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.6 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;
}
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}</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-edge);
border-radius: 999px;
background: 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;
}
:where(.ma-fgrid__filter[aria-pressed='true']) {
background: var(--ma-ink);
border-color: var(--ma-ink);
color: var(--ma-panel);
opacity: 1;
}
: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: 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 }[] | none | The buttons; a card matches when its data-tags contains the value. |
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.
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.
The logo
The site wears its logo kit: the mark in the header and the footer, and the same drawing on the share cards, the favicons and the home screen icon.
The site measures before it prints, and an About page
The library is Moonarc
The home stops carrying 141 demos
Phase 7F.3: the default palette
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