Card
{% card %} is a generic content card: a bordered surface with an optional media zone, a body, and an optional footer. It carries no knowledge of the registry or $item — it's a plain presentational component you can drop into prose, or feed from a collection body template.
There is one generic card (named by shape, not by entity) — refrakt deliberately has no article-card / recipe-card / per-entity card runes. A designed list item is {% card %} fed by entity fields, not a bespoke card per type.
{% card href="/runes/collection" %}
---
### Collection rune
The plural counterpart to `ref` and `expand` — query the registry and render the results.
---
Core rune · see also `expand`
{% /card %}<div data-rune="card" data-rune-fields="{"media-position":"top"}">
<div data-name="body">
<h3 id="collection-rune" data-name="title">Collection rune</h3>
<p>
The plural counterpart to
<code>ref</code>
and
<code>expand</code>
— query the registry and render the results.
</p>
</div>
<footer data-name="footer">
<p>
Core rune · see also
<code>expand</code>
</p>
</footer>
<a data-name="link" href="/runes/collection" data-link="stretch"></a>
</div>Collection rune
The plural counterpart to ref and expand — query the registry and render the results.
<div class="rf-card" data-media-position="top" data-rune="card" data-density="full">
<div data-name="content" class="rf-card__content">
<div data-name="body" class="rf-card__body" data-section="body">
<h3 id="collection-rune" data-name="title" class="rf-card__title">Collection rune</h3>
<p>
The plural counterpart to
<code>ref</code>
and
<code>expand</code>
— query the registry and render the results.
</p>
</div>
<footer data-name="footer" class="rf-card__footer">
<p>
Core rune · see also
<code>expand</code>
</p>
</footer>
</div>
<a data-name="link" href="/runes/collection" data-link="stretch" class="rf-card__link"></a>
</div>Zones
The body divides on --- (a horizontal rule) into up to three zones — here all three, with a real cover image:
{% card href="/runes/learning/recipe" media-position="start" %}

---
Brunch classic
### Tequila Sunrise
A bright, layered cocktail — five minutes, no shaker.
---
Cocktail · Easy
{% /card %}<div data-rune="card" data-rune-fields="{"media-position":"start"}">
<div data-section="media" data-name="media">
<img src="https://assets.refrakt.md/tequila-sunrise.png" alt="A tequila sunrise cocktail">
</div>
<p data-name="eyebrow">Brunch classic</p>
<div data-name="body">
<h3 id="tequila-sunrise" data-name="title">Tequila Sunrise</h3>
<p>A bright, layered cocktail — five minutes, no shaker.</p>
</div>
<footer data-name="footer">
<p>Cocktail · Easy</p>
</footer>
<a data-name="link" href="/runes/learning/recipe" data-link="stretch"></a>
</div><div class="rf-card" data-media-position="start" data-rune="card" data-density="full">
<div data-section="media" data-name="media" class="rf-card__media" data-guest-fit="clip" data-guest-posture="presentational">
<img src="https://assets.refrakt.md/tequila-sunrise.png" alt="A tequila sunrise cocktail" />
</div>
<div data-name="content" class="rf-card__content">
<p data-name="eyebrow" class="rf-card__eyebrow" data-section="eyebrow">Brunch classic</p>
<div data-name="body" class="rf-card__body" data-section="body">
<h3 id="tequila-sunrise" data-name="title" class="rf-card__title">Tequila Sunrise</h3>
<p>A bright, layered cocktail — five minutes, no shaker.</p>
</div>
<footer data-name="footer" class="rf-card__footer">
<p>Cocktail · Easy</p>
</footer>
</div>
<a data-name="link" href="/runes/learning/recipe" data-link="stretch" class="rf-card__link"></a>
</div>- media (optional, leading zone) — laid out beside the body on wide screens and as a full-bleed header on small screens.
- body — the main content.
- footer (optional, trailing zone) — a muted meta row.
Zones are assigned by count: 1 zone → body; 2 → media + body; 3 → media + body + footer. To have a footer with no media, lead with an empty zone (--- body --- footer).
In the body zone, a leading paragraph immediately followed by a heading is treated as an eyebrow — the small uppercase kicker above the title (the same pattern as section and recipe). Above, Brunch classic is the eyebrow over the Tequila Sunrise heading.
The media zone isn't just images
The media zone holds any content — it's ordinary markdown, transformed in place. An image is the common case, but a {% codegroup %}, a {% sandbox %}, a chart, or a video embed work equally well:
{% card %}
{% codegroup %}
```js
export const sum = (a, b) => a + b;
```
```py
def sum(a, b): return a + b
```
{% /codegroup %}
---
### Tiny utilities
A starter kit of one-liners.
{% /card %}<div data-rune="card" data-rune-fields="{"media-position":"top"}">
<div data-section="media" data-name="media">
<section data-rune="code-group">
<div role="tablist" data-name="tabs">
<button data-name="tab" role="tab">
<span>JavaScript</span>
</button>
<button data-name="tab" role="tab">
<span>Python</span>
</button>
</div>
<div data-name="panels">
<div role="tabpanel" data-name="panel">
<div class="rf-codeblock">
<pre data-language="js">
<code data-language="js">export const sum = (a, b) => a + b;
</code>
</pre>
</div>
</div>
<div role="tabpanel" data-name="panel">
<div class="rf-codeblock">
<pre data-language="py">
<code data-language="py">def sum(a, b): return a + b
</code>
</pre>
</div>
</div>
</div>
</section>
</div>
<div data-name="body">
<h3 id="tiny-utilities" data-name="title">Tiny utilities</h3>
<p>A starter kit of one-liners.</p>
</div>
</div>export const sum = (a, b) => a + b;
def sum(a, b): return a + b
Tiny utilities
A starter kit of one-liners.
<div class="rf-card" data-media-position="top" data-rune="card" data-density="full">
<div data-section="media" data-name="media" class="rf-card__media" data-guest-fit="clip">
<section class="rf-codegroup rf-codegroup--scroll" data-overflow="scroll" data-elevation="flat" data-rune="code-group" data-density="compact" data-code-host="true">
<div role="tablist" data-name="tabs" class="rf-codegroup__tabs">
<button data-name="tab" role="tab" class="rf-codegroup__tab">
<span>JavaScript</span>
</button>
<button data-name="tab" role="tab" class="rf-codegroup__tab">
<span>Python</span>
</button>
</div>
<div data-name="panels" class="rf-codegroup__panels">
<div role="tabpanel" data-name="panel" class="rf-codegroup__panel">
<div class="rf-codeblock">
<pre data-language="js"><code data-language="js">export const sum = (a, b) => a + b;
</code></pre>
</div>
</div>
<div role="tabpanel" data-name="panel" class="rf-codegroup__panel">
<div class="rf-codeblock">
<pre data-language="py"><code data-language="py">def sum(a, b): return a + b
</code></pre>
</div>
</div>
</div>
</section>
</div>
<div data-name="content" class="rf-card__content">
<div data-name="body" class="rf-card__body" data-section="body">
<h3 id="tiny-utilities" data-name="title" class="rf-card__title">Tiny utilities</h3>
<p>A starter kit of one-liners.</p>
</div>
</div>
</div>Whole-card links
href makes the entire card clickable. It's rendered as a stretched-link overlay, so real links inside the body or footer stay clickable too (no invalid nested <a>). The overlay is a real link: it takes one tab stop, and screen readers announce it by the card's title (aria-labelledby).
{% card href="/guide/getting-started/" %}
### Getting started
Read the five-minute guide.
{% /card %}
Any block can be made a link the same way, with {% link %} around it.
Media guests in a linked card
A linked card is one interaction target, so its media guest is presentational. An interactive guest in the media zone — a codegroup, tabs, a live map — is demoted: its controls go pointer-events: none (clicks fall through to the card link) and it renders its static fallback rather than live chrome. The demotion is scoped to the media zone only — a button or link in the body/footer stays clickable. Authoring an interactive guest in a linked card emits a build warning; drop href or the interactivity. In cover mode the media is always an inert backdrop, linked or not. See the interaction-posture contract for the full model.
Layout attributes
card shares the media+content layout vocabulary with bento-cell and every other media-bearing rune. The body splits on --- into media → body → footer (media-first, in source order); media-position decides where the media sits visually, independently of source order.
| Attribute | Type | Default | Description |
|---|---|---|---|
media-position | string | top | Where the media sits: top, bottom, start (left), end (right), or cover (media fills the card, content overlays — see below) |
media-ratio | string | — | Media zone's share of the row when beside content (start/end): 1/3, 2/5, 1/2, 3/5, 2/3 |
valign | string | — | Cross-axis alignment when media is beside content: top, center, bottom, stretch |
collapse | string | — | Breakpoint at which beside layouts collapse to a stack: sm, md, lg, never |
content-place | string | auto | Cover only. Where the overlaid content anchors: <block> <inline> (each start/center/end), or auto to adapt to orientation |
height | string | — | Intrinsic card height (named scale sm/md/lg/xl) — gives a cover or bg-only card a poster shape |
aspect | string | — | Intrinsic card aspect ratio (e.g. 16/9, 3/4) — the proportional alternative to height |
href | string | — | Optional whole-card link target |
Elevation & frame
A card exposes two decorable surfaces (surface model): the self surface (the card box) takes elevation — a box-shadow that floats the whole tile — and the media surface (the [data-section="media"] zone) takes frame chrome: aspect, crop anchor, a silhouette drop-shadow, displacement, and oversize. The two never collide — elevation is the card's z-shadow, frame-shadow is the photo's silhouette.
{% card elevation="raised" frame-aspect="16/9" frame-anchor="top left" %}

---
### Framed media
`elevation` floats the card; `frame-*` presents the image in its media zone.
{% /card %}<div data-rune="card" data-rune-fields="{"media-position":"top"}" elevation="raised">
<div data-section="media" data-name="media">
<img src="https://picsum.photos/seed/carddash/800/450" alt="Dashboard">
</div>
<div data-name="body">
<h3 id="framed-media" data-name="title">Framed media</h3>
<p>
<code>elevation</code>
floats the card;
<code>frame-*</code>
presents the image in its media zone.
</p>
</div>
<meta data-field="frame-aspect" content="16/9">
<meta data-field="frame-anchor" content="top left">
</div>Framed media
elevation floats the card; frame-* presents the image in its media zone.
<div class="rf-card" data-media-position="top" data-elevation="raised" data-rune="card" data-density="full">
<div data-section="media" data-name="media" class="rf-card__media" data-guest-fit="clip" style="--frame-aspect: 16/9; --frame-anchor: top left">
<img src="https://picsum.photos/seed/carddash/800/450" alt="Dashboard" />
</div>
<div data-name="content" class="rf-card__content">
<div data-name="body" class="rf-card__body" data-section="body">
<h3 id="framed-media" data-name="title" class="rf-card__title">Framed media</h3>
<p>
<code>elevation</code>
floats the card;
<code>frame-*</code>
presents the image in its media zone.
</p>
</div>
</div>
</div>elevation floats the card box (none/sm/md/lg); frame (or a named preset from theme/project config) decorates the media zone. Because the media zone is a clipping host, a displaced or oversized guest is cropped into a peek — frame-anchor picks the focal point. See surfaces for the full facet list.
Cover mode
media-position="cover" is the poster layout: the media well fills the card interior and the body overlays it. It's a one-attribute switch from top/bottom/start/end — the same content, restacked. The media stays a media guest, so the thin edge and --rf-radius-media are preserved; nothing else about the card changes.
{% card href="/runes/learning/recipe" media-position="cover" scrim-type="frost" scrim-blur="md" height="lg" %}

---
Brunch classic
### Tequila Sunrise
A bright, layered cocktail — five minutes, no shaker.
{% /card %}<div data-rune="card" data-rune-fields="{"media-position":"cover","height":"lg"}">
<div data-section="media" data-name="media">
<img src="https://assets.refrakt.md/tequila-sunrise.png" alt="A tequila sunrise cocktail">
</div>
<p data-name="eyebrow">Brunch classic</p>
<div data-name="body">
<h3 id="tequila-sunrise" data-name="title">Tequila Sunrise</h3>
<p>A bright, layered cocktail — five minutes, no shaker.</p>
</div>
<a data-name="link" href="/runes/learning/recipe" data-link="stretch"></a>
<meta data-field="scrim-type" content="frost">
<meta data-field="scrim-blur" content="md">
</div><div class="rf-card rf-card--cover" data-media-position="cover" data-height="lg" data-scrim-type="frost" data-scrim-blur="md" data-rune="card" data-density="full" data-cover-scope="full">
<div data-section="media" data-name="media" class="rf-card__media" data-guest-fit="clip" data-guest-posture="presentational">
<img src="https://assets.refrakt.md/tequila-sunrise.png" alt="A tequila sunrise cocktail" />
</div>
<div data-name="content" class="rf-card__content" data-color-scheme="dark">
<p data-name="eyebrow" class="rf-card__eyebrow" data-section="eyebrow">Brunch classic</p>
<div data-name="body" class="rf-card__body" data-section="body">
<h3 id="tequila-sunrise" data-name="title" class="rf-card__title">Tequila Sunrise</h3>
<p>A bright, layered cocktail — five minutes, no shaker.</p>
</div>
</div>
<a data-name="link" href="/runes/learning/recipe" data-link="stretch" class="rf-card__link"></a>
</div>A cover card has no natural height (there's no side-by-side content to set it), so give it one with height (the named scale sm/md/lg/xl) or aspect (e.g. aspect="3/4"). When the card sits in an external grid track (a bento cell, a collection row), the track wins; otherwise height/aspect set the shape, falling back to a portrait default. height/aspect are also the standalone analog of a bento row-span for a bg-only card — a card with no media at all, just a background fill, needs an intrinsic height to show.
Placing the overlay — content-place
content-place anchors the overlaid content. It's a two-axis logical value — <block> <inline>, each start / center / end — mapping to align/justify. The default, auto, adapts to the card's own orientation via a container query: a portrait card drops the content to the block end (a caption band), a landscape card pulls it to the inline start (a side panel). Pin it with an explicit value, e.g. content-place="center center" for a centred hero.
content-place is inert outside cover mode — there's no overlay to anchor — and the engine warns if you set it on a non-cover card.
The cover scrim
Overlaying text on an arbitrary photo is a legibility footgun, so cover mode turns on a default scrim on the media surface — a gradient weighted toward the content edge (it follows content-place, and you can pin it with an explicit scrim="top|bottom|left|right"). The example above instead uses the frost treatment (scrim-type="frost", with scrim-blur on the none/sm/md/lg scale): a frosted-glass blur banded behind the text rather than a gradient. Either way the scrim is masked to the content edge so it never blurs or darkens the whole image, and the foreground reads against the darkened media automatically (scrim-tone controls the polarity — a dark scrim yields light text). Opt out with scrim="none", or set a tint for a bespoke overlay colour.
The scheme is scoped to the overlaid text, not the whole card: the card's own surface — the padded edge framing the media well — keeps the page palette (light in light mode, dark in dark mode), while only the text sitting on the image flips to stay legible.
In a recipe, cover mode uses header scope: only the title block overlays the image (a poster header), and the ingredients/steps flow below on the page palette — the same media-position="cover" switch, scoped to the part that should sit on the image.
Feeding a card from a collection
Because the card is plain, a collection body template wires entity fields into it — the card neither knows nor cares that it's in a collection:
{% collection type="page" filter="url:/blog/*" sort="date-desc" layout="grid" %}
{% card href=$item.url %}
### {% $item.data.title %}
{% date($item.data.date) %} — {% $item.data.description %}
{% /card %}
{% /collection %}
See collection → per-item templates for the $item contract.
In a grid, every card in a row shares the height of the tallest one, and the body stretches to fill that height — so the footers line up along a common baseline regardless of how much body each card has.
Output contract
<div class="rf-card" data-rune="card" data-media-position="top">
<div data-section="media" data-name="media">…</div>
<div data-name="content">
<div data-name="body">
<p data-name="eyebrow">…</p> <!-- leading paragraph before a heading -->
<h3 data-name="title">…</h3> <!-- the body's leading heading -->
…
</div>
<footer data-name="footer">…</footer>
</div>
<!-- only when href is set: focusable, labelled by the card's title -->
<a data-name="link" href="…" data-link="stretch" aria-labelledby="…"></a>
</div>
The media / content split, responsive collapse, and mobile full-bleed media header all come from the shared layouts/split.css (keyed off data-layout / data-section="media" / data-media-position), so card ships only its box chrome.
In cover mode the root carries data-media-position="cover" plus data-cover-scope="full" (and rf-card--cover); content-place emits data-content-place and the --cover-place-block/--cover-place-inline custom properties, height emits data-height, and aspect an inline aspect-ratio. The default scrim is added unless scrim="none" or a tint opts out; scrim-type="frost" adds data-scrim-type/data-scrim-blur (the scrim renders on the media well's ::after, never the self-surface bg layer). The overlay foreground scheme (data-color-scheme) lands on the [data-name="content"] overlay — not the root — so the card box surface keeps the page palette.
Attributes
| Attribute | Type | Required | Description |
|---|---|---|---|
href | string | — | Optional link target; makes the whole card clickable. |
content-place | string | — | Cover overlay anchor: "<block> <inline>" (e.g. "end start") or "auto" |
height | "sm" | "md" | "lg" | "xl" | — | Intrinsic card height (named scale) for cover / bg-only cards |
aspect | string | — | Intrinsic card aspect ratio (e.g. "16/9", "3/4") for cover / bg-only cards |
Inherited from the split layout preset
| Attribute | Type | Required | Description |
|---|---|---|---|
media-position | "top" | "bottom" | "start" | "end" | "cover" | — | Where the media zone sits relative to the content: above (top), below (bottom), or beside (start/end) |
media-ratio | "1/3" | "2/5" | "1/2" | "3/5" | "2/3" | — | Media zone’s share of the row width when media is beside content (start/end) |
valign | "top" | "center" | "bottom" | "stretch" | — | Cross-axis alignment when media is beside content (start/end); applies to the shorter zone |
collapse | "sm" | "md" | "lg" | "never" | — | Breakpoint at which side-by-side layouts collapse to a single stacked column |
Universal attributes
bg
The background layer (SPEC-088): image, video, gradient, flat overlay wash and legibility scrim, in a single injected layer behind the rune's content.
| Attribute | Type | Required | Description |
|---|---|---|---|
bg | string | — | Background preset applied to this block |
bg-from | string | — | Gradient start colour — a semantic token name (→ var(--rf-color-*)) |
bg-gradient | "to-t" | "to-b" | "to-l" | "to-r" | "to-tr" | "to-br" | "to-bl" | "to-tl" | — | Gradient direction (bounded named set) |
bg-gradient-type | "linear" | "radial" | "conic" | — | Gradient type |
bg-to | string | — | Gradient end colour — a semantic token name |
bg-via | string | — | Optional middle gradient stop — a semantic token name |
scrim | "top" | "bottom" | "left" | "right" | "none" | — | Scrim direction (heaviest edge); presence turns the scrim on, "none" opts out of the default cover scrim |
scrim-blur | "none" | "sm" | "md" | "lg" | — | Frost scrim blur amount |
scrim-strength | "sm" | "md" | "lg" | — | Gradient scrim strength |
scrim-tone | "dark" | "light" | — | Whether the scrim darkens (for light text) or lightens (for dark text) |
scrim-type | "gradient" | "frost" | — | Scrim treatment: gradient (default) or frost (backdrop blur) |
dropcap
Per-instance drop-cap opt-in (SPEC-108).
| Attribute | Type | Required | Description |
|---|---|---|---|
dropcap | boolean | — | Style the opening letter of a prose body as a drop cap (SPEC-108). Honoured only when the body reads as prose; ignored otherwise. |
elevation
The chrome/depth ladder (SPEC-107). The skin maps each rung to a chrome bundle by attribute, so there is no BEM class.
| Attribute | Type | Required | Description |
|---|---|---|---|
elevation | "sunken" | "flush" | "flat" | "raised" | "floating" | "overlay" | "none" | "sm" | "md" | "lg" | — | Surface depth on the SPEC-107 ladder (sunken→overlay); none/sm/md/lg are deprecated aliases |
frame
Surface chrome (SPEC-086): a named preset from the theme registry with inline `frame-*` overrides layered on top.
| Attribute | Type | Required | Description |
|---|---|---|---|
frame | string | — | Named frame preset presenting this block's media surface |
frame-anchor | string | — | Crop focal point when the guest is cut (object-position) |
frame-aspect | string | — | Aspect ratio of the framed media, e.g. "16/9" |
frame-displace | "top" | "bottom" | "end" | "bottom-end" | "top-end" | "both" | — | Edge/corner the framed guest moves toward |
frame-displace-mode | "peek" | "bleed" | — | How displacement renders: `peek` (default) translates the guest visually inside its frame target; `bleed` uses negative margin on the media zone so following layout pulls up — extends past a section like a hero without leaving a gap above |
frame-offset | "none" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl" | — | Displacement distance (non-linear named scale: sm…xl ride the block-spacing tokens, 2xl/3xl/4xl step up to section-spacing for bleed-mode displacements that clear a section edge) |
frame-overflow | "clip" | "bleed" | — | How the frame handles a guest whose content is wider than it: `clip` (default) keeps it inside the rounded frame; `bleed` runs an overflowing guest out to the layout edge on a narrow viewport so it reads as cropped by the screen (a bleed host only — hero/feature) |
frame-oversize | string | — | How far the guest exceeds its slot (scale factor) |
frame-place | string | — | Guest-box alignment in the slot (e.g. "left top") |
frame-shadow | "none" | "sm" | "md" | "lg" | — | Silhouette drop-shadow strength for the framed media |
inset
Internal padding override.
| Attribute | Type | Required | Description |
|---|---|---|---|
inset | "flush" | "tight" | "default" | "loose" | "breathe" | — | Inner padding of this block |
motion
Scroll-reveal entrance (SPEC-105). The author declares the character, the theme owns the choreography, a behaviour owns the timing.
| Attribute | Type | Required | Description |
|---|---|---|---|
reveal | "none" | "fade" | "slide" | "scale" | "blur" | — | Scroll-reveal entrance character (none|fade|slide|scale|blur); the theme owns the choreography |
stagger | boolean | — | Cascade this block's items in as it reveals (no-op on single-child runes) |
reading
Editorial register for body text (SPEC-108). The author picks the register; the theme owns the magnitude.
| Attribute | Type | Required | Description |
|---|---|---|---|
reading | "fine" | "ui" | "prose" | — | Reading register for this block’s body (SPEC-108): fine | ui | prose. The theme owns the editorial treatment. |
spacing
Block-level rhythm override.
| Attribute | Type | Required | Description |
|---|---|---|---|
spacing | "flush" | "tight" | "default" | "loose" | "breathe" | — | Vertical spacing above and below this block |
substrate
Generated pattern fills (SPEC-087). Markers only — the engine sets the attributes and cell/opacity custom properties, CSS draws the pattern.
| Attribute | Type | Required | Description |
|---|---|---|---|
substrate | "dots" | "grid" | "lines" | "cross" | "checker" | "none" | — | Generated surface pattern |
substrate-fill | "inherit" | "inset" | — | Surface fill the pattern sits on (full colour stays with tint) |
substrate-opacity | "sm" | "md" | "lg" | — | Pattern ink strength |
substrate-size | "sm" | "md" | "lg" | — | Pattern cell size |
substrate-target | "self" | "media" | — | Which surface the pattern fills (overrides the rune/theme default) |
tint
Per-rune colour override (SPEC-053): a named tint from the theme registry, with inline per-token overrides layered on top.
| Attribute | Type | Required | Description |
|---|---|---|---|
tint | string | — | Color tint preset applied to this block |
tint-mode | "auto" | "dark" | "light" | — | Whether the tint adapts to auto, dark, or light mode |
width
The track a block rune occupies.
| Attribute | Type | Required | Description |
|---|---|---|---|
width | "compact" | "narrow" | "content" | "wide" | "full" | — | Maximum width constraint for this block |
| Not available | Why |
|---|---|
| prominence | this rune has no page-section header |
See also
- collection — feeds cards from registry entities via a body template.
- The
reciperune is built on the same split-layout skeleton (media zone + delimited content).
