Usage
The Twig API
Every method takes an optional set handle. With one set — every Lite install and most Pro ones — leave it out and Compare works out which set is meant.
State
{{ craft.compare.count() }} {# how many are on the visitor's list #}
{{ craft.compare.has(product) }} {# is this one on it? #}
{{ craft.compare.isFull() }}
{{ craft.compare.items() }} {# the elements, in list order #}
{{ craft.compare.list() }} {# the CompareList model #}
{{ craft.compare.url() }} {# the standalone comparison page #}
{{ craft.compare.setFor(product) }} {# which set this element belongs to #}
{{ craft.compare.sets }}
Nothing in the Twig API writes. A compare list changes only through a POST, which is what keeps a comparison out of reach of a prefetching browser or a crawler following links.
Ready-made markup
{{ craft.compare.button(product) }}
{{ craft.compare.button(product, {
text: 'Add to compare',
addedText: 'Added',
class: 'btn btn--ghost',
}) }}
{{ craft.compare.bar() }}
The button renders a real <button> inside a real <form> posting to Compare's toggle action, so
it works with JavaScript switched off — it just costs a page load. The runtime intercepts the same
markup and turns it into a fetch. There is no separate "JS version" to keep in step.
The table
{{ craft.compare.render() }} {# the visitor's own list #}
{{ craft.compare.render(products) }} {# these elements, right now #}
{{ craft.compare.render(null, { differencesOnly: true }) }}
render(products) is how you put a "compare our three plans" table on a landing page without
anybody adding anything.
Building it yourself
{% set table = craft.compare.table() %}
{% if table and not table.isEmpty() %}
<table>
<thead>
<tr>
<th></th>
{% for element in table.elements %}
<th><a href="{{ element.url }}">{{ element }}</a></th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in table.getVisibleRows(false) %}
<tr class="{{ row.isUniform() ? 'same' : 'differs' }}">
<th>{{ row.getLabel() }}</th>
{% for cell in row.cells %}
<td>{{ cell.isEmpty() ? '—' : cell.html }}</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
<p>{{ table.getDifferenceCount() }} of these rows differ.</p>
{% endif %}
getVisibleRows(true) returns only the rows that differ. Rows no element answered are always
dropped — a whole row of blanks tells a shopper nothing and costs them a screenful.
Rolling your own controls
Any element carrying the right data attributes is picked up, including ones added to the page later by your own JavaScript — the handlers are delegated, so infinite scroll and filtered listings work with no re-initialisation.
<button data-compare-toggle="{{ product.id }}" data-compare-set="products">Compare</button>
<span data-compare-count="products">0</span>
<a href="{{ craft.compare.url() }}" data-compare-open="products">Compare now</a>
<button data-compare-clear="products">Clear</button>
<button data-compare-remove="{{ product.id }}" data-compare-set="products">Remove</button>
To keep an icon inside a button whose label changes, mark the text node:
<button data-compare-toggle="{{ product.id }}">
<svg>…</svg><span data-compare-label>Compare</span>
</button>
Events and the JS handle
document.addEventListener('compare:changed', (e) => console.log(e.detail.sets));
document.addEventListener('compare:toggled', (e) => {
console.log(e.detail.elementId, e.detail.list, e.detail.error);
});
window.craftCompare.refresh(); // re-read state from the server
window.craftCompare.open('products'); // open the modal
window.craftCompare.state(); // current sets
Overriding the markup
| To replace | Create |
|---|---|
| The comparison table | templates/compare/_table.twig |
| The comparison page | templates/compare/index.twig |
| One set's table only | Set a Custom table template on the set |
Table templates receive table, set, differencesOnly, settings and plugin.
The JSON API
Every endpoint the bundled runtime uses is public and documented, so you can switch Load the bundled front end off and drive it from your own build.
| Method | Endpoint | Body |
|---|---|---|
GET | /compare/session.json | — |
POST | compare/list/add | elementId |
POST | compare/list/remove | elementId |
POST | compare/list/toggle | elementId |
POST | compare/list/clear | set |
POST | compare/list/reorder | set, ids[] |
GET | compare/list/table | set, optional ids, differencesOnly, format=json |
POST | compare/list/share | set (Pro) |
Start with session.json. It returns a fresh CSRF token and the current state of every set:
{
"csrfTokenName": "CRAFT_CSRF_TOKEN",
"csrfToken": "…",
"loggedIn": false,
"mayCompare": true,
"sets": {
"products": { "count": 2, "max": 4, "full": false, "items": [ … ] }
}
}
Why that endpoint exists
Compare buttons live on product cards, and product cards live on cached pages — Craft's
{% cache %}, Blitz, a CDN. The moment such a page is served from cache two things are wrong: the
CSRF token baked into it is stale, and the button does not know whether the visitor already added
this product.
So the runtime never trusts the HTML it was rendered into. It fetches the session, takes the token from there, and reconciles the buttons. Cached and uncached pages then behave identically.
A refusal is a 200, not an error. A full list or "please sign in" comes back as
success: false with a human-readable error — those are answers the front end has to say out
loud, not faults.
const s = await (await fetch('/compare/session.json', {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
})).json();
const body = new FormData();
body.append(s.csrfTokenName, s.csrfToken);
body.append('elementId', 1234);
const res = await (await fetch('/actions/compare/list/toggle', {
method: 'POST',
headers: { Accept: 'application/json' },
credentials: 'same-origin',
body,
})).json();
if (!res.success) alert(res.error);
credentials: 'same-origin' is what carries the guest cookie. Without it every visitor looks like
a brand new one on browsers that default to omitting credentials.
Adding your own row type
use craft\events\RegisterComponentTypesEvent;
use justinholtweb\compare\services\Rows;
use yii\base\Event;
Event::on(Rows::class, Rows::EVENT_REGISTER_ROW_TYPES, function(RegisterComponentTypesEvent $e) {
$e->types[] = MyRatingRow::class;
});
Extend justinholtweb\compare\rows\BaseRow, implement type(), displayName() and
resolve(ElementInterface $element): Cell, and return a Cell whose value is a normalized
comparable rather than the markup — that is what difference highlighting reads.