Building a Product Comparison Table with Filterable Columns in Alpine.js
AI generated
x-data
Alpine
Alpine.js / Practical Case Study
Product Comparison Table with Filterable Columns
comparing several products side by side and filtering with purpose

A comparison table sounds like a simple HTML table with a few columns at first. But once several products with differing numbers of attributes need to sit side by side, a differences-only view is required, and the table still has to work on a smartphone, it turns into a genuinely small application with its own state. Alpine.js is a good fit for exactly this task because row and column logic can be expressed entirely declaratively in the markup, without needing dedicated build tooling or a framework like React.

10 min read Differences-only filter Dynamic rows Responsive scroll container

1. The data model: products as columns, attributes as rows

The first design decision is picking the right data structure. Instead of hand-typing the table row by row in HTML, it pays off to use an array of attribute objects, where each object carries an attribute label plus an array holding one value per product. This structure makes it easy to later hide an entire row without having to maintain several places in the markup at once.

The products themselves live in a separate array that only holds name, image, and price, and feeds the table header. This way the mapping between header and data rows stays consistent, because both structures are referenced through the same index and can never drift apart independently.


function productCompare() {
    return {
        onlyDifferences: false,
        products: [
            { name: 'Model A', image: '/img/a.jpg', price: '$129.00' },
            { name: 'Model B', image: '/img/b.jpg', price: '$159.00' },
            { name: 'Model C', image: '/img/c.jpg', price: '$99.00' },
        ],
        attributes: [
            { label: 'Weight', values: ['1.2 kg', '1.4 kg', '1.2 kg'] },
            { label: 'Battery life', values: ['10 h', '14 h', '8 h'] },
            { label: 'Water resistance', values: ['IPX4', 'IPX7', 'IPX4'] },
            { label: 'Warranty', values: ['2 years', '2 years', '2 years'] },
        ],
        get visibleAttributes() {
            if (!this.onlyDifferences) {
                return this.attributes;
            }
            return this.attributes.filter(
                (attr) => new Set(attr.values).size > 1
            );
        },
    };
}

2. Practical example: implementing the differences-only filter with a getter

The real value of a comparison table comes from the differences-only filter, which hides rows where every product shares the same value. Instead of tucking this logic into a method that would need to be called manually on every render, a getter is the better fit, since it is automatically re-evaluated as a computed value whenever onlyDifferences or the underlying data changes.

In the visibleAttributes getter from the previous example, a Set is built from the values in a row. If that set contains only a single unique element, every product shares the same value for that attribute, and the row gets filtered out while the filter is active. This approach requires no manual counting and works regardless of how many products are actually being compared.


<div x-data="productCompare()">
    <label class="inline-flex items-center gap-2">
        <input type="checkbox" x-model="onlyDifferences">
        <span>Show only differences</span>
    </label>

    <div class="overflow-x-auto mt-4">
        <table class="min-w-full text-sm">
            <thead>
                <tr>
                    <th class="text-left">Attribute</th>
                    <template x-for="product in products" :key="product.name">
                        <th x-text="product.name"></th>
                    </template>
                </tr>
            </thead>
            <tbody>
                <template x-for="attr in visibleAttributes" :key="attr.label">
                    <tr>
                        <td x-text="attr.label" class="font-medium"></td>
                        <template x-for="(value, i) in attr.values" :key="i">
                            <td x-text="value"></td>
                        </template>
                    </tr>
                </template>
            </tbody>
        </table>
    </div>
</div>

3. Responsive rendering when a table has many columns on small screens

Once a table reaches four or five product columns, it becomes unreadable on a smartphone, even with horizontal scrolling, because the leftmost attribute-label column scrolls out of view. A proven fix is a sticky-positioned first column inside a horizontally scrollable container, so the attribute label always stays visible while the product columns slide sideways.

An alternative, and often the better solution for very narrow viewports, is switching the presentation entirely: instead of a table, each product gets rendered as its own card, with attributes listed as a definition list. Alpine.js can drive this switch through x-show combined with Tailwind breakpoint classes, so desktop users keep seeing the table while mobile users automatically get the card view, without maintaining two separate data sources.


<div class="hidden md:block sticky-first-col">
    <!-- Table view from the md breakpoint up, first column sticky via CSS -->
</div>
<div class="md:hidden space-y-4" x-data="productCompare()">
    <template x-for="(product, i) in products" :key="product.name">
        <div class="border rounded-lg p-4">
            <h3 x-text="product.name" class="font-semibold"></h3>
            <dl class="mt-2 space-y-1 text-sm">
                <template x-for="attr in visibleAttributes" :key="attr.label">
                    <div class="flex justify-between">
                        <dt x-text="attr.label" class="text-gray-500"></dt>
                        <dd x-text="attr.values[i]"></dd>
                    </div>
                </template>
            </dl>
        </div>
    </template>
</div>

4. Dynamically removing individual products from the comparison

In practice, users frequently want to remove one product from a comparison without reloading the whole page. A method that removes from both the products array and the matching index of every row's values array keeps header and data rows in sync. It is important to always determine the index first and consistently use that same index for both arrays, rather than searching by product name, which could theoretically occur more than once.

When the last remaining comparison slot gets removed, the component should automatically show a hint and exit comparison mode instead of rendering an empty table. That can simply be checked with an additional condition in the template that inspects products.length.


removeProduct(index) {
    this.products.splice(index, 1);
    this.attributes.forEach((attr) => {
        attr.values.splice(index, 1);
    });
}

5. Visually highlighting the best value in each row

Beyond the plain differences filter, users often want the best value in a row visually highlighted, for instance the lowest price or the longest battery life. Because attributes differ in their comparison direction, higher is better for battery life, lower for price, each attribute object needs an additional comparison direction that a helper function evaluates while rendering.

A compact solution is a per-row getter that returns the numerically parsed best value, compared against the current cell value through a :class binding in the template. Values that are not numeric, such as free-text material descriptions, are simply excluded from the best-value logic and always rendered neutrally.

6. Keeping the selected comparison products in sync with the URL

When the comparison list is assembled from the product listing and a user arrives on the comparison page through a shared link, the selection should be reconstructable from the URL. A good approach is keeping the product IDs as a comma-separated query parameter and reading it from URLSearchParams during the component's init().

If the user changes the selection during the session, for instance by removing a product, a $watch on products updates the URL through history.replaceState, without creating an extra browser history entry. That keeps the comparison view shareable at any time, even after individual products have been removed.


init() {
    const params = new URLSearchParams(window.location.search);
    const ids = params.get('compare');
    if (ids) {
        this.loadProductsByIds(ids.split(','));
    }
    this.$watch('products', () => {
        const ids = this.products.map((p) => p.id).join(',');
        const url = new URL(window.location.href);
        url.searchParams.set('compare', ids);
        history.replaceState({}, '', url);
    });
}

7. Performance with many attribute rows and products

With comparisons involving twenty or more attribute rows and several products, every change to onlyDifferences re-evaluates the entire getter, which can become noticeable with very large data sets. A simple optimization is not recalculating the set of unique values on every render, but precomputing the result once when the data loads as an additional hasDifference field per row.

For x-for loops it also matters that a stable, unique identifier is used as the :key, such as the attribute label or a product ID, rather than the loop index. Otherwise, Alpine.js unnecessarily discards and rebuilds existing DOM nodes on filter changes instead of merely showing or hiding them, which can cause visible flicker with more complex rows that carry their own local state.

8. Accessibility: preserving table semantics despite dynamic filtering

A dynamically filtered table stays usable for screen readers only if native table semantics are preserved: th elements with a matching scope attribute for row and column headers are mandatory, as is a meaningful caption describing what is currently being compared. When a row gets removed by the filter, it should ideally disappear from the DOM entirely rather than just being hidden visually through CSS, since Alpine.js x-for already adds and removes DOM nodes anyway.

The differences-only toggle itself should be built as a real input type="checkbox" with an associated label, not a plain div with a click handler, so keyboard users and screen readers pick up the state correctly. An aria-live="polite" region that announces the current count of visible rows after a filter change noticeably improves the experience for users of assistive technology.

9. Limits of this approach and when a server-side solution pays off

The purely client-side comparison table shown here works well for a manageable number of products and attributes that are already fully embedded as JSON in the page or fetched with a single API request. If instead hundreds of products with widely varying attribute sets need to be compared, for instance in a B2B catalog with per-category attribute groups, the client-side approach hits its limits, because the initial data volume becomes too large.

In such cases, a hybrid solution pays off, where Alpine.js remains responsible for interactive filtering and rendering, while the actual attribute selection gets pre-filtered server-side, for instance through a dedicated GraphQL query returning only the needed attributes. That keeps the client-side logic simple and fast while keeping the data volume under control.

Aspect Plain table Alpine.js comparison table Practical relevance
Data structure Hardcoded in HTML Attribute array with a values array per row Rows can be filtered centrally
Differences filter Not available Getter with set-based check No manual re-rendering needed
Mobile rendering Horizontal scroll without context Card view via breakpoint switch Better readability on smartphones
URL synchronization Not available Query parameter plus history.replaceState Comparison stays shareable
Scaling Manual, error-prone Limits with very large data sets Pre-filter server-side beyond hundreds of products

Mironsoft

Alpine.js interactivity for Hyvä frontends

A Hyvä frontend that needs more interactivity, but without React overhead?

We build interactive frontend components for Hyvä themes with Alpine.js, lightweight and without build-step complexity, from simple toggles to complex form flows.

Custom Components

Develop interactive Alpine.js components for specific shop requirements.

Performance Review

Review existing Alpine.js implementations for reactivity pitfalls and performance.

Team Training

Bring developers up to speed on Alpine.js patterns for Hyvä themes hands-on.

10. Summary

Product Comparison Table with Alpine.js: The Essentials at a Glance

Data model

Attributes as an array with a values array per product, header driven by a separate products array, both linked through the same index.

Differences-only filter

A getter builds a set from each row's values and hides rows with only one unique value while the filter is active.

Responsive behavior

From four or five columns onward, a sticky first column or a full switch to a card view on small screens is recommended.

Limits

With very many products and heterogeneous attribute sets, server-side pre-filtering pays off over pure client logic.

11. FAQ: Product Comparison Table with Alpine.js: The Essentials at a Glance

1How should the data model for an Alpine.js comparison table be structured?
As an array of attribute objects with a values array per product, plus a separate products array for the header, both linked through the same index.
2How does the differences-only filter work technically?
A getter builds a set from all values in a row. If the set has only one element, every value is identical, and the row is filtered out while the filter is active.
3Why use a getter instead of a method for the filter logic?
A getter is automatically re-evaluated as a computed value whenever the filter or the data changes, without needing a manual method call in the template.
4How is the table kept readable on small screens?
Either through a sticky-positioned first column inside a scrollable container, or by switching entirely to a card view using Tailwind breakpoints.
5How are individual products removed from the comparison?
Through a method that removes by index from both the products array and each row's values array, keeping header and data rows in sync.
6How can the best value in a row be highlighted?
Each attribute needs a comparison direction, higher or lower is better, and a getter returns the numerically best value for comparison via a class binding.
7How does a comparison selection stay shareable?
Product IDs are kept as a query parameter in the URL, read on initialization, and updated on changes through history.replaceState.
8What should be watched regarding x-for and performance?
A stable, unique key instead of the loop index prevents unnecessary rebuilding of DOM nodes on filter changes.
9How does the table stay accessible?
Through real th elements with a scope attribute, a meaningful caption, a real checkbox for the filter, and optionally an aria-live region for the result count.
10When does a server-side solution pay off over pure Alpine.js?
With very many products and heterogeneous attribute sets, for instance in large B2B catalogs, where the initial client-side data volume would become too large.