Compare for Craft CMS

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 replaceCreate
The comparison tabletemplates/compare/_table.twig
The comparison pagetemplates/compare/index.twig
One set's table onlySet 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.

MethodEndpointBody
GET/compare/session.json
POSTcompare/list/addelementId
POSTcompare/list/removeelementId
POSTcompare/list/toggleelementId
POSTcompare/list/clearset
POSTcompare/list/reorderset, ids[]
GETcompare/list/tableset, optional ids, differencesOnly, format=json
POSTcompare/list/shareset (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.