Skip to content

Components / Sections

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

tone
density
columns

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

Baseline · newly available

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:

terminal
npx shadcn@latest add @moonarc-pro/work-grid
src/pages/index.astro
---
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

PropTypeDefaultDescription
projects{ href: string; title: string; client?: string; year?: string | number; disciplines: string[]; cover: string | { src, srcset?, width?, height? }; clip?: string | string[]; name?: string }[]noneThe 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 }[]noneThe filter buttons, in order. Each prints how many projects carry its value, which is part of its name (Identity 3).
headlinestringnoneThe section's heading. Slot "headline" wins.
eyebrowstringnoneA short line above the headline, in mono. Slot "eyebrow" wins.
copystringnoneA paragraph under the headline. Slot "copy" wins.
level1 | 21Heading level of the headline: 1 on the work page itself, 2 under another h1.
allstring | false'All'Label of the everything button; false for none.
filterLabelstring'Filter by discipline'Accessible name of the filter group.
statusstring'{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.
syncbooleanfalseMirror the chosen filter in the URL (?discipline=web) and read it on load.
paramstring'discipline'The query parameter for sync.
columns2 | 32Columns on a wide screen: one on a phone, two from 40 rem of the section's width, three from 64 rem where asked.
ratiostring'4 / 3'Aspect ratio of the covers and the clips.
playLabelstring'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

Replaces: Portfolio grids (Framer studio templates) · FilterableGrid (Motion Primitives) · Isotope portfolio. See the migration table.

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.

newly · Chrome 137 · Firefox 144 · Safari 18.4click

01 / 04 · Sample case study

Harbour Lines

A mark, a typeface and a sign system for an imaginary ferry line, read from a moving deck.

Client
A sample ferry line
Year
2026
Role
Identity, wayfinding, motion
Credits
Sample names
A sample cover: a blue field with a pale circle

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

Chrome 115 · not Firefox · Safari 26scrollclick

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