jixoai-ui
the terminal design language
loading the style sheet…
Skip to content
docs · print medium: screen

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.

install — card-grid

$npx jixoai-ui add card-grid

https://ui.jixoai.com/r/card-grid.json

subgrid rows any two-block card min prop lanes forwarded to tenants

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.

CardGrid usage
<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

card-grid-usage.svelte svelte
<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 child grid-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-subgrid on a wrapper keeps the cell but restores the child's own rows
usage
<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

KeyAction
—Not interactive — a layout container; children keep their own semantics

ARIA

AttributeValueDescription
(none)—The grid adds no roles or labels; DOM order is the reading order.
prefers-reduced-motionreduceThe 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.

xs

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.

sm

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.

default

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.

lg

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.

TokenDefaultSource
--jx-grid-min320px (min prop)component
--jx-card-i0–7component
stagger step70ms (capped at 8th child)structural
gap20px at every rung (measured)structural
--jx-gap8 / 8 / 12 / 16pxdensity
--jx-inset8 / 8 / 12 / 16pxdensity

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.

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

the eight axes on card-grid
<!-- 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).

card-grid-universal.svelte svelte
<!-- 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.

card-grid-theme-bridge.svelte svelte
<!-- 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>
one real query() case
<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.

card-grid-query-demo.svelte svelte
<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>
TokenDefaultSource
--jx-grid-min320px (the min prop)component
--jx-card-i0–7component
the stagger step70ms (capped at the 8th child)structural
gap20px at every density rung (measured)structural
--jx-gap / --jx-insetrung scale (xs/sm 8 · default 12 · lg 16px)density
the lone-card capmax-width: 46remstructural
the subgrid rowsauto 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

PropertyTypeDefaultDescription
min string'320px'Minimum column width before the grid collapses a column (any CSS length).
foot booleanfalsedeclares 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

PropertyTypeDefaultDescription
size'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopethe base scale — root font-size; parts size in em
shape'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto''auto' · ambient scopecorner geometry (CSS corner-shape; §14 degrade table)
radius'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopecorner size; auto = the concentric broadcast (§3)
density'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopespacing/leading scale over the kernel channels (§4)
color'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string'auto' · ambient scopethe hue axis of the fixed oklch primary system (§5)
theme'light' | 'dark' | 'system' | 'auto''auto' · ambient scopelight/dark profile; system = the JS-mutable global (§6)
elevation'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number'auto' · ambient scopeofficial M3 levels over the surface ladder (§7)
motion'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number'auto' · ambient scopeintensity across the motion kernels (§8)
jixoai-ui ui.jixoai.com the jixoai design language

Theme

Brand hue

0°

Auto cycle · 24h