Magento 2 Experten — Hyvä Theme, Tailwind CSS & SEO aus einer Hand ›

Integrating Alpine Filter Logic: Category Buttons, Live Filtering Without a Page Reload

Integrating Alpine Filter Logic: Category Buttons, Live Filtering Without a Page Reload

~9 Min. Lesezeit Zuletzt aktualisiert am August 9, 2026

Now the interactive part of the team page comes together: category buttons that filter the grid live, with no page reload at all. The pattern is deliberately very similar to the accordion example from chapter 16 - except here it's not the visibility of individual entries that's decided, but whether they belong to the active category.

Step 1: embedding team data as JSON in x-data

As shown with the mini cart example in chapter 15, we embed the team data supplied by the ViewModel as JSON directly into x-data - from there, the entire filter logic runs purely client-side, with no further server request.

app/code/Mironsoft/TeamPage/view/frontend/templates/team/index.phtml (excerpt)
<?php

declare(strict_types=1);

/** @var \Magento\Framework\Escaper $escaper */
/** @var \Magento\Framework\View\Element\Template $block */
/** @var \Magento\Framework\Serialize\Serializer\Json $json */
/** @var \Mironsoft\TeamPage\ViewModel\TeamMembers $teamViewModel */
$teamViewModel = $block->getData('team_view_model');
$json = $block->getData('json_serializer');
?>
<div
    class="mx-auto max-w-6xl px-4 py-12 md:px-8"
    x-data="teamFilter(<?= /* @noEscape */ $json->serialize($teamViewModel->getTeamMembers()) ?>)"
>

json_serializer is bound as a second argument in the Layout XML - just like the ViewModel itself, only this time pointing at a Magento core class instead of custom code:

<argument name="json_serializer" xsi:type="object">Magento\Framework\Serialize\Serializer\Json</argument>

Step 2: the Alpine.data() component

As learned in chapter 16, a named Alpine.data() component is worth it for several related methods, instead of a plain inline object:

Inline script in team/index.phtml
<script>
    document.addEventListener('alpine:init', () => {
        Alpine.data('teamFilter', (members) => ({
            members: members,
            activeCategory: 'alle',

            setCategory(category) {
                this.activeCategory = category;
            },

            isActive(category) {
                return this.activeCategory === category;
            },

            get visibleMembers() {
                if (this.activeCategory === 'alle') {
                    return this.members;
                }
                return this.members.filter(
                    (member) => member.category === this.activeCategory
                );
            },
        }));
    });
</script>

visibleMembers is a getter - it recomputes the currently visible list on every access, based on activeCategory. That's the core of live filtering: when activeCategory changes from a button click, Alpine automatically recomputes visibleMembers and updates the DOM - with no manual rebuilding of the list.

Step 3: the category buttons

Category buttons above the grid
<div class="mb-8 flex flex-wrap gap-2">
    <?php foreach ($teamViewModel->getCategories() as $category): ?>
        <button
            type="button"
            @click="setCategory('<?= $escaper->escapeJs($category['key']) ?>')"
            :class="isActive('<?= $escaper->escapeJs($category['key']) ?>')
                ? 'bg-brand-accent text-white'
                : 'bg-slate-100 text-brand-slate hover:bg-slate-200'"
            class="rounded-full px-4 py-2 text-sm font-semibold transition"
        >
            <?= $escaper->escapeHtml($category['label']) ?>
        </button>
    <?php endforeach; ?>
</div>

Two escaper methods are used deliberately here: escapeJs() for the category key, since it lands inside a JavaScript expression (@click="..."), and escapeHtml() for the visible button text - exactly the escaper rules from chapter 8, now applied concretely.

Step 4: switching the grid to visibleMembers

The final change replaces the PHP foreach loop from chapter 20 with a client-side Alpine iteration using x-for (officially x-for combined with a <template> tag, the standard pattern for lists in Alpine):

The grid as an Alpine template
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
    <template x-for="member in visibleMembers" :key="member.name">
        <article class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
            <div class="mb-4 h-40 w-full rounded-lg bg-slate-100"></div>
            <h3 class="text-lg font-bold text-brand-dark" x-text="member.name"></h3>
            <p class="text-sm text-brand-slate" x-text="member.role"></p>
        </article>
    </template>
</div>

Important difference from chapter 20: since the grid is now built entirely client-side from the embedded JSON data, $escaper isn't used here anymore - x-text sets text content and automatically escapes it in the process (Alpine internally sets textContent, not innerHTML), just like $escaper->escapeHtml() does on the PHP side.

Achtung: Important: x-text is safe because it sets text content. x-html, on the other hand, inserts raw HTML and would be the wrong choice here - just like unguarded PHP output without $escaper (chapter 8).

Testing the current state

After a bin/cache-clean, /team now shows the four category buttons - clicking "Development" instantly filters the grid down to Anna and David, clicking "All" shows all four cards again. All without a page reload.

Tipp: In the browser dev tools, you can directly watch the Network tab and confirm that clicking a category button triggers no new request to the server - the best proof that filtering genuinely happens client-side.