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.
Table of Contents
- 1. The data model: products as columns, attributes as rows
- 2. Practical example: implementing the differences-only filter with a getter
- 3. Responsive rendering when a table has many columns on small screens
- 4. Dynamically removing individual products from the comparison
- 5. Visually highlighting the best value in each row
- 6. Keeping the selected comparison products in sync with the URL
- 7. Performance with many attribute rows and products
- 8. Accessibility: preserving table semantics despite dynamic filtering
- 9. Limits of this approach and when a server-side solution pays off
- 10. Summary
- 11. FAQ
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.