Skip to content

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.

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

Baseline · newly available

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 moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
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.

terminal
npx shadcn@latest add https://moonarc.dev/r/filter-grid.json

The 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.astro
---
/**
 * 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

PropTypeDefaultDescription
filters{ label: string; value: string }[]noneThe buttons; a card matches when its data-tags contains the value.
allstring | false'All'Label of the everything button; false for none.
multibooleanfalseSeveral filters at once, any match.
syncbooleanfalseMirror the selection in the URL (?filter=web,brand) and read it on load.
namestring'filter'The query parameter for sync.
columnsnumber3Grid columns.
labelstring'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

Replaces: Isotope · LayoutGroup grid (Framer Motion) · FilterableGrid (Motion Primitives) · MixItUp. See the migration table.

Study 1Study 2Study 3

click · grows from its thumbnail · ← →

A 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.

newly · Chrome 111 · Firefox 144 · Safari 18click
Release

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.

Shows blog posts as cards, with the first post featured across two columns.

newly · Chrome 111 · Firefox 144 · Safari 18scrollhoverclick

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown