patrickkunka/mixitup
MixItUp is a high-performance, dependency-free JavaScript library for animated filtering, sorting, inserting, and paginating. It delivers smooth CSS3 transitions, responsive grid layouts, custom controls, callbacks, and easy integration with any framework or CMS. Great for galleries and dynamic cont...
patrickkunka/mixitup is not a Composer package — it is a dependency-free, vanilla JavaScript library for animated client-side filtering, sorting and DOM insertion/removal. You will use it almost entirely inside Blade views and resources/js, not inside controllers. Treat it as a front-end utility that pairs with a server-rendered list.
Install and wire it up
npm install [email protected] # pin explicitly, see Gotchas
// resources/js/app.js
import mixitup from 'mixitup';
window.mixitup = mixitup;
{{-- layouts/app.blade.php --}}
@vite(['resources/js/app.js'])
A CDN <script> tag works too if you do not have Vite, but a pinned npm dependency is strongly preferred for a 2018-era, archived library.
Smallest working example
<div id="grid" class="mixitup-container">
<div class="mix category-a" data-price="12">A</div>
<div class="mix category-b" data-price="20">B</div>
</div>
<button data-filter=".category-a">Category A</button>
<button data-filter="all">All</button>
<button data-sort="price:asc">Price ↑</button>
mixitup('#grid');
MixItUp picks up .mixitup-container, .mix, and any [data-filter] / [data-sort] control automatically. Controls can live anywhere in the document — they do not have to be inside the container.
Where to look first
mixitup's README and the docs at mixitup.io (the v3 "Configuration" and "API" pages are the only reference you need).dist/mixitup.js — it is unminified enough to grep when behaviour surprises you.The canonical Laravel pattern: one query, one Blade render, all interaction in the browser.
// ProductController@index
$products = Product::with('category')->orderBy('name')->get();
$categories = Category::orderBy('name')->get();
<div id="product-grid" class="mixitup-container">
<div class="mixitup-controls">
<button class="mixitup-control" data-filter="all">All</button>
@foreach ($categories as $category)
<button class="mixitup-control" data-filter=".{{ $category->slug }}">
{{ $category->name }}
</button>
@endforeach
<button class="mixitup-control" data-sort="price:asc">Price ↑</button>
<button class="mixitup-control" data-sort="price:desc">Price ↓</button>
</div>
@foreach ($products as $product)
<article class="mix {{ $product->category->slug }}"
data-price="{{ $product->price }}"
data-name="{{ $product->name }}">
@include('products._card', ['product' => $product])
</article>
@endforeach
</div>
data-filter takes a CSS selector. Using the category slug as a class keeps the Blade loop trivial; if slugs can contain characters invalid in a class (leading digit, dots), normalise them into a data-category and filter with [data-category="..."] instead. Note that MixItUp's own controls read data-filter, so if you use attribute selectors you must also teach it to escape them — simpler to emit a safe filter-class accessor on the model.
// resources/js/grid.js
const config = {
selectors: { target: '.mix' },
animation: { duration: 250, effects: 'fade scale(0.95)' },
load: { filter: 'all' },
layout: { display: 'inline-block' },
};
export function initGrid(selector = '.mixitup-container', overrides = {}) {
const el = document.querySelector(selector);
return el ? mixitup(el, { ...config, ...overrides }) : null;
}
Then initGrid('#product-grid') from app.js or from an Alpine component. One mixer per container; keep a reference (window.mixer or a module-scoped map) so you can call it later.
data-* attributedata-sort="price:asc" maps to the data-price attribute on each target. Guard against lexicographic ordering by supplying a comparator when values are numeric:
mixer.sort((a, b) => Number(a.dom.el.dataset.price) - Number(b.dom.el.dataset.price));
Custom comparators are the reliable escape hatch; the attribute-based sorter is fine for names and dates in ISO format.
MixItUp has its own pagination: { limit: 12 }. Do not stack it on top of Laravel's paginate() — you will get 12 items client-side paginated out of 12 server-side items. Two coherent choices:
limit and its pager buttons.pagination: { limit: Infinity } or simply omit it).Mixing both is the most common source of "why does the pager do nothing" bugs.
MixItUp caches the target list at init. New DOM is invisible to it until you tell it.
// after fetch() from a Laravel JSON endpoint
const html = await fetch(`/products?category=${id}`).then(r => r.text());
const tpl = document.createElement('div');
tpl.innerHTML = html.trim();
tpl.querySelectorAll('.mix').forEach(el => mixer.insert(el));
For Livewire, the simplest robust pattern is to re-initialise the mixer whenever the component re-renders, using wire:key on the loop so Livewire does not reuse nodes in a way MixItUp has already positionally transformed:
document.addEventListener('livewire:init', () => {
Livewire.hook('morph.updated', ({ el }) => {
const container = el.closest('.mixitup-container');
if (!container) return;
container.__mixer?.destroy();
container.__mixer = mixitup(container, config);
});
});
Alpine works well as the lifecycle owner:
<div x-data
x-init="$el.__mixer = mixitup($el, window.mixitupConfig)"
@destroy="$el.__mixer?.destroy()"
class="mixitup-container">
Always destroy() before re-initialising in the same element. Without it you accumulate event listeners and animation timers.
mixer.on('mixEnd', (state) => {
const visible = state.activeFilter.selector;
window.dataLayer?.push({ event: 'grid_filter', filter: visible });
document.querySelectorAll('.mix img[data-src]').forEach(img => {
if (img.offsetParent !== null) img.src = img.dataset.src;
});
});
mixStart, mixEnd, mixFail (filter matched nothing) and mixClick are the useful ones. mixFail is where you toggle an empty-state message.
Animations make assertions flaky. Ship a test-only config:
mixitup(container, { animation: { duration: 0, enable: false } });
Or gate on document.body.dataset.env === 'testing' set from your layout. Then assert visibility with Dusk's assertVisible/assertNotVisible on .mix elements.
The project is archived (last release 2018). Pin the exact version in package.json, commit your lockfile, and vendor the dist file if you need long-term stability. There will be no fixes for future browser changes; budget a migration path (Shuffle.js, or plain Alpine + CSS View Transitions) if the feature becomes business-critical.
Only direct children are targets. mixer scans .mixitup-container > .mix. Wrapping your card in an extra <div> per iteration silently breaks filtering — the classic Blade @foreach indentation mistake.
Flash of unfiltered content. The server renders every item; MixItUp hides them after init. Either set load: { filter: 'all' } and accept a brief flash, or hide pre-init:
.mixitup-container:not(.mixitup-init) .mix { visibility: hidden; }
and toggle mixitup-init on mixEnd (or immediately after mixitup() returns).
Numeric strings sort wrong. data-price="9" vs "10" can order as 10, 9. Zero-pad the attribute or use a function comparator.
layout.display must match your CSS. If your cards are display: flex or a CSS grid, set layout: { display: 'inline-block' } and let your own CSS handle the visual grid, or MixItUp's inline styles will fight your stylesheet. Nested display: grid containers on .mixitup-container are generally fine; on .mix they are not.
No-JS and SEO. MixItUp adds nothing for crawlers or users without JS — which is fine here, because the full list is already in the HTML. Do not "optimise" by fetching items via AJAX; you lose the fallback for zero user-facing benefit.
Accessibility is on you. MixItUp toggles display, so filtered-out items leave the tab order correctly, but it adds no ARIA. Mark controls with aria-pressed and keep an aria-live="polite" count ("Showing 12 of 48 products") updated from mixEnd. Respect prefers-reduced-motion by shipping animation: { duration: 0 } when it matches.
Don't nest mixers. Two mixers over overlapping containers produce contradictory inline styles that are painful to debug. One container, one mixer.
Bundle size. The full dist is ~50 KB minified. There is no meaningful tree-shaking for a 2018 UMD/ESM-era build; if that matters, import only what you use or load it lazily on the pages that need it.
data-sort/data-filter on non-controls. Any element carrying data-filter becomes a control, including things you did not intend (e.g. a link with data-filter for analytics). Namespace custom attributes and pass explicit selectors: { control: '.mixitup-control' } to avoid accidental wiring.
Extension points: mixitup.use(pluginFn) registers a state/API extension, and mixer.getState(), mixer.getItems() give you read access. For anything beyond filter/sort/insert, prefer driving the DOM yourself and calling mixer.destroy()/re-init rather than writing a plugin against an unmaintained API.
How can I help you explore Laravel packages today?