LayoutDrawer

Drawer

A body-only rune that declares a richer-than-a-tooltip, lighter-than-a-navigation panel. Trigger it from anywhere on the page via {% ref "drawer-id" /%}; without JS the body renders as an in-flow callout at its authored position so readers always have access to the content. With JS, the body is enhanced into a <dialog> and the xref click opens it as a modal — esc-to-close, focus trap, and inert background come from the platform.

Declaring a drawer

A drawer is body-only — no trigger section, no hr delimiter. The author writes the content; the rune assigns it an addressable id.

{% drawer id="auth-system" title="Auth system" %}
A short explainer that lives next to wherever it's mentioned, without forcing
the reader to navigate away.

- code blocks
- embedded runes
- rich Markdown

…fit naturally in the body.
{% /drawer %}

Renders as a styled in-flow <section> at the authored position (progressively enhanced into a <dialog> when the behaviors script loads):

Auth system

A short explainer that lives next to wherever it's mentioned, without forcing the reader to navigate away.

  • code blocks
  • embedded runes
  • rich Markdown

…fit naturally in the body.

Triggering from xrefs

The drawer registers itself as a page-scoped entity. Any {% ref %} to its id on the same page resolves to <a href="#drawer-{id}" data-target-type="drawer">…</a> — a normal anchor without JS, an open-dialog trigger with JS.

Reading about {% ref "auth-system" label="our auth subsystem" /%}? It's
the kind of detail that lives in a drawer, not a separate page.

Reading about our auth subsystem? It's the kind of detail that lives in a drawer, not a separate page.

The trigger anchor is just an anchor. Without JS, clicking scrolls to the drawer at its authored position via fragment navigation. With JS, the behaviors layer queries a[data-target-type="drawer"] whose href matches a drawer on this page and intercepts the click to call dialog.showModal().

data-target-type is a neutral convention — any rune that wants to be "addressable as a trigger" can set data-target-type="{rune-name}" on its resolved xrefs. Future popover, modal, sheet runes can adopt the same hook-point without expanding the xref surface.

Triggers anywhere, multiple triggers free

Because the drawer is id-addressable, the same drawer can be referenced from N places on the same page without coordination. The body is authored once; refs to it are normal authoring.

Multiple xrefs to Auth system or to this same drawer all resolve to the same anchor target — the drawer above. Clicking either opens it; the behaviors layer doesn't care which trigger fired.

Attributes

AttributeTypeRequiredDescription
idstringStable id for the drawer. Used in entity registration and as the `id="drawer-{value}"` on the rendered element so xref triggers can target it via fragment.
titlestringPanel heading text. Rendered in the drawer header (or as a heading in the no-JS in-flow rendering).
headingLevelnumberHeading level (1-6) for the title. When omitted, the level is auto-detected from outline position (one deeper than the nearest preceding heading). Out-of-range values are clamped to the 1-6 range.
shortcutstringKeyboard shortcut that opens the drawer when behaviors are loaded (e.g. ".", "cmd+k"). Surfaces as `data-shortcut`; the progressive-enhancement layer wires the listener.
side"right" | "left" | "top" | "bottom"Edge the panel slides from when enhanced. Defaults to "right".
size"sm" | "md" | "lg"Panel size. "sm" / "md" (default) / "lg" — width for left/right sides, height for top/bottom.

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.

AttributeTypeRequiredDescription
bgstringBackground preset applied to this block
bg-fromstringGradient 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-tostringGradient end colour — a semantic token name
bg-viastringOptional 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).

AttributeTypeRequiredDescription
dropcapbooleanStyle 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.

AttributeTypeRequiredDescription
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
inset

Internal padding override.

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

AttributeTypeRequiredDescription
reveal"none" | "fade" | "slide" | "scale" | "blur"Scroll-reveal entrance character (none|fade|slide|scale|blur); the theme owns the choreography
staggerbooleanCascade this block's items in as it reveals (no-op on single-child runes)
prominence

Header emphasis (SPEC-107). Scales a rune's page-section header; the skin maps it to a type register by attribute, so there is no BEM class.

AttributeTypeRequiredDescription
prominence"quiet" | "normal" | "prominent" | "display"Section-header emphasis (only on page-section-header family runes)
reading

Editorial register for body text (SPEC-108). The author picks the register; the theme owns the magnitude.

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

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

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

AttributeTypeRequiredDescription
tintstringColor 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.

AttributeTypeRequiredDescription
width"compact" | "narrow" | "content" | "wide" | "full"Maximum width constraint for this block
Not availableWhy
framethis rune declares neither a `frameTarget` nor a media section

Progressive enhancement

The drawer is built on the no-JS-visible / JS-hidden contract: the body always renders, the dialog UX is the enhancement.

Without JS:

  • The drawer body is a styled <section class="rf-drawer"> at its authored position.
  • Title is a heading at the level chosen by headingLevel (or auto-detected from outline depth).
  • Close button is hidden (no point exposing a control without script).
  • xref triggers are plain anchors; clicking scrolls to the drawer via fragment navigation.
  • Keyboard shortcut listener doesn't activate.

With JS (when @refrakt-md/behaviors is loaded):

  • The <section> is replaced with a <dialog>, preserving id and attributes.
  • Close button reveals (hidden removed).
  • xref clicks call dialog.showModal() instead of scrolling.
  • Esc closes the panel (native dialog behaviour).
  • Backdrop click closes the panel.
  • Close button click closes the panel.
  • Keyboard shortcut opens the panel (global listener; skipped when focus is in input/textarea/select/[contenteditable]).
  • URL hash sync: a page load with #drawer-{id} opens the matching drawer automatically; opening updates location.hash via replaceState; closing clears it; the browser back button closes an open drawer.

Keyboard shortcuts

Bare keys (".", "k") and modifier prefixes are both supported:

FormExample
Bare keyshortcut="."
Primary modifier (cmd on macOS, ctrl elsewhere)shortcut="cmd+k"
Multiple modifiersshortcut="ctrl+shift+/"
Plain function keysshortcut="?"

The behaviors layer collapses cmd+ and ctrl+ into "the platform's primary modifier" — cmd+k matches Cmd-K on macOS and Ctrl-K on Windows/Linux.

Two drawers on the same page declaring the same shortcut emit a dev-mode warning naming both; last-registered wins.

Page-scoped ids

Two pages can each declare id="auth" without colliding in the registry — drawer ids are scoped to the page they're declared on. Same-page xrefs find their page-local drawer. Cross-page xref-to-drawer is mechanically supported (the registry has a cross-page fallback so the lookup succeeds, and the resulting href is the destination page plus the fragment) but the end-to-end UX is deferred to a future spec.

Title-level auto-detection

{% drawer id="x" title="T" %} (no explicit headingLevel) emits an h3 placeholder marked with data-drawer-title-auto. The pipeline's postProcess pass walks the page renderable, tracks the most recent heading level, and rewrites the placeholder to h{n+1} (clamped to h1-h6). Default behaviour when no preceding heading exists: the title becomes h2 (one deeper than the page-title h1 that lives in layout).

Explicit headingLevel= always wins.

The drawer body splits on a top-level --- into two zones — body and footer — same shape {% card %} uses (SPEC-078). 1 zone → all body (today's behaviour, unchanged); 2 zones → body + footer. The footer renders below the body with a top divider and slightly muted text:

{% drawer id="auth" title="Auth system" %}
The auth system uses JWTs with refresh tokens.

- Tokens live in HttpOnly cookies
- 15-minute access, 30-day refresh
- Rotation on every refresh

---

[Read the full design doc on Notion →](https://example.com/auth-design)
{% /drawer %}

The footer zone is generic markdoc — any inline content goes there, not only hardcoded URLs. The canonical case is a {% ref %} pointing at the same entity the body expanded:

{% drawer id="aggregate" title="Aggregate rune" %}
{% expand "SPEC-076" /%}

---

See {% ref "SPEC-076" /%}
{% /drawer %}

The xref resolves the URL from the registry, so the link stays correct as the entity moves and the author doesn't memorise URLs.

In dialog mode, the drawer becomes a flex column — header (when present) and footer (when present) pin via flex: 0 0 auto; body scrolls via flex: 1 1 auto; overflow-y: auto. The drawer's max-height cap provides the scroll context. So a long entity body or long file snippet scrolls inside the drawer with the footer staying one tap away regardless of scroll depth.

Hoisted drawers (preview="drawer")

xref and file-ref each accept preview="drawer" which hoists a drawer for the referenced target. The inline link stays in prose; the drawer is emitted at the page root and opens on click:

See {% file-ref path="packages/types/src/theme.ts" lines="74-125" label="SiteConfig" preview="drawer" /%}
for the shape.

Hoisted drawers use the same <section class="rf-drawer"> shape as author-declared drawers — same chrome, same body / footer zones, same behaviors-layer enhancement. The chrome footer is populated by the rune that hoists (file-ref: GitHub link; xref: entity page link).

Collision with author-declared drawers. If an author writes {% drawer id="X" %} on the same page where a preview="drawer" reference would generate the same id, the author drawer wins — the hoist defers, the inline preview link points at the existing drawer. This lets authors customise a specific drawer's body or footer without losing the inline-link ergonomics. The build emits an info-level note naming both sources.

Composition

Drawers compose like any block container — embed runes, code blocks, even the snippet rune for a "view source" drawer. The drawer below is live on this page; click view this page's source (or press .) to open it.

{% drawer id="page-source" title="View source" shortcut="." size="lg" %}
{% snippet path=$file.path lang="markdoc" /%}
{% /drawer %}

Trigger with {% ref "page-source" label="view this page's source" /%}.

View source

---
title: Drawer
description: Addressable modal panel — declared once, opened from any xref on the page
category: Layout
plugin: core
status: stable
type: rune
---

# Drawer

A body-only rune that declares a richer-than-a-tooltip, lighter-than-a-navigation panel. Trigger it from anywhere on the page via `{% ref "drawer-id" /%}`; without JS the body renders as an in-flow callout at its authored position so readers always have access to the content. With JS, the body is enhanced into a `<dialog>` and the xref click opens it as a modal — esc-to-close, focus trap, and `inert` background come from the platform.

## Declaring a drawer

A drawer is body-only — no trigger section, no `hr` delimiter. The author writes the content; the rune assigns it an addressable id.

```markdoc
{% drawer id="auth-system" title="Auth system" %}
A short explainer that lives next to wherever it's mentioned, without forcing
the reader to navigate away.

- code blocks
- embedded runes
- rich Markdown

…fit naturally in the body.
{% /drawer %}
```

Renders as a styled in-flow `<section>` at the authored position (progressively enhanced into a `<dialog>` when the behaviors script loads):

{% drawer id="auth-system" title="Auth system" %}
A short explainer that lives next to wherever it's mentioned, without forcing the reader to navigate away.

- code blocks
- embedded runes
- rich Markdown

…fit naturally in the body.
{% /drawer %}

## Triggering from xrefs

The drawer registers itself as a page-scoped entity. Any `{% ref %}` to its id on the same page resolves to `<a href="#drawer-{id}" data-target-type="drawer">…</a>` — a normal anchor without JS, an open-dialog trigger with JS.

```markdoc
Reading about {% ref "auth-system" label="our auth subsystem" /%}? It's
the kind of detail that lives in a drawer, not a separate page.
```

Reading about {% ref "auth-system" label="our auth subsystem" /%}? It's the kind of detail that lives in a drawer, not a separate page.

The trigger anchor is just an anchor. Without JS, clicking scrolls to the drawer at its authored position via fragment navigation. With JS, the behaviors layer queries `a[data-target-type="drawer"]` whose href matches a drawer on this page and intercepts the click to call `dialog.showModal()`.

`data-target-type` is a **neutral convention** — any rune that wants to be "addressable as a trigger" can set `data-target-type="{rune-name}"` on its resolved xrefs. Future popover, modal, sheet runes can adopt the same hook-point without expanding the xref surface.

## Triggers anywhere, multiple triggers free

Because the drawer is id-addressable, the same drawer can be referenced from N places on the same page without coordination. The body is authored once; refs to it are normal authoring.

Multiple xrefs to {% ref "auth-system" /%} or to {% ref "auth-system" label="this same drawer" /%} all resolve to the same anchor target — the drawer above. Clicking either opens it; the behaviors layer doesn't care which trigger fired.

## Attributes

{% include file="rune-attributes.md" variables={r: "rune:drawer"} /%}

## Progressive enhancement

The drawer is built on the **no-JS-visible / JS-hidden** contract: the body always renders, the dialog UX is the enhancement.

**Without JS:**
- The drawer body is a styled `<section class="rf-drawer">` at its authored position.
- Title is a heading at the level chosen by `headingLevel` (or auto-detected from outline depth).
- Close button is `hidden` (no point exposing a control without script).
- xref triggers are plain anchors; clicking scrolls to the drawer via fragment navigation.
- Keyboard shortcut listener doesn't activate.

**With JS** (when `@refrakt-md/behaviors` is loaded):
- The `<section>` is replaced with a `<dialog>`, preserving id and attributes.
- Close button reveals (`hidden` removed).
- xref clicks call `dialog.showModal()` instead of scrolling.
- Esc closes the panel (native dialog behaviour).
- Backdrop click closes the panel.
- Close button click closes the panel.
- Keyboard shortcut opens the panel (global listener; skipped when focus is in `input`/`textarea`/`select`/`[contenteditable]`).
- URL hash sync: a page load with `#drawer-{id}` opens the matching drawer automatically; opening updates `location.hash` via `replaceState`; closing clears it; the browser back button closes an open drawer.

## Keyboard shortcuts

Bare keys (`"."`, `"k"`) and modifier prefixes are both supported:

| Form | Example |
|------|---------|
| Bare key | `shortcut="."` |
| Primary modifier (cmd on macOS, ctrl elsewhere) | `shortcut="cmd+k"` |
| Multiple modifiers | `shortcut="ctrl+shift+/"` |
| Plain function keys | `shortcut="?"` |

The behaviors layer collapses `cmd+` and `ctrl+` into "the platform's primary modifier" — `cmd+k` matches Cmd-K on macOS and Ctrl-K on Windows/Linux.

Two drawers on the same page declaring the same shortcut emit a dev-mode warning naming both; last-registered wins.

## Page-scoped ids

Two pages can each declare `id="auth"` without colliding in the registry — drawer ids are scoped to the page they're declared on. Same-page xrefs find their page-local drawer. Cross-page xref-to-drawer is mechanically supported (the registry has a cross-page fallback so the lookup succeeds, and the resulting href is the destination page plus the fragment) but the end-to-end UX is deferred to a future spec.

## Title-level auto-detection

`{% drawer id="x" title="T" %}` (no explicit `headingLevel`) emits an `h3` placeholder marked with `data-drawer-title-auto`. The pipeline's postProcess pass walks the page renderable, tracks the most recent heading level, and rewrites the placeholder to `h{n+1}` (clamped to h1-h6). Default behaviour when no preceding heading exists: the title becomes `h2` (one deeper than the page-title `h1` that lives in layout).

Explicit `headingLevel=` always wins.

## Body and footer zones

The drawer body splits on a top-level `---` into two zones — **body** and **footer** — same shape `{% card %}` uses (SPEC-078). 1 zone → all body (today's behaviour, unchanged); 2 zones → body + footer. The footer renders below the body with a top divider and slightly muted text:

```markdoc
{% drawer id="auth" title="Auth system" %}
The auth system uses JWTs with refresh tokens.

- Tokens live in HttpOnly cookies
- 15-minute access, 30-day refresh
- Rotation on every refresh

---

[Read the full design doc on Notion →](https://example.com/auth-design)
{% /drawer %}
```

The footer zone is generic markdoc — any inline content goes there, not only hardcoded URLs. The canonical case is a `{% ref %}` pointing at the same entity the body expanded:

```markdoc
{% drawer id="aggregate" title="Aggregate rune" %}
{% expand "SPEC-076" /%}

---

See {% ref "SPEC-076" /%}
{% /drawer %}
```

The xref resolves the URL from the registry, so the link stays correct as the entity moves and the author doesn't memorise URLs.

### Always-visible footer

In dialog mode, the drawer becomes a **flex column** — header (when present) and footer (when present) pin via `flex: 0 0 auto`; body scrolls via `flex: 1 1 auto; overflow-y: auto`. The drawer's `max-height` cap provides the scroll context. So a long entity body or long file snippet scrolls inside the drawer with the footer staying one tap away regardless of scroll depth.

## Hoisted drawers (`preview="drawer"`)

[`xref`](/runes/xref) and [`file-ref`](/runes/file-ref) each accept `preview="drawer"` which **hoists** a drawer for the referenced target. The inline link stays in prose; the drawer is emitted at the page root and opens on click:

```markdoc
See {% file-ref path="packages/types/src/theme.ts" lines="74-125" label="SiteConfig" preview="drawer" /%}
for the shape.
```

Hoisted drawers use the same `<section class="rf-drawer">` shape as author-declared drawers — same chrome, same body / footer zones, same behaviors-layer enhancement. The chrome footer is populated by the rune that hoists (file-ref: GitHub link; xref: entity page link).

**Collision with author-declared drawers.** If an author writes `{% drawer id="X" %}` on the same page where a `preview="drawer"` reference would generate the same id, the **author drawer wins** — the hoist defers, the inline preview link points at the existing drawer. This lets authors customise a specific drawer's body or footer without losing the inline-link ergonomics. The build emits an info-level note naming both sources.

## Composition

Drawers compose like any block container — embed runes, code blocks, even the snippet rune for a "view source" drawer. The drawer below is live on this page; click {% ref "page-source" label="view this page's source" /%} (or press `.`) to open it.

```markdoc
{% drawer id="page-source" title="View source" shortcut="." size="lg" %}
{% snippet path=$file.path lang="markdoc" /%}
{% /drawer %}

Trigger with {% ref "page-source" label="view this page's source" /%}.
```

{% drawer id="page-source" title="View source" shortcut="." size="lg" %}
{% snippet path=$file.path lang="markdoc" /%}
{% /drawer %}

Nested drawers and multiple simultaneous open drawers aren't supported in v1 — native `<dialog>` enforces single-modal, and stacked modals are an antipattern.

## See also

- [xref](/runes/xref) — the trigger primitive. `data-target-type="{entity-type}"` propagation is what lets drawer (and any future addressable rune) opt into trigger behaviour.
- [file-ref](/runes/file-ref) — path-based sibling of xref. Same `preview="drawer"` attribute hoists a drawer with a file's snippet.
- [snippet](/runes/snippet) — embed a project file as a code block; pairs naturally with `{% drawer %}` for a view-source pattern, and is the body shape file-ref's preview drawer uses.

Nested drawers and multiple simultaneous open drawers aren't supported in v1 — native <dialog> enforces single-modal, and stacked modals are an antipattern.

See also

  • xref — the trigger primitive. data-target-type="{entity-type}" propagation is what lets drawer (and any future addressable rune) opt into trigger behaviour.
  • file-ref — path-based sibling of xref. Same preview="drawer" attribute hoists a drawer with a file's snippet.
  • snippet — embed a project file as a code block; pairs naturally with {% drawer %} for a view-source pattern, and is the body shape file-ref's preview drawer uses.