# Filter Grid (Moonarc)

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.

- Import: `import FilterGrid from '@moonarc/core/FilterGrid'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/filter-grid.json`
- Tier B · category ui · trigger click
- Readout: `<FilterGrid multi>`
- Browser support: newly (Chrome 137 · Firefox 144 · Safari 18.4); elsewhere: without view transitions the cards change places at once; without match-element the grid cross-fades and no card slides
- Measured cost: 987 B raw JS · 535 B gzip · + runtime + morph (with dependencies 3.4 kB raw; CSS 5.6 kB raw)
- Page: https://moonarc.dev/components/filter-grid/

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

## Usage

```astro
<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>
```

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

## Craft

- 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

## Source

```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>

```
