A poster card that plays a muted clip while the pointer or focus is on it and rewinds when it leaves.
Components / UI
Filter Grid
Filter buttons over a grid of cards: the cards that stay slide to their new places, the ones that go fade out, the ones that return fade in. The motion is a same-document view transition, so the browser measures and moves them. The script toggles aria-pressed and hidden; each card is a transition group only for the duration through view-transition-name: match-element. Without JavaScript every card shows; under reduced motion they change places at once.
Live demo
Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.
Adjust · props are custom properties, set inline, so you edit the running instance
Measured
JavaScript of its own
987 B raw
535 B gzip.
Uses the shared runtime (1.9 kB raw, once per site) and the shared morph helper (1.1 kB raw, once per site). With those included: 3.4 kB raw.
CSS 5.8 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.
Browser support
Chrome 137 · Firefox 144 · Safari 18.4; needs view-transitions, match-element. Elsewhere: without view transitions the cards change places at once; without match-element the grid cross-fades and no card slides.
Install
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
import FilterGrid from '@moonarc/core/FilterGrid';
---
<FilterGrid filters={[{ label: 'Web', value: 'web' }, { label: 'Brand', value: 'brand' }, { label: 'Motion', value: 'motion' }]} sync>
<li data-tags="web motion"><a href="/work/one/">…</a></li>
<li data-tags="brand"><a href="/work/two/">…</a></li>
<li data-tags="web"><a href="/work/three/">…</a></li>
</FilterGrid>Copy it into your project instead (shadcn registry)
Owns the file, no dependency. The registry item also installs the shared runtime, the morph helper and the base tokens.
npx shadcn@latest add https://moonarc.dev/r/filter-grid.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
Source
The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy runtime.ts and morph.ts.
---
/**
* FilterGrid — filter buttons over a grid; the cards that stay slide to
* their new places, the ones that go fade out, the ones that return fade
* in. The move is a same-document view transition through lib/morph: each
* card is a group (view-transition-name: match-element) only for the
* duration, so a router swap never sees them; an engine without
* match-element cross-fades the grid. The script toggles aria-pressed and
* hidden — the geometry is the browser's. Without JavaScript every card
* shows and the bar is hidden; under reduced motion the cards change
* places at once.
*/
import type { HTMLAttributes } from 'astro/types';
interface Filter {
label: string;
value: string;
/** How many cards carry this value, printed after the label (part of the button's name). */
count?: number;
}
interface Props extends HTMLAttributes<'div'> {
filters: Filter[];
/** Label of the "everything" button; false for none. */
all?: string | false;
/** Several filters at once (any match). */
multi?: boolean;
/** Mirror the selection in the URL as ?<name>=a,b and read it on load. */
sync?: boolean;
/** The query parameter for sync. */
name?: string;
/** Grid columns. */
columns?: number;
/** Accessible name of the filter group. */
label?: string;
}
const { filters, all = 'All', multi = false, sync = false, name = 'filter', columns = 3, label = 'Filter', class: className, style, ...rest } = Astro.props;
const inline = [`--ma-fgrid-cols:${columns}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---
<div class:list={['ma-fgrid', className]} data-ma-fgrid data-multi={multi ? '' : undefined} data-sync={sync ? name : undefined} style={inline} {...rest}>
<div class="ma-fgrid__bar" role="group" aria-label={label}>
{all !== false && <button type="button" class="ma-fgrid__filter" aria-pressed="true" data-value="">{all}</button>}
{filters.map((f) => <button type="button" class="ma-fgrid__filter" aria-pressed="false" data-value={f.value}>{f.label}{f.count != null && <span class="ma-fgrid__count">{` ${f.count}`}</span>}</button>)}
</div>
<ul class="ma-fgrid__grid">
<slot />
</ul>
</div>
<style is:global>
@layer components {
:where(.ma-fgrid__bar) {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-bottom: 1rem;
}
/* no script yet (or ever): every card shows, the bar is not offered */
:where(.ma-fgrid:not([data-live]) .ma-fgrid__bar) {
display: none;
}
:where(.ma-fgrid__filter) {
padding: 0.35em 0.85em;
border: 1px solid var(--ma-fgrid-edge, var(--ma-edge));
border-radius: 999px;
background: var(--ma-fgrid-bg, var(--ma-panel));
color: inherit;
font: inherit;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
opacity: 0.8;
transition:
background-color var(--ma-duration-fast) var(--ma-ease-out),
color var(--ma-duration-fast) var(--ma-ease-out),
opacity var(--ma-duration-fast) var(--ma-ease-out),
scale var(--ma-duration) var(--ma-ease);
}
:where(.ma-fgrid__filter:hover) {
opacity: 1;
}
:where(.ma-fgrid__filter:active) {
scale: 0.96;
}
/* the pressed filter: two hooks, so a page or a section gives it its accent without touching this rule */
:where(.ma-fgrid__filter[aria-pressed='true']) {
background: var(--ma-fgrid-on-bg, var(--ma-ink));
border-color: var(--ma-fgrid-on-bg, var(--ma-ink));
color: var(--ma-fgrid-on-fg, var(--ma-panel));
opacity: 1;
}
/* the space before the number is text, so the button's name reads "Identity 3", not "Identity3". The number is set
lighter, never fainter: at 0.7 inside a resting button's 0.8 it painted at 0.56 and read 3.96 : 1 on a light page
(7J, measured over the rendered ground by the template contrast gate) */
:where(.ma-fgrid__count) {
font-variant-numeric: tabular-nums;
font-weight: 400;
}
:where(.ma-fgrid__filter:focus-visible) {
outline: 2px solid currentColor;
outline-offset: 2px;
}
:where(.ma-fgrid__grid) {
display: grid;
grid-template-columns: repeat(var(--ma-fgrid-cols, 3), minmax(0, 1fr));
gap: var(--ma-fgrid-gap, 0.75rem);
margin: 0;
padding: 0;
list-style: none;
}
/* hidden is the filter: a card's own display (a flex card, a utility class) must not bring it back. The UA's [hidden]
rule loses to any author display; an important declaration in a layer beats every normal one */
:where(.ma-fgrid__grid > [hidden]) {
display: none !important;
}
/* each card is a group only while the filter transition runs — match-element names them, nothing is written by the script */
:where(:root[data-ma-vt='filter']) .ma-fgrid__grid > * {
view-transition-name: match-element;
}
:where(:root)[data-ma-vt='filter']::view-transition-group(*) {
animation-duration: var(--ma-duration);
animation-timing-function: var(--ma-ease);
}
:where(:root)[data-ma-vt='filter']::view-transition-old(*):only-child,
:where(:root)[data-ma-vt='filter']::view-transition-new(*):only-child {
animation-duration: var(--ma-duration-fast);
animation-timing-function: var(--ma-ease-out);
}
@media (prefers-reduced-motion: reduce) {
:where(.ma-fgrid__filter) {
transition: none;
}
}
}
</style>
<script>
import { onMount } from '../lib/runtime';
import { vt } from '../lib/morph';
onMount<HTMLElement>('[data-ma-fgrid]', (root, { signal }) => {
const btns = [...root.querySelectorAll<HTMLButtonElement>('.ma-fgrid__filter')];
const items = [...root.querySelectorAll<HTMLElement>('.ma-fgrid__grid > *')];
const key = root.dataset.sync;
const press = (b: HTMLElement, on: boolean) => (b.ariaPressed = `${on}`);
const on = () => btns.filter((b) => b.ariaPressed == 'true').map((b) => b.dataset.value!).filter(Boolean);
const update = () => {
const a = on();
items.forEach((li) => (li.hidden = !!a.length && !a.some((v) => ` ${li.dataset.tags} `.includes(` ${v} `))));
if (key) {
const u = new URL(location.href);
a.length ? u.searchParams.set(key, a.join(',')) : u.searchParams.delete(key);
history.replaceState(history.state, '', u);
}
};
root.addEventListener(
'click',
(e) => {
const b = (e.target as Element).closest<HTMLButtonElement>('.ma-fgrid__filter');
if (!b) return;
// one at a time, or (multi) toggle and keep "all" pressed exactly when nothing else is
if (!b.dataset.value || !('multi' in root.dataset)) btns.forEach((x) => press(x, x === b));
else {
press(b, b.ariaPressed != 'true');
btns.forEach((x) => x.dataset.value || press(x, !on().length));
}
vt(update, 'filter');
},
{ signal },
);
const init = key && new URL(location.href).searchParams.get(key);
if (init) {
btns.forEach((b) => press(b, init.split(',').includes(b.dataset.value!)));
update();
}
root.dataset.live = '';
});
</script>Props
| Prop | Type | Default | Description |
|---|---|---|---|
filters | { label: string; value: string; count?: number }[] | none | The buttons; a card matches when its data-tags contains the value. count prints after the label and is part of the button's name (Identity 3). |
all | string | false | 'All' | Label of the everything button; false for none. |
multi | boolean | false | Several filters at once, any match. |
sync | boolean | false | Mirror the selection in the URL (?filter=web,brand) and read it on load. |
name | string | 'filter' | The query parameter for sync. |
columns | number | 3 | Grid columns. |
label | string | 'Filter' | Accessible name of the button group. |
Reduced motion
The cards change places at once; the buttons keep their pressed state.
With ClientRouter
Bound through the shared runtime. The cards carry a view-transition-name only during a filter transition, so a navigation snapshots nothing of them; sync uses replaceState, which the router respects.
Why it is built this way
- A view transition does what FLIP libraries do (measure, move, fade) in the compositor and with nothing measured by hand: the script changes hidden, the browser draws the rearrangement.
- Groups exist only during the transition: view-transition-name: match-element under html[data-ma-vt="filter"], so a ClientRouter swap never captures a hundred cards and the script writes no names. The per-card naming fallback for engines without match-element (Chrome 137, Firefox 144, Safari 18.4) was built, cost 170 B raw that the tier did not have, and went: those engines get the cross-fade.
- Cards that leave fade on the fast token (old snapshot only), cards that arrive fade in the same, cards that stay glide on the preset curve. That is three rules and one shared root cross-fade from base.css.
- aria-pressed on real buttons, in a group with a name: the state is the ARIA, and the CSS styles the attribute.
- The filter is the hidden attribute, held by one important declaration in the components layer: the browser's own [hidden] rule loses to any author display, so a card styled as a flex box would otherwise stay on screen.
- Without the script the bar is not shown, because its buttons would do nothing.
- The buttons read --ma-fgrid-bg and --ma-fgrid-edge, the pressed one --ma-fgrid-on-bg and --ma-fgrid-on-fg (the ink and panel tokens by default), and the grid --ma-fgrid-gap and --ma-fgrid-cols: a section gives the active filter its accent and the grid its rhythm through them.
Replaces: Isotope · LayoutGroup grid (Framer Motion) · FilterableGrid (Motion Primitives) · MixItUp. See the migration table.
Related
Lightbox
UIA thumbnail gallery whose images open full size: the image grows out of its own thumbnail on a same-document view transition and returns to it on close, and ← → move through the set.
Endpoint, Homescreen, Estate, Practice and Stay, templates for developer products, mobile apps, estate agents, practices and places to stay, and thirty sections
Endpoint, a template for a product with an HTTP API: a console on the home page that sends a request and streams the response back with its status, its time and its region, an API reference written at build from your OpenAPI document, a quickstart and four guides, usage pricing with a slider, a status page, the regions on a map, a changelog with a feed, search and a contact page for sales.
This site in Plus Jakarta Sans and JetBrains Mono
The terms, privacy and refunds, checked against Indonesian law
Core and the MCP server on npm
Creator, a template for one person's work, and seven sections
Blog Grid
ProShows blog posts as cards, with the first post featured across two columns.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown