ContentCard

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="{&quot;media-position&quot;:&quot;top&quot;}">
  <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" aria-hidden="true" tabindex="-1"></a>
</div>

Collection rune

The plural counterpart to ref and expand — query the registry and render the results.

Core rune · see also expand

<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">
      <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" aria-hidden="true" tabindex="-1" 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" %}
![A tequila sunrise cocktail](https://assets.refrakt.md/tequila-sunrise.png)

---

Brunch classic

### Tequila Sunrise
A bright, layered cocktail — five minutes, no shaker.

---

Cocktail · Easy
{% /card %}
<div data-rune="card" data-rune-fields="{&quot;media-position&quot;:&quot;start&quot;}">
  <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" aria-hidden="true" tabindex="-1"></a>
</div>
A tequila sunrise cocktail

Brunch classic

Tequila Sunrise

A bright, layered cocktail — five minutes, no shaker.

Cocktail · Easy

<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-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">Brunch classic</p>
    <div data-name="body" class="rf-card__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" aria-hidden="true" tabindex="-1" 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 page-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="{&quot;media-position&quot;:&quot;top&quot;}">
  <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) =&gt; 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">
    <section class="rf-codegroup rf-codegroup--scroll" data-overflow="scroll" 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) =&gt; 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">
      <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>

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

{% card href="/guide/getting-started/" %}
### Getting started
Read the five-minute guide.
{% /card %}

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.

AttributeTypeDefaultDescription
media-positionstringtopWhere the media sits: top, bottom, start (left), end (right), or cover (media fills the card, content overlays — see below)
media-ratiostringMedia zone's share of the row when beside content (start/end): 1/3, 2/5, 1/2, 3/5, 2/3
valignstringCross-axis alignment when media is beside content: top, center, bottom, stretch
collapsestringBreakpoint at which beside layouts collapse to a stack: sm, md, lg, never
content-placestringautoCover only. Where the overlaid content anchors: <block> <inline> (each start/center/end), or auto to adapt to orientation
heightstringIntrinsic card height (named scale sm/md/lg/xl) — gives a cover or bg-only card a poster shape
aspectstringIntrinsic card aspect ratio (e.g. 16/9, 3/4) — the proportional alternative to height
hrefstringOptional 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="md" frame-aspect="16/9" frame-anchor="top left" %}
![Dashboard](https://picsum.photos/seed/carddash/800/450)
---
### Framed media
`elevation` floats the card; `frame-*` presents the image in its media zone.
{% /card %}
<div data-rune="card" data-rune-fields="{&quot;media-position&quot;:&quot;top&quot;}" elevation="md">
  <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>
Dashboard

Framed media

elevation floats the card; frame-* presents the image in its media zone.

<div class="rf-card" data-media-position="top" data-elevation="md" data-rune="card" data-density="full">
  <div data-section="media" data-name="media" class="rf-card__media" 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">
      <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" %}
![A tequila sunrise cocktail](https://assets.refrakt.md/tequila-sunrise.png)

---

Brunch classic

### Tequila Sunrise
A bright, layered cocktail — five minutes, no shaker.
{% /card %}
<div data-rune="card" data-rune-fields="{&quot;media-position&quot;:&quot;cover&quot;,&quot;height&quot;:&quot;lg&quot;}">
  <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" aria-hidden="true" tabindex="-1"></a>
  <meta data-field="scrim-type" content="frost">
  <meta data-field="scrim-blur" content="md">
</div>
A tequila sunrise cocktail

Brunch classic

Tequila Sunrise

A bright, layered cocktail — five minutes, no shaker.

<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-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">Brunch classic</p>
    <div data-name="body" class="rf-card__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" aria-hidden="true" tabindex="-1" 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 -->
  <a data-name="link" href="…" aria-hidden="true" tabindex="-1"></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.

See also

  • collection — feeds cards from registry entities via a body template.
  • The recipe rune is built on the same split-layout skeleton (media zone + delimited content).