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.
Work Grid
The whole body of work as a grid of covers with filters by discipline. The filters are real buttons (aria-pressed, each with its count), the order of the grid is kept, and the cards that stay slide to their places through a view transition (FilterGrid); a status line says how many projects show and is spoken after each press. A project with a clip plays it on hover (HoverVideo, caption under the clip; never under reduced motion), the others show their cover, and each card is wiped open from its foot as it enters (Reveal clip). Each cover can carry the project's view transition name, so with Astro's router it grows into the case study.
Live demo
Live, from the library itself, at full size: scroll, hover, navigate away and back. The demo is never gated.
Variants · each row writes the attribute the prop writes; the section's own CSS answers
Measured
JavaScript of its own
443 B raw
317 B gzip.
Uses Reveal and the shared runtime (1.9 kB raw, once per site) and HoverVideo and FilterGrid and the shared morph helper (1.1 kB raw, once per site). With those included: 5.8 kB raw.
CSS 19.4 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, has. Elsewhere: without view transitions the cards change places at once; without match-element the grid cross-fades; without JavaScript every card shows and the filters and the count are not offered.
Install Pro
The demo above is never gated; the source is. With a key in components.json (see account), the registry serves it like any other item:
npx shadcn@latest add @moonarc-pro/work-grid---
import WorkGrid from '@/components/moonarc/pro/WorkGrid.astro';
---
<WorkGrid
headline="Work"
filters={[
{ label: 'Identity', value: 'identity' },
{ label: 'Web', value: 'web' },
{ label: 'Motion', value: 'motion' },
]}
projects={[
{ href: '/work/harbour/', title: 'Harbour Lines', client: 'Harbour Transit', year: 2026, disciplines: ['identity', 'motion'], cover: '/work/harbour.webp', clip: ['/work/harbour.webm', '/work/harbour.mp4'], name: 'work-harbour' },
{ href: '/work/field-notes/', title: 'Field Notes', client: 'Field Press', year: 2025, disciplines: ['web'], cover: '/work/field-notes.webp', name: 'work-field-notes' },
]}
sync
/>Without a key the registry answers 401 with an upgrade message. Everything this section composes is free: filter-grid, hover-video, reveal.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
projects | { href: string; title: string; client?: string; year?: string | number; disciplines: string[]; cover: string | { src, srcset?, width?, height? }; clip?: string | string[]; name?: string }[] | none | The projects in the grid's order. disciplines are the filter values a project answers to; clip (webm first, then mp4) plays on hover with the cover as its poster; name is the cover's view transition name, written as Astro's transition:name writes it, the same the case study's hero carries. |
filters | { label: string; value: string }[] | none | The filter buttons, in order. Each prints how many projects carry its value, which is part of its name (Identity 3). |
headline | string | none | The section's heading. Slot "headline" wins. |
eyebrow | string | none | A short line above the headline, in mono. Slot "eyebrow" wins. |
copy | string | none | A paragraph under the headline. Slot "copy" wins. |
level | 1 | 2 | 1 | Heading level of the headline: 1 on the work page itself, 2 under another h1. |
all | string | false | 'All' | Label of the everything button; false for none. |
filterLabel | string | 'Filter by discipline' | Accessible name of the filter group. |
status | string | '{n} of {total} projects' | The count over the filters, a role="status" line spoken after each press: {n} the projects shown, {total} all of them. |
sync | boolean | false | Mirror the chosen filter in the URL (?discipline=web) and read it on load. |
param | string | 'discipline' | The query parameter for sync. |
columns | 2 | 3 | 2 | Columns on a wide screen: one on a phone, two from 40 rem of the section's width, three from 64 rem where asked. |
ratio | string | '4 / 3' | Aspect ratio of the covers and the clips. |
playLabel | string | 'Play the clip' | Accessible name of a clip's play button, shown on touch screens and under reduced motion. |
tone | 'surface' | 'muted' | 'ink' | 'brand' | unset (paints nothing) | The ground: the page surface, a muted band, an always-dark ink panel, or a deep brand panel. The pressed filter is the theme's primary pair, the tone's text pair on ink and brand. |
density | 'compact' | 'cozy' | 'roomy' | 'cozy' | Block padding and the grid's gaps ×0.8 / ×1 / ×1.25 through --ma-density. |
Theming and overrides
The section reads the theme contract (shadcn's variable names with the base tokens as fallback), so a pasted theme or one --ma-brand recolours it. Its class names are the override surface and stay stable; a named slot replaces the matching prop with your own markup.
Classes
.ma-fgrid.ma-fgrid__grid.ma-workgrid.ma-workgrid__copy.ma-workgrid__eyebrow.ma-workgrid__head.ma-workgrid__headline.ma-workgrid__img.ma-workgrid__inner.ma-workgrid__link.ma-workgrid__meta.ma-workgrid__status.ma-workgrid__still.ma-workgrid__title
Slots
slot="eyebrow"slot="headline"slot="copy"
Reduced motion
The cards change places at once when a filter is pressed, no clip plays on hover (a play button is the only way to start one), and each card fades in where it was wiped.
With ClientRouter
FilterGrid, HoverVideo and the count bind through the shared runtime and are released before the swap. Cards carry a view transition name of their own only where name is set, and the grid's own names exist only while a filter transition runs, so a navigation captures one element per project at most.
Why it is built this way
- The count is a role="status" line the section's script rewrites from the hidden attribute itself (a MutationObserver), not from the click: FilterGrid changes the cards inside a view transition's update, a frame after the press.
- The grid keeps its order: a filter hides the cards that do not match, it never sorts, so a reader who knows the third project finds it third.
- Each button prints its count, so a filter that would empty the grid says so before it is pressed.
- A clip only where a project has one, with preload="none": the grid costs its covers until someone hovers, and a touch screen gets a play button instead of a hover it cannot make.
- The pressed filter is the theme's primary pair, the grid's one accent; everything else is the neutral ramp.
Replaces: Portfolio grids (Framer studio templates) · FilterableGrid (Motion Primitives) · Isotope portfolio. See the migration table.
Related
A poster card that plays a muted clip while the pointer or focus is on it and rewinds when it leaves.
The top of a case study: the project's title at display size, its facts in a grid (client, year, role, credits), the cover that opens from a rounded inset to the full width as the page scrolls (ScrollExpand), then a before-and-after (ImageCompare) or a gallery that opens full size (Lightbox).
Sample projects
Selected work
The work as a numbered index: one line per project with the title at display size and the client, the discipline and the year in mono, the whole line a link.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown