registry:ui · Layout
card-grid — the subgrid equalizer
Shared header and body rows keep card tops aligned and bodies filled to the tallest: grid + subgrid, works with any two-block card. A layout LANDLORD: it stamps the universal lanes and forwards them to the tenants it hosts — its own paint stays structural.
$npx jixoai-ui add card-grid
https://ui.jixoai.com/r/card-grid.json
overview
Overview
A layout landlord: structural geometry on itself, the universal lanes forwarded to whoever it hosts.
CardGrid renders one auto-fit grid — repeat(auto-fit, minmax(min(100%, var(--jx-grid-min)), 1fr)) — plus the
subgrid equalization law: the grid defines two shared rows (header / body), every
direct child spans both and opts into grid-template-rows: subgrid, so
headers align to the tallest header and bodies fill to the tallest body at any column
count. The cards come from YOUR children snippet — the grid never asks what a child
is; any two-block card qualifies, and data-no-subgrid opts a child out.
A lone child is capped at the 46rem editorial measure instead of stretching
banner-wide.
The eight axes resolve on the section as a landlord: the grid's OWN paint is one
fixed 20px gap plus structural geometry — density-inert by construction, because the
space ladder hangs off the :root-anchored --jx-unit that no rung scope
re-bases. What the axes move is the SUPPLY: the named density rung stamps the scope
the tenants live in, and their --jx-inset/--jx-stack channels re-base (measured 12 / 16 / 20px tenant padding across the rungs). Kinship,
named precisely: grid and stack are the sibling layout
primitives riding the same fixed space ladder — layout families are
supply-only-on-self by construction. The grid is a LEAF that composes nothing and is
composed by nothing in the fleet (grep receipt: only the blueprint scenes mount it).
usage
Usage
Feed it any two-block cards; the grid never asks what a child is — section-card qualifies unchanged.
<script lang="ts">
import CardGrid from '@ui/card-grid.svelte';
import SectionCard from '@ui/section-card.svelte';
</script>
<CardGrid min={260}>
<SectionCard eyebrow="alpha" title="Short header" summary="One summary line.">
<p>Body.</p>
</SectionCard>
<SectionCard
eyebrow="beta"
title="A deliberately much longer header that wraps"
summary="A longer summary pushing the shared header row taller — every header aligns to it."
>
<ul class="flex flex-col gap-2">
<li>body fills to the tallest row</li>
<li>…</li>
</ul>
</SectionCard>
<!-- not a two-block card? opt the child out of the shared rows -->
<div data-no-subgrid><!-- anything --></div>
</CardGrid>card-grid
Grid + subgrid equalizer: cards span the grid's two shared rows (header / body), so every header aligns to the tallest header and every body fills to the tallest body — no ragged tops, no unequal bottoms. The slider drives the collapse width; the checkbox opts card 03 out.
card 01
Short header
One summary line — this header is short, yet it reserves the same shared row height as card 02's taller block.
A short body. The subgrid row still stretches it to the tallest card's extent.
body row: shared
card 02
A deliberately much longer header that wraps to two lines
The tallest header block sets the shared header row for every card in the grid — resize the stage with the Playground slider and watch the alignment hold at every column count.
- headers align to the tallest header
- bodies fill to the tallest body
- rows live on the GRID, not on each card
body row: shared
card 03
Opted out — data-no-subgrid
This card is the same two-block card as the others while the Playground checkbox is off; flip it to watch the opt-out collapse the shared-row contract.
Same law as cards 01 and 02 — spanning and subgridding the shared rows.
body row: shared
<script lang="ts">
import CardGrid from '@ui/card-grid.svelte';
import SectionCard from '@ui/section-card.svelte';
</script>
<CardGrid min={260}>
<SectionCard eyebrow="alpha" title="Short header" summary="One summary line.">
<p>Body.</p>
</SectionCard>
<SectionCard
eyebrow="beta"
title="A deliberately much longer header that wraps"
summary="A longer summary pushing the shared header row taller — every header aligns to it."
>
<ul class="flex flex-col gap-2">
<li>body fills to the tallest row</li>
<li>…</li>
</ul>
</SectionCard>
</CardGrid>law
Rows live on the grid
The grid defines the two shared rows and every child subgrids into them; a card never measures its siblings. That is why the alignment survives any column count, any card content, and any resize — and why opting out is a single attribute instead of a prop.
- the GRID owns
grid-template-rows: auto 1fr; each childgrid-row: span 2+grid-template-rows: subgrid— no JS, no measurement, no ResizeObserver - works with any two-block card: the grid never asks what the child is — section-card qualifies unchanged
- the opt-out is an attribute, not a prop:
data-no-subgridon a wrapper keeps the cell but restores the child's own rows
<script lang="ts">
import CardGrid from '@ui/card-grid.svelte';
import SectionCard from '@ui/section-card.svelte';
</script>
<CardGrid min={260}>
<SectionCard eyebrow="alpha" title="Short header" summary="One summary line.">
<p>Body.</p>
</SectionCard>
<SectionCard
eyebrow="beta"
title="A deliberately much longer header that wraps"
summary="A longer summary pushing the shared header row taller — every header aligns to it."
>
<ul class="flex flex-col gap-2">
<li>body fills to the tallest row</li>
<li>…</li>
</ul>
</SectionCard>
<!-- not a two-block card? opt the child out of the shared rows -->
<div data-no-subgrid><!-- anything --></div>
</CardGrid>types
Card grid variants
One layout, three postures: the shared-row grid, the opted-out cell, and the lone-child cap.
shared rows (default)
Every child spans auto 1fr and subgrids — headers align, bodies fill.
data-no-subgrid opt-out
A wrapper attribute keeps the cell but restores the child's own rows — not a prop.
lone child
auto-fit would stretch a lone card into a banner; it is capped at an editorial measure.
a11y
Accessibility
Pure layout — no semantics added or removed; the entrance stagger respects reduced motion.
Keyboard
| Key | Action |
|---|---|
| — | Not interactive — a layout container; children keep their own semantics |
ARIA
| Attribute | Value | Description |
|---|---|---|
| (none) | — | The grid adds no roles or labels; DOM order is the reading order. |
| prefers-reduced-motion | reduce | The internal entrance cascade is skipped — cards render fully visible. |
theming
Density and tokens
The landlord's own rhythm is density-inert — the 20px gap holds at every rung (the space ladder hangs off the :root --jx-unit); what density moves is the tenants, through the re-based inset/stack channels.
card 01
Shared header
Header row shared across the grid.
Body fills to the tallest row.
card 02
Another header
Second card in the density demo.
The gap and rows are fixed; density does not rescale the grid.
card 01
Shared header
Header row shared across the grid.
Body fills to the tallest row.
card 02
Another header
Second card in the density demo.
The gap and rows are fixed; density does not rescale the grid.
card 01
Shared header
Header row shared across the grid.
Body fills to the tallest row.
card 02
Another header
Second card in the density demo.
The gap and rows are fixed; density does not rescale the grid.
card 01
Shared header
Header row shared across the grid.
Body fills to the tallest row.
card 02
Another header
Second card in the density demo.
The gap and rows are fixed; density does not rescale the grid.
| Token | Default | Source |
|---|---|---|
| --jx-grid-min | 320px (min prop) | component |
| --jx-card-i | 0–7 | component |
| stagger step | 70ms (capped at 8th child) | structural |
| gap | 20px at every rung (measured) | structural |
| --jx-gap | 8 / 8 / 12 / 16px | density |
| --jx-inset | 8 / 8 / 12 / 16px | density |
axes
The eight axes on card-grid
A FIRST-TIME all-no-own contract (census batch B): the landlord stamps every carrier and reads none — its own paint is one fixed gap plus structural geometry. The axis story is the SUPPLY CHAIN: the named density rung stamps the scope the tenants live in and their inset/stack channels re-base (measured section-card header padding 8px 12px at xs/sm, 12px 16px at default, 12px 20px at lg — block/inline); size stamps the §11 echo nothing follows (the fixed-voice contrast case); theme is BRIDGE-ONLY (the .dark lands, the grid paints nothing, the tenants speak through their own emission forms). The carriers stamp the section root (the promoted root is self-carried), greppable in the raw SSR.
Reading the table: Property is the axis, Type is the real carrier or consumption it drives on THIS family, Default is the lane default — the named steps, number unit, and consumption are in each description.
| Property | Type | Default | Description |
|---|---|---|---|
| size | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' | SUPPLY-ONLY, the stamp lands and nothing follows (the heading-contrast case) — the §11 echo stamps the root's font-size inline (measured: exactly 14px at size={14}), and every hero voice in the grid's own paint is structural: the columns template, the fixed 20px gap, the subgrid rows — none is em-of-parent. The tenants hold their own fixed --text-* voices; only consumer-authored em typography would scale. The §11 echo measured verbatim on the root style attr. Number unit: px. |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' | SUPPLY-ONLY — stamps --jx-shape-effective and --jx-radius-factor-effective; no family css reads them (grep receipt: zero readers, zero corner declarations in ui/card-grid/ — the grid draws no boxes; the tenants' corners are their own law). Number unit: none. |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' | SUPPLY-ONLY — stamps --jx-radius-effective; no family css reads it (grep receipt: zero readers, zero border-radius in the family css). The concentric broadcast reaches the tenants through the §11 supply, and each tenant's own radius law answers. Number unit: px. |
| density | 'small' | 'medium' | 'large' | 'auto' | number (+ the five legacy spellings) | 'auto' | SUPPLY-ONLY ON SELF, CONSUMED BY COMPOSITION — measured both halves. ON SELF: the grid's own 20px gap is INERT at every rung (measured 20px at xs/sm/default/lg) — the space ladder hangs off --space-20 = calc(var(--jx-unit) × 5) and --jx-unit is declared at :root only, which no rung scope re-bases (the declaring-element law). ON THE TENANTS: the named rung stamps data-density on the section root — the scope block IS the tenants' home — and the re-based --jx-inset/--jx-stack/--jx-text channels step the composed cards: measured SECTION-CARD header padding 8px 12px (xs) · 8px 12px (sm) · 12px 16px (default) · 12px 20px (lg), block/inline. Card tenants' chrome is frozen (the card family's own law) — the moving tenant on this page is the section-card. The NUMBER lane is inert (the coefficient stamps, nothing re-declares). Number unit: coefficient. |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' | SUPPLY-ONLY — stamps --jx-color-effective; no family css reads it (grep receipt: zero readers — the grid paints no hue; the transparent-ground + contrast-filter fusion belongs to inline-code, not here). Tenant hue is each card's own paint. Number unit: hue degrees. |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' | BRIDGE-ONLY — measured: a resolved dark lands the .dark class on the section root, and the GRID'S OWN PAINT has nothing theme-able to move (transparent ground, one structural gap) — the section holds at every scope. The pole is the TENANTS', decided through their own emission forms: measured, a Card tenant under the grid's bridge holds its light paint (the typed-layer frozen pole); a raw-var tenant would flip through the same bridge. The grid supplies the island; the guests speak for themselves. light and system stamp nothing — tree inheritance. No number lane. |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'auto' | SUPPLY-ONLY — stamps --jx-elevation-effective; no family css reads it (grep receipt: zero readers, zero box-shadow in the family css — the grid casts nothing; tenant shadows are tenant law). Number unit: dp. |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' | SUPPLY-ONLY ON THE AXIS — the carrier --jx-motion-effective is unread (grep receipt: zero readers). The entrance cascade is the family's OWN law, deliberately outside the axis: an internal IntersectionObserver arms .is-entered when the grid scrolls into view, then cards rise on the TIME axis with per-index delays (70ms × index, capped at the 8th child; 320/420ms opacity/transform legs; fill-mode backwards — plays at first paint, no hydration wait), html.js keeps no-JS and pre-hydration fully visible, and prefers-reduced-motion kills the cascade entirely. Animation, not transition-on-the-axis. Number unit: coefficient. |
Deviations, cited: the adoption is the census batch B row (explicit-props W3-B — openspec/changes/explicit-props/research/migration-census.md); the carriers set is pinned by test/docs-ambient-vocabulary.spec.ts (card-grid in expectedCarriers). The §1 collision rule: the root is a <div> — no native attribute names at stake; every axis name is the family's own destructured lane. The density row's two halves are measured, not inferred: gap inert at every rung; the section-card tenant stepping 8px 12px → 12px 16px → 12px 20px (block/inline) across the rungs.
<!-- density: SUPPLY-ONLY on self (the 20px gap holds at
every rung — the space ladder hangs off the :root --jx-unit);
CONSUMED BY COMPOSITION (the rung scope re-bases the tenants'
--jx-inset/--jx-stack channels — visible on section-card tenants) -->
<CardGrid density="small">
<SectionCard eyebrow="tenant one" title="the guests re-base">
the grid holds; the tenant steps
</SectionCard>
</CardGrid>
<!-- size: the §11 echo stamps the root's font-size — the tenants'
own --text-* voices give it nothing to scale -->
<CardGrid size={14}>…</CardGrid>card-grid · the supply chain
tenant one
the rung steps the guests
the grid supplied density small · size 14
tenant two
every axis forwards the same way
header padding at the small rung: 8px 12px (measured)
Measured: the section's own 20px gap and structural geometry hold; the SECTION-CARD tenants' header padding steps to the small rung (8px 12px block/inline) through the stamped scope — the supply chain is the point. A Card tenant would hold still (its chrome is frozen by its own law).
<!-- density: SUPPLY-ONLY on self (the 20px gap holds at
every rung — the space ladder hangs off the :root --jx-unit);
CONSUMED BY COMPOSITION (the rung scope re-bases the tenants'
--jx-inset/--jx-stack channels — visible on section-card tenants) -->
<CardGrid density="small">
<SectionCard eyebrow="tenant one" title="the guests re-base">
the grid holds; the tenant steps
</SectionCard>
</CardGrid>
<!-- size: the §11 echo stamps the root's font-size — the tenants'
own --text-* voices give it nothing to scale -->
<CardGrid size={14}>…</CardGrid>card-grid · theme bridge
tenant one
the bridge lands, the landlord holds
theme="dark" — .dark on the section root
tenant two
the tenant speaks for itself
its own emission form decides what flips
The bridge-only specimen: the resolved dark lands the .dark class on the grid's root — and the GRID'S OWN PAINT has nothing to move (transparent ground, one structural gap). What answers is each tenant, through its own emission form.
<!-- theme: BRIDGE-ONLY — the resolved scope lands .dark on the
grid's root; the landlord's own paint is structural (transparent
ground, the 20px gap unmoved — measured identical in both scopes).
What answers is each tenant, through its own emission form: raw
var() reads flip at the consumer, typed emissions freeze at :root. -->
<CardGrid theme="dark" min="220px">
<SectionCard eyebrow="tenant one" title="the bridge lands, the landlord holds">
its own emission form decides what flips
</SectionCard>
</CardGrid><script lang="ts">
import CardGrid from '@ui/card-grid.svelte';
import SectionCard from '@ui/section-card.svelte';
import { query } from '@lib/universal-props-query.svelte';
import type { DensityLane } from '@lib/defaults.svelte';
</script>
<!-- below 48rem the base (small) applies — the guests breathe tight;
at 48rem+ the md case wins and they step to the default rung.
The tenants are SECTION-CARDS: their header padding carries the
rung (measured 8px 12px ↔ 12px 16px block/inline across the key) -->
<CardGrid density={query<{ md: DensityLane }, DensityLane>({ md: 'default' }, 'small')}>
<SectionCard eyebrow="tenant one" title="the rung rides the viewport">…</SectionCard>
<SectionCard eyebrow="tenant two" title="compact below, roomy above">…</SectionCard>
</CardGrid>card-grid · query()
tenant one
the rung rides the viewport
watch the header padding breathe
tenant two
compact below 48rem, roomy above
measured 8px 12px ↔ 12px 16px (block/inline)
Media keys are min-width: below 48rem the base applies — the small rung, tight tenants; at 48rem and wider the md case wins — the default rung, roomy tenants (measured section-card tenant header padding 8px 12px ↔ 12px 16px block/inline across the key). The density lane is a STRING lane: both generics are load-bearing. Resize across 48rem.
<script lang="ts">
import CardGrid from '@ui/card-grid.svelte';
import SectionCard from '@ui/section-card.svelte';
import { query } from '@lib/universal-props-query.svelte';
import type { DensityLane } from '@lib/defaults.svelte';
</script>
<!-- below 48rem the base (small) applies — the guests breathe tight;
at 48rem+ the md case wins and they step to the default rung.
The tenants are SECTION-CARDS: their header padding carries the
rung (measured 8px 12px ↔ 12px 16px block/inline across the key) -->
<CardGrid density={query<{ md: DensityLane }, DensityLane>({ md: 'default' }, 'small')}>
<SectionCard eyebrow="tenant one" title="the rung rides the viewport">…</SectionCard>
<SectionCard eyebrow="tenant two" title="compact below, roomy above">…</SectionCard>
</CardGrid>| Token | Default | Source |
|---|---|---|
| --jx-grid-min | 320px (the min prop) | component |
| --jx-card-i | 0–7 | component |
| the stagger step | 70ms (capped at the 8th child) | structural |
| gap | 20px at every density rung (measured) | structural |
| --jx-gap / --jx-inset | rung scale (xs/sm 8 · default 12 · lg 16px) | density |
| the lone-card cap | max-width: 46rem | structural |
| the subgrid rows | auto 1fr (foot mode: auto 1fr auto) | structural |
api
API
The table renders from the GENERATED meta + curation; the eight axis rows split into the shared section beneath. 12 meta rows − 8 ambient axes = 4 family rows (min, foot, class, children) — no rest row (the family spreads nothing: the children snippet and the two structural props are the whole surface); no EXTRA lane.
Properties
| Property | Type | Default | Description |
|---|---|---|---|
| min | string | '320px' | Minimum column width before the grid collapses a column (any CSS length). |
| foot | boolean | false | declares the third shared row — zone-trio cards' FEET align at the bottom; false = head/body only. |
| class | string | '' | Forwarded to the grid container. |
| children | Snippet | — | The cards; each child spans the two shared rows. |
Universal props
| Property | Type | Default | Description |
|---|---|---|---|
| size | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | the base scale — root font-size; parts size in em |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' · ambient scope | corner geometry (CSS corner-shape; §14 degrade table) |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | corner size; auto = the concentric broadcast (§3) |
| density | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | spacing/leading scale over the kernel channels (§4) |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' · ambient scope | the hue axis of the fixed oklch primary system (§5) |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' · ambient scope | light/dark profile; system = the JS-mutable global (§6) |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'auto' · ambient scope | official M3 levels over the surface ladder (§7) |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' · ambient scope | intensity across the motion kernels (§8) |