---
title: Reading
description: A family of primitives for long-form content pages — progress, spine, minimap, bookmark, meta, plus a one-tag composition shell
visibility: guest
draft: false
---

# Reading

The reading-* family — a set of complementary primitives for long-form content pages.

A blog post, a docs page, a long-form article: the family covers
"where am I in the article", "navigate the structure",
"see the density at a glance", "resume where I left off",
"how long is this", and a one-tag composition wrapper.

::: warning Viewport-scoped layout
The `reading-*` family of primitives defaults to `position: fixed` relative to the viewport — they're designed for full-page reading experiences (article pages, docs sites). **Avoid** nesting one inside a normal-width card, grid, or dashboard tile **without** the `boundary` prop: the fixed element ignores its parent's intrinsic width, producing double scrollbars and visual overflow. Pin them at the page level (inside `<main>` or `<article>`), never inside a constrained container.
:::

## Quick start (the "I just want it to work" path)

One tag wraps everything with sensible defaults:

:::preview{title="reading-shell with defaults", frame="iframe", height="640px", flush="true", desktopOnly}
{{-- The CSS variable on the shell wrapper cascades to the inner spine so
     it renders narrow enough to fit alongside the article in this iframe;
     the article below reserves matching padding-right. --}}
<x-wirekit::reading-shell :previewMode="true" bookmarkKey="reading-quick-start-demo" style="--reading-spine-width-expanded: 10rem;">
    <x-wirekit::prose>
        <article class="wk-reading-content" style="padding: 1rem; --reading-content-spine-gutter: 11rem;">
            <h2 id="r-quickstart-intro">Introduction</h2>
            <p>The shell renders a progress bar, a sidebar TOC, and a bookmark prompt with sensible defaults — drop your article body inside, pass a bookmark key, and the rest is opinionated defaults. The progress bar pins to the very top of this iframe; the spine sits at the right edge; the bookmark arms itself once you've dwelled long enough.</p>
            <p>Scroll this preview to see all three primitives respond. The bar fills, the spine highlights the active heading, and after 30 seconds of dwell, the bookmark prompt appears bottom-right (only on a return visit — first visit shows nothing).</p>
            <p>The shell wrapper is the 80% case: pass a bookmark key, accept the comfortable density default. For finer control, decompose into primitives and mount each one directly.</p>
            <h2 id="r-quickstart-body">Body</h2>
            <p>Each primitive in the family carries its own Alpine state; nothing communicates between them. That makes the composition robust to changes in any one of them — opt out per-toggle (<code>:spine="false"</code>) or pick a density preset (<code>density="compact"</code>) without breaking the others.</p>
            <p>The four primitives the shell composes by default: reading-progress (the bar), reading-spine (the sidebar TOC), reading-bookmark (the dwell-triggered prompt), reading-meta (time-to-read estimate, off by default). The minimap and TOC primitives are toggleable but disabled in the comfortable density preset — opt in via <code>:minimap="true"</code> / <code>:toc="true"</code>.</p>
            <p>The density preset (<code>comfortable</code>, <code>compact</code>, <code>minimal</code>) flips per-primitive defaults. <code>compact</code> uses a thinner progress bar and forces the spine to always-md expanded. <code>minimal</code> shows only the progress bar.</p>
            <h2 id="r-quickstart-end">Conclusion</h2>
            <p>For per-primitive customization beyond what toggles + density cover, compose primitives directly. The shell covers the common case; dropping to primitives covers the rest, with no "shell-with-overrides" half-step in between.</p>
            <p>The shell's div wrapper is the only structural element it adds. If your layout needs the article body to live inside a flex/grid container or a Livewire boundary, the shell's wrapper might interfere — that's the canonical signal to drop the shell and mount primitives directly.</p>
            <p>Click any spine link to smooth-scroll to that heading. Click anywhere else to return reading. The bookmark saves your offset every second; on a real return visit (after 30s+ dwell time), it'll prompt you to resume.</p>
        </article>
    </x-wirekit::prose>
</x-wirekit::reading-shell>
:::

## Family contracts

These contracts apply uniformly to every primitive in the family. They
are documented once here, not duplicated in each primitive section.

### Reduced motion

Every transition (bar fill `transform: scaleX`, dot stroke `dashoffset`,
spine width / label opacity / tick color-fade, bookmark pill enter /
leave, minimap stripe color transitions, smooth-scroll on click /
back-to-top) collapses to `0.01ms` under `prefers-reduced-motion: reduce`
via the global `@media` block in `dist/wirekit.css`. Values still
update on scroll — only the interpolating transition is removed; the
value itself jumps to current.

### Print stylesheet

All primitives carry a `@media print { display: none !important }` rule
scoped via marker classes (`.wk-reading-progress`, `.wk-reading-spine`,
`.wk-reading-minimap`, `.wk-reading-bookmark`, `.wk-reading-meta`).
Print-friendly for blog-to-PDF workflows — only the article body
prints, not the reading chrome.

### The `target` prop convention

Every primitive accepts a `target` prop that defaults to `null`. When
null, the primitive resolves an internal default per-component:

- `reading-progress` `target=null` → tracks viewport scroll
- `reading-spine`, `reading-bookmark`, `reading-meta`, `reading-minimap`
  `target=null` → resolves to `'main, article'` (first-match wins)

Pass an explicit CSS selector when your article container isn't `<main>`
or `<article>` (e.g. `target="#docs-body"`).

### The `wk-reading-*` marker classes

Every primitive carries a `wk-reading-{name}` marker class on its root
element. Use these for developer CSS overrides without editing the
package source.

## Primitives

### Reading Progress

A viewport-pinned indicator that fills 0 → 100% as the reader scrolls.
Two surfaces share one component: `indicator="bar"` (default — thin
horizontal strip) and `indicator="dot"` (circular SVG pinned bottom-
right). Both reuse the same Alpine state machine; only the rendered
DOM differs.

The fill animation uses `transform: scaleX` (bar) / `stroke-dasharray`
(dot) — both compositor-only properties, GPU-accelerated, no layout
or paint cost.

:::preview{title="Bar (default)", frame="iframe", height="480px", flush="true"}
<x-wirekit::reading-progress />
<x-wirekit::prose>
    <article style="padding: 1rem;">
        <h2>Watch the bar fill as the page scrolls</h2>
        <p>The reading-progress bar pins to the very top of the iframe (its <code>position: fixed</code> anchors to the iframe's viewport, not the outer page). Scroll inside this preview frame — the bar's fill grows from 0% to 100% as the scroll position moves from 0 to the bottom.</p>
        <p>The fill animation uses <code>transform: scaleX</code> — a compositor-only property, so the bar fills smoothly even on low-powered devices without triggering layout or paint. Under <code>prefers-reduced-motion: reduce</code> the transition collapses to 0.01ms; the value still updates on scroll, only the interpolation is removed.</p>
        <p>Default height is 3px (<code>height="md"</code>). Pass <code>height="sm"</code> for a 2px hairline or <code>height="lg"</code> for a 5px chunky bar. The color is the accent token by default; pass <code>variant="success"</code> / <code>variant="warning"</code> / <code>variant="danger"</code> for semantic states.</p>
        <p>The bar starts hidden until the reader has scrolled past <code>showAfter</code> pixels (default 0). Set a higher value (e.g. <code>:showAfter="100"</code>) if you want the bar to appear only after the reader has committed to reading past the article header.</p>
        <p>Pass <code>:milestones="true"</code> to fire <code>wirekit:reading-progress:milestone</code> events at 25%, 50%, 75%, and 100%. Useful for analytics or for triggering a "you're halfway through" prompt where it makes sense.</p>
        <p>The progress bar lives in its own DOM node and never interferes with the document flow — even when nested inside other components, it takes itself out of layout via <code>position: fixed</code> the moment it mounts.</p>
        <p>If your page has a fixed header above the article, set <code>position="bottom"</code> to pin the bar to the viewport bottom instead. Both positions share the same Alpine state machine; only the rendered DOM differs.</p>
        <p>For embedded contexts like browser extensions or iframes-within-iframes where the bar should match surrounding text color, pass <code>variant="auto"</code>. The fill resolves to <code>currentColor</code>, so the bar inherits whatever color the surrounding context provides.</p>
        <p>Keep scrolling — the more text you scroll past, the more visible the bar's fill becomes. By the end of this preview block the bar should be at or near 100%.</p>
        <p>The progress bar is the lightest of the reading-* primitives — pure CSS plus a single rAF-throttled scroll listener. No IntersectionObserver, no localStorage, no DOM scanning of headings.</p>
    </article>
</x-wirekit::prose>
:::

:::preview{title="Dot indicator", frame="iframe", height="480px", flush="true"}
<x-wirekit::reading-progress indicator="dot" />
<x-wirekit::prose>
    <article style="padding: 1rem;">
        <h2>Watch the dot fill as the page scrolls</h2>
        <p>Scroll inside this preview frame and the dot's stroke fills clockwise as the scroll position progresses from 0 to the bottom. Same Alpine state machine as the bar variant; only the rendered DOM differs. The dot pins to the bottom-right of the iframe via <code>position: fixed</code> and renders as a circular SVG with a stroke that fills as the reader progresses.</p>
        <p>The fill animation uses <code>stroke-dasharray</code> on the SVG circle — also a compositor-only property, GPU-accelerated. The dot doesn't take itself out of layout (it sits in its own fixed-position container), so it never interferes with the article flow below.</p>
        <p>Use the dot variant when a horizontal bar would interfere with your layout — for example, if you already have a fixed top nav or a persistent CTA at the top. The dot is also less visually heavy than the bar; some product surfaces want the indicator to be discoverable but not prominent.</p>
        <p>Hover the dot to see the percentage tooltip (the percentage is also exposed via the dot's <code>aria-label</code> for screen readers). On touch, the tooltip is suppressed; the percentage is still available via assistive tech.</p>
        <p>Pass <code>position="bottom-left"</code> / <code>position="top-left"</code> / <code>position="top-right"</code> to relocate the dot. The default <code>bottom-right</code> follows the convention every messaging app and chat surface uses for "your status indicator".</p>
        <p>The dot variant carries the same <code>variant</code> prop as the bar (<code>primary</code>, <code>neutral</code>, <code>success</code>, <code>warning</code>, <code>danger</code>, <code>info</code>, <code>auto</code>) — the color token threading is identical.</p>
        <p>Both indicators (bar and dot) ship a <code>@media print { display: none !important }</code> rule via the <code>.wk-reading-progress</code> marker class. Print-friendly; the article body prints clean without progress chrome on every page.</p>
        <p>If you need both indicators simultaneously (a bar for "where am I" plus a dot for ambient feedback), mount two <code>&lt;x-wirekit::reading-progress&gt;</code> tags side by side. Both share scroll state via the same plugin; neither knows about the other.</p>
        <p>Keep scrolling to see the dot fill. The stroke completes as the reader hits the bottom of the article.</p>
    </article>
</x-wirekit::prose>
:::

#### Variants

Six canonical values + one runtime-resolved value:

| Value | Fill |
|---|---|
| `primary` (default) | `--color-wk-accent` (brand emphasis) |
| `neutral` | `--color-wk-text-muted` (quiet gray) |
| `success` / `warning` / `danger` | semantic color tokens |
| `info` | visual synonym of `primary` (matches alert/callout pattern) |
| `auto` | `currentColor` fallback — for embedded contexts (iframes, browser extensions) where the bar should match surrounding text color |

Every variant resolves through `var(--reading-progress-fill, var(--color-wk-X))`,
so the `--reading-progress-fill` developer-side token override applies
regardless of variant. Set the token at `:root` for a theme-wide override.

#### Reading Progress props

| Prop | Type | Default | Description |
|---|---|---|---|
| `position` | string | `'top'` | `top` or `bottom` viewport edge |
| `height` | string | `'md'` | `sm` (2px) / `md` (3px) / `lg` (5px) |
| `variant` | string | `'primary'` | See "Variants" above |
| `intent` | string\|null | `null` | Canonical name for the color axis, accepting the same values as `variant`. Falls back to `variant` when omitted, and wins over it when both are given. |
| `showAfter` | int | `0` | Pixel scroll threshold before bar appears |
| `target` | string\|null | `null` | CSS selector or null for viewport scroll |
| `indicator` | string | `'bar'` | `bar` or `dot` |
| `segments` | array\|null | `null` | Fractional 0..1 chapter-marker positions |
| `milestones` | bool | `false` | Fire `wirekit:reading-progress:milestone` event at 25/50/75/100% |
| `boundary` | `'container'`\|`<css-selector>`\|null | `null` | When set to `'container'`, swap `position: fixed` → `position: sticky` so the bar/dot stays inside the nearest positioned, scrollable ancestor. Pass a CSS selector string (e.g. `"#article"`) to additionally assert at runtime that the matched ancestor exists — the Alpine init logs a `console.warn` when the selector matches no ancestor. Required for embedding inside modal bodies, sidebar panes, or preview frames. See [Scoping a primitive to its parent](#scoping-a-primitive-to-its-parent--the-boundary-prop). |
| `scope` | string\|null | `null` | Personalization scope |

### Reading Spine

> **Looking for canonical layout-wiring?** The [on-page-toc recipe](/blueprints/recipes/on-page-toc) shows the Stripe / Linear / Vercel / shadcn-style sidebar pattern end-to-end: reading-spine next to `<main>`, CSS variables for sticky offset, integration with a sticky header + breadcrumb, and a working preview.

A sidebar mini-TOC that auto-builds from page headings, tracks the
reader's scroll position via IntersectionObserver, and expands on hover
or focus.

:::preview{title="Default spine", frame="iframe", height="640px", flush="true", desktopOnly}
{{-- The CSS-variable override narrows the expanded spine width so it fits
     the iframe gutter; the article below reserves matching padding-right. --}}
<x-wirekit::reading-spine style="--reading-spine-width-expanded: 10rem;" />
<x-wirekit::prose>
    {{-- .wk-reading-content reserves a responsive inline-end gutter for the
         fixed-position spine — full width below md (spine hidden), an 11rem
         gutter at md+ (matching this preview's 10rem expanded spine + 1rem
         offset via the --reading-content-spine-gutter override). --}}
    <article class="wk-reading-content" style="padding: 1rem; --reading-spine-width-expanded: 10rem; --reading-content-spine-gutter: 11rem;">
        <h2 id="rs-default-overview">Overview</h2>
        <p>The reading-spine is a fixed-position sidebar TOC at the right edge of the iframe. It auto-builds from the headings inside the nearest <code>&lt;article&gt;</code> or <code>&lt;main&gt;</code> ancestor, renders one tick per heading, and tracks the active section via IntersectionObserver as you scroll.</p>
        <p>Hover or focus the spine to expand the labels — collapsed it's just a thin column of ticks; expanded it shows full heading text. The expand-on-hover keeps horizontal space minimal until the reader actually wants to navigate.</p>
        <h3 id="rs-default-ticks">Tick widths</h3>
        <p>Tick width encodes heading level: h2 ticks render wider than h3 ticks, which render wider than h4 ticks. This visual hierarchy survives at minified scale — the reader can tell nesting depth at a glance even before expanding.</p>
        <p>Levels included default to <code>'2,3'</code> (h2 + h3). Pass <code>levels="'2'"</code> for h2-only (flat outline), or <code>levels="'2,3,4'"</code> for deeper hierarchies. Six levels (h1-h6) are valid; the plugin filters anything outside that range.</p>
        <h2 id="rs-default-active">Active section detection</h2>
        <p>The active heading is the LAST heading whose top is at or above the viewport-top offset line (default 96px). This handles tall sections cleanly — a section longer than the viewport becomes "active" as the reader scrolls past its first paragraph rather than waiting for the next heading to enter the viewport.</p>
        <p>The IntersectionObserver runs with <code>rootMargin: -96px 0px -90% 0px</code> — narrow band at the viewport top, wide band below. Each callback re-evaluates against live <code>getBoundingClientRect()</code> to find the currently-active item. Cost is ~2ms at 100 headings, well under one frame.</p>
        <h3 id="rs-default-scroll">Smooth scroll on click</h3>
        <p>Click any tick → smooth-scroll to that heading. The URL hash updates via <code>history.replaceState()</code>, not <code>pushState()</code> — back-button still goes to the previous page rather than the previous heading. Saves the user from a multi-press back-button repro to escape the article.</p>
        <p>Smooth scrolling is gated by <code>prefers-reduced-motion: reduce</code> — when the OS preference is set, the scroll happens instantly. Same gate applies to the active-state color transitions on the ticks.</p>
        <h2 id="rs-default-events">Section-changed events</h2>
        <p>The spine dispatches <code>wirekit:reading-spine:section-changed</code> with details <code>{ index, id, text, level }</code> when the active heading changes. Listen with <code>x-on:wirekit:reading-spine:section-changed.window</code> to update the browser tab title, fire analytics on section view, or sync state with a deeper component.</p>
        <p>The dispatch is debounced 80ms — rapid scrolling doesn't fire 30 events for transient pass-throughs; only the heading the reader actually paused on triggers a callback.</p>
        <h3 id="rs-default-mobile">Mobile behavior</h3>
        <p>The spine is hidden on viewports under <code>md</code> (768px) by default — hover doesn't exist on touch and the fixed sidebar would crowd a narrow viewport. Pass <code>hideBelow="none"</code> to show the spine even on mobile, useful for the collapsed-tick "I'm in section X" indicator without the expand interaction.</p>
        <p>Filter slot: pass any input as the <code>filter</code> slot, two-way bind via <code>x-model="filter"</code>. Items hide when their text doesn't match (case-insensitive substring). Useful for very long outlines (e.g. API reference pages with 40+ sections).</p>
    </article>
</x-wirekit::prose>
:::

#### Filter slot — canonical worked example

Pass any input as the `filter` slot; two-way-bind via `x-model="filter"`.
Items hide when their text doesn't match (case-insensitive substring).

```blade
<x-wirekit::reading-spine>
    <x-slot:filter>
        <x-wirekit::input
            x-model="filter"
            placeholder="Filter sections..."
            size="sm"
        />
    </x-slot:filter>
</x-wirekit::reading-spine>
```

#### Reading Spine props

| Prop | Type | Default | Description |
|---|---|---|---|
| `target` | string\|null | `null` | Scroll container to scan; null falls back to `main, article` |
| `levels` | string | `'2,3'` | Comma-separated heading levels to include |
| `position` | string | `'right'` | `left` or `right` viewport edge |
| `expand` | string | `'hover'` | `hover` / `focus` / `always` / `always-md` |
| `offset` | string | `'6rem'` | Active-detection threshold |
| `hideBelow` | string | `'md'` | Tailwind responsive prefix (`sm`/`md`/`lg`/`xl`/`none`) |
| `numbered` | bool | `false` | Hierarchical 1, 2.1, 2.2 labels |
| `fillSections` | bool | `false` | Per-tick progress fill via CSS gradient |
| `sectionEvents` | bool | `true` | Dispatch `wirekit:reading-spine:section-changed` |
| `backToTop` | bool | `false` | Back-to-top pill at the spine bottom |
| `boundary` | `'container'`\|null | `null` | When set to `'container'`, swap Tailwind `fixed` → `absolute` so the spine stays inside the nearest positioned ancestor instead of the viewport edge. See [Scoping a primitive to its parent](#scoping-a-primitive-to-its-parent--the-boundary-prop). |
| `scope` | string\|null | `null` | Personalization scope |

### Reading TOC

Horizontal sticky-strip TOC for marketing landing pages. Sibling to
`reading-spine` — same data source (auto-builds from page headings via
the same plugin pattern, IntersectionObserver tracks the active
section), different rendered shape (a flat row of links across the top
or bottom of the article container) and different responsive defaults
(mobile-hidden by default).

**When to use:** marketing landing pages with 3-4 anchor sections
(Hero, Features, Pricing, FAQ) where a vertical sidebar feels
excessive. The strip pins to the top of the viewport on scroll, and
the link for the currently-visible section highlights via
`aria-current="location"`.

**When NOT to use:** dense documentation pages with many headings — a
horizontal strip overflows fast at viewport widths < 1024px. Reach for
`<x-wirekit::reading-spine>` with `hideBelow="none"` instead, since
its collapsed-tick mode works at narrow widths where a horizontal
strip cannot.

Typical range is three or four anchored sections — a landing page rather than a reference page.

```blade
<x-wirekit::reading-toc offset="4rem" />
```

::: info Works inside your own scroll region
If your shell scrolls an inner container rather than the page — a fixed header with `overflow-y: auto` beneath it — the jump finds that container on its own. Nothing to configure.
:::

The `offset` prop pushes the sticky position down by the developer's
own fixed nav height (e.g. `4rem` to clear a 64px header bar above the
TOC). The same value is passed through to the IntersectionObserver
rootMargin so active-section detection lines up with the visual
position of the strip.

:::preview{title="The reading family in one page", frame="iframe", height="640px", flush="true", desktopOnly}
<main>
    <x-wirekit::reading-toc flush />
    <article>
        <x-wirekit::prose>
            <section id="rd-hero" style="padding-block: 3rem; padding-inline: var(--padding-wk-x-sm);">
                <h2>Hero</h2>
                <p>Marketing landing page section one — a brief outline of what the page covers. The TOC strip pinned to the top of this iframe is sticky; scroll down and watch the active link follow you as each heading crosses the strip's bottom edge.</p>
                <p>The strip auto-scans <code>&lt;main&gt;</code> for <code>&lt;h2&gt;</code> elements (default <code>levels="2"</code>) and renders one link per heading. No prop wiring required for the common case — drop the tag inside <code>&lt;main&gt;</code>, the rest is automatic.</p>
                <p>Each link has <code>aria-current="location"</code> when active. The color comes from <code>--reading-toc-color-active</code> (the accent token by default). Idle state uses <code>--reading-toc-color-idle</code> (text-muted by default). Both transition over 150ms unless reduced-motion is set.</p>
            </section>
            <section id="rd-features" style="padding-block: 3rem; padding-inline: var(--padding-wk-x-sm);">
                <h2>Features</h2>
                <p>The bullet-pointed pitch. Each feature gets a paragraph or two, all under the same h2. The strip's IntersectionObserver flips the active link as soon as this heading crosses the viewport-top + offset line.</p>
                <p>Click the "Hero" link in the strip to smooth-scroll back. The smooth-scroll math accounts for the strip's offset so the destination heading lands just below the strip, not under it. URL hash updates via <code>replaceState</code> — back-button still goes to the previous page, not to the previous anchor.</p>
                <p>Per-link max-width truncation defaults to 24ch (<code>--reading-toc-link-max-width</code>). A long heading like "How to manage your team's billing settings" gets truncated to keep the strip from overflowing horizontally on narrow viewports.</p>
            </section>
            <section id="rd-pricing" style="padding-block: 3rem; padding-inline: var(--padding-wk-x-sm);">
                <h2>Pricing</h2>
                <p>Three tiers, each with a card. Anchor links from the TOC strip jump straight to this section. The strip stays visible all the way down the page (it's <code>position: sticky; top: 0</code> by default).</p>
                <p>If your page has a fixed nav above the article, pass <code>offset="4rem"</code> on the strip to push the sticky position down by your nav's height. The same value passes through to the IntersectionObserver rootMargin and to the smooth-scroll target math — three concerns, one offset value.</p>
                <p>The list is wrapped in <code>overflow-x-auto</code> so a strip with 6+ items still scrolls cleanly without breaking the page layout. On mobile (under sm by default), the strip is hidden entirely — pass <code>hideBelow="none"</code> to show it on mobile if your design accommodates it.</p>
            </section>
            <section id="rd-faq" style="padding-block: 3rem; padding-inline: var(--padding-wk-x-sm);">
                <h2>FAQ</h2>
                <p>Accordion of common questions. Last anchor in the strip. The strip stays visible here too — the reader can jump back to any earlier section without scrolling all the way up.</p>
                <p>For pages with more than 5-6 anchored sections, switch to the vertical reading-spine instead. The horizontal strip overflows fast at viewport widths under 1024px, even with per-link truncation. The spine's collapsed-tick mode is the right shape for dense vertical TOCs at narrow widths.</p>
                <p>The two primitives (<code>reading-toc</code> and <code>reading-spine</code>) share the same data-collection model — both auto-build from headings via the same Alpine plugin pattern, both track active sections via IntersectionObserver. Only the rendered shape differs.</p>
            </section>
        </x-wirekit::prose>
    </article>
</main>
:::

#### A11y / motion / print

- Real `<a href="#section-id">` links inside a `<nav aria-label="Page sections">`
  landmark — keyboard-navigable via Tab + Enter; screen readers
  announce the strip as a Navigation landmark.
- Active link carries `aria-current="location"` (matches spine pattern).
- `prefers-reduced-motion: reduce` collapses the smooth-scroll on link
  click to instant (matches family convention).
- Printed via `@media print { display: none !important }` — strip is
  screen-only chrome.

#### Reading TOC props

| Prop | Type | Default | Description |
|---|---|---|---|
| `target` | string\|null | `null` | Heading-host container; null resolves to `main, article` (family default) |
| `levels` | string | `'2'` | Comma-separated heading levels — usually just `'2'` for landing pages, `'2,3'` for nested-section sites |
| `position` | string | `'top'` | `top` or `bottom` (sticky to viewport top or bottom) |
| `offset` | string | `'0'` | CSS offset to clear developer-side fixed chrome (e.g. `'4rem'`) |
| `hideBelow` | string | `'sm'` | Tailwind responsive hide (`sm`/`md`/`lg`/`xl`/`none`) — mobile-hidden by default |
| `flush` | bool | `false` | Drop the inline padding so the link text runs flush to the viewport edge — for a TOC sitting under edge-to-edge page chrome. The hover background keeps its padding on the opposite edge. |
| `scope` | string\|null | `null` | Personalization scope |

### Reading Minimap

Every-item density overview of a scrollable container. Sibling
primitive to `reading-spine` — same family contracts, different visual
and interaction model. Two canonical use cases:

1. **Long-form article density-overview** — paragraphs / headings as
   stripes; sits at the article's right edge. Complements (or
   replaces) `reading-spine` when paragraph-level density-overview is
   wanted over heading-level TOC.
2. **Sidebar navigation density-overview** — nav links as stripes;
   sits at the sidebar's right edge. Designed for long sidebars where
   scanning for one specific item is friction.

#### Reading Minimap behavior

- Renders each matched item as a 1-2px stripe at proportional vertical
  position (`offsetTop / scrollHeight`)
- Translucent viewport-overlay rectangle tracks the host's visible
  region; rAF-throttled scroll updates
- Click stripe → smooth-scroll target so the item is centered in the
  viewport (instant under `prefers-reduced-motion: reduce`)
- Drag overlay → pan host scroll position proportionally
- Hover stripe (non-touch) → tooltip with the item's label (default
  `item.textContent`, override via `data-minimap-label="..."` attribute)
- `aria-hidden="true"` on the whole component — alternative view of
  nav already exposed to AT via the underlying primary nav

:::preview{title="Rendered mode — literal scaled-down preview", frame="iframe", height="640px", flush="true", desktopOnly}
<x-wirekit::reading-minimap
    hideBelow="none"
    mode="rendered"
    renderTarget="article"
    width="48px"
    :show-bookmarks="false"
    :auto-fade-idle="false"
/>
<x-wirekit::prose>
    <article style="padding: 1rem 4rem 1rem 1rem;">
        <h2 id="rmr-overview">Rendered mode</h2>
        <p>Scroll inside this preview to see how the minimap paints an abstract content-texture map of the article body. Every paragraph, heading, code block, and image becomes a small colored rectangle on a canvas — the editor-minimap convention from PhpStorm and VS Code, not a literal scaled-down clone.</p>
        <p>The canvas is painted by walking every text-bearing node in the source via <code>TreeWalker</code>, getting per-rendered-line rects via <code>Range.getClientRects()</code>, and drawing each as a rectangle at minimap scale. Visual elements (img, figure, svg, video, canvas) are drawn in a second pass at their natural bounding box.</p>

        <h3 id="rmr-when">When to choose rendered over stripes</h3>
        <p>Pick rendered-mode when the visual rhythm of the page is part of the navigation cue — long-form articles with figures, code blocks, and varied section lengths. Pick the default stripe mode when raw density (one tick per item) reads clearer.</p>
        <p>For very long articles (over 5 000 source DOM nodes) rendered-mode falls back automatically to stripes — the canvas-build pass is O(text nodes) and the budget keeps the cold-start cost bounded above that threshold.</p>

        <h3 id="rmr-theme">Theme awareness</h3>
        <p>Each element type tints with its own color: h1 through h6 each get a distinct alpha tier (h1 darkest, h6 lightest); <code>pre</code>/<code>code</code> blocks tint indigo; tables tint rose; blockquotes tint slate-bolder; images tint amber; any <code>[class*="wk-"]</code> WireKit component tints emerald; prose and default tint slate-muted.</p>
        <p>All 13 colors override via CSS custom properties on the minimap root (<code>--reading-minimap-color-h1</code> through <code>--reading-minimap-color-default</code>). The component reads each token via <code>getComputedStyle</code> at paint time, so per-theme overrides apply automatically — no JS-side branching needed.</p>

        <h2 id="rmr-perf">Performance characteristics</h2>
        <p>The minimap mounts lazily — the canvas isn't constructed until the wrapper enters the viewport. A <code>MutationObserver</code> on the source target rebuilds the canvas on meaningful content changes, debounced 250 ms.</p>
        <p>Scroll tracking runs at one <code>requestAnimationFrame</code> tick per scroll event — compositor-only transforms, no layout thrash. Reduced-motion users get an instant snap-update instead of the rAF loop.</p>
        <p>Keep scrolling — the more text you scroll past, the further down the minimap your viewport-overlay rectangle moves.</p>
    </article>
</x-wirekit::prose>
:::

:::preview{title="Minimap stripes + viewport overlay tracking scroll", frame="iframe", height="640px", flush="true", desktopOnly}
<x-wirekit::reading-minimap hideBelow="none" itemSelector="h2, h3, p" />
<x-wirekit::prose>
    <article style="padding: 1rem 5rem 1rem 1rem;">
        <h2 id="rm-overview">Overview</h2>
        <p>Each heading and paragraph in this article is rendered as a 1-2px stripe at the right edge of the iframe — that's the reading-minimap. Scroll inside this preview frame to watch the translucent viewport-overlay rectangle move down the column as the page scrolls.</p>
        <p>The minimap sits at <code>position: fixed</code> against the iframe's viewport, so it stays pinned regardless of where the reader is in the article. Width defaults to 60px; the stripes inside are right-aligned and tinted with the accent token.</p>
        <h3 id="rm-stripes">Stripe density</h3>
        <p>Stripe count equals the number of items the <code>itemSelector</code> picks up — by default <code>'a, h2, h3, [data-minimap-item]'</code>. This preview overrides to <code>'h2, h3, p'</code> so every paragraph renders as its own stripe; on a real article this gives a paragraph-level density overview.</p>
        <p>Stripes thicker than 1px encode heading level (h2 thicker than h3, etc.). Paragraph stripes are 1px hairlines so the visual hierarchy survives at this scale.</p>
        <h2 id="rm-viewport">Viewport overlay</h2>
        <p>The translucent rectangle on top of the stripe column tracks the host's visible region. Its top + height come from <code>scrollY / scrollHeight</code> and <code>clientHeight / scrollHeight</code> respectively, so the rectangle's vertical position + size always represent "where am I, and how much is in view".</p>
        <p>Drag the rectangle to pan the host scroll position proportionally. Click any stripe to smooth-scroll the host so the matching item lands centered in the viewport.</p>
        <h3 id="rm-mobile">Mobile behavior</h3>
        <p>The minimap defaults to <code>hideBelow="lg"</code> (hidden under 1024px viewport) because a stripe column at narrow widths competes with the article body for horizontal space. This preview overrides to <code>"none"</code> so the minimap is visible inside the iframe regardless of width.</p>
        <p>For mobile-friendly density overviews, prefer the reading-spine — its collapsed-tick mode works at narrow widths.</p>
        <h2 id="rm-end">Closing</h2>
        <p>The minimap is the densest of the reading-* primitives, but every primitive in the family follows the same contract: scroll-driven, IntersectionObserver-tracked, theme-aware via family-prefixed CSS variables.</p>
        <p>Keep scrolling — the viewport overlay continues to move down as the article ends. By the bottom, the rectangle should sit at the lower portion of the stripe column.</p>
    </article>
</x-wirekit::prose>
:::

:::preview{title="Block-style stripes — :itemStyle=block (content-texture, skeleton-style rectangles)", frame="iframe", height="640px", flush="true", desktopOnly}
<x-wirekit::reading-minimap hideBelow="none" itemSelector="h2, h3, p" itemStyle="block" />
<x-wirekit::prose>
    <article style="padding: 1rem 5rem 1rem 1rem;">
        <h2>Block-style stripes</h2>
        <p>Each item renders as a soft gray rectangle whose height tracks the source paragraph's natural height — the minimap reads as a content-texture rather than a sparse list of lines.</p>
        <p>Use this variant when the article has wildly different section lengths and the per-section visual weight is itself a navigation cue. The classic line-mode stays the default for dense link-lists or sidebars where vertical real estate is tight.</p>
        <h3>Theming</h3>
        <p>Three new tokens drive the block visuals: <code>--reading-minimap-block-bg</code> (background, defaults to a 25%-alpha mix of the idle stripe color), <code>--reading-minimap-block-radius</code> (corner radius, 2px), and <code>--reading-minimap-block-gap</code> (inter-block gap, 2px).</p>
        <p>Override any of them at <code>:root</code> for theme-wide changes. The viewport-overlay rectangle and click-to-scroll mechanics behave the same as the line variant.</p>
        <h2>When it shines</h2>
        <p>Long-form articles where some sections are a single paragraph and others span several screens of content. The block-mode visualization echoes that rhythm so the reader sees the article's pacing at a glance.</p>
        <p>For pure link-lists, marketing landing pages, or any density where every item is roughly the same height, stick with the default line mode — it's more compact and reads cleaner.</p>
        <h3>Scroll to test the active marker</h3>
        <p>Scroll this preview downward. The darker block in the minimap column moves with you — it always represents the section whose heading is currently above the viewport-center line. The Alpine controller picks the active index by walking the items collection on every <code>scroll</code> event (rAF-throttled) and selecting the last item whose absolute top has passed the center.</p>
        <p>This "where am I" cue is the most-used affordance of the minimap. Click any block to jump straight to that section — the smooth-scroll math centers the heading in the viewport. With <code>prefers-reduced-motion: reduce</code> the scroll is instant.</p>
        <p>The active-detection works against any scroll container, not just the window. Pass <code>:target="'.article'"</code> on the minimap and the IntersectionObserver re-roots itself to the named container. Useful for split-pane layouts where the article body is its own scroll context.</p>
        <h2>Composition with the rest of the family</h2>
        <p>The minimap pairs cleanly with <code>&lt;x-wirekit::reading-progress&gt;</code> at the top of the page and <code>&lt;x-wirekit::reading-spine&gt;</code> on the inboard edge of the article. All three primitives share the same item-collection contract — pass identical <code>itemSelector</code> values and the active marker stays in sync across all three displays.</p>
        <p>Reading-bookmark plays well too: when the developer returns to the article, the saved scroll offset restores; the minimap's active marker syncs to whichever block corresponds to that restored position. No coordination needed between the primitives — they each subscribe to the same scroll source and resolve independently.</p>
        <p>The reading-shell wrapper composes all of them in one tag with sane defaults. Pass <code>:minimap="true"</code> on the shell and the block-mode minimap appears on the right edge with no further wiring.</p>
        <h3>Density tuning</h3>
        <p>Reduce <code>--reading-minimap-block-gap</code> to <code>0</code> for a continuous bar (no inter-block whitespace) — useful when the article has many short paragraphs and the gap noise would dominate the texture. Increase it to <code>4px</code> for a more "card-stack" feel.</p>
        <p>The block-radius token (<code>--reading-minimap-block-radius</code>) accepts any CSS length. A value of <code>9999px</code> turns each block into a pill — works well for sidebars where the minimap doubles as a scroll-handle. Zero gives sharp rectangles for the most "data-density" appearance.</p>
        <p>The background token (<code>--reading-minimap-block-bg</code>) is the only color control specific to block mode. The idle stripe color, active color, and viewport-overlay tokens are shared with line mode — so a theme override carries across the two variants without duplicating.</p>
        <h2>Performance characteristics</h2>
        <p>One DOM node per matched item — the same as line mode. The proportional-height calculation runs once on init (and again on resize via ResizeObserver), so the steady-state cost is identical to line mode. There's no per-frame layout work beyond what the line variant already does.</p>
        <p>For very long articles (1000+ matched items), the block-mode column becomes visually dense to the point of unreadability — the per-item height averages below 1 px and the texture blurs to a solid bar. The minimap auto-falls-back to line mode beyond <code>5000</code> matched items as a hard guard, but developers should manually opt into line mode much earlier (around 100-200 items) for legibility.</p>
        <p>The fallback threshold is configurable via the <code>RENDERED_MAX_TAGS</code> constant in the JS plugin, but the practical advice is: if your article has more than ~200 paragraphs, line mode reads better. Block-mode shines in the 10-100 item range.</p>
        <h3>Where line mode wins instead</h3>
        <p>Sidebar TOCs, link-lists with consistent item height, dashboard widget rows — anything where the per-item visual weight is uniform and irrelevant to navigation. Line mode renders as crisp horizontal hairlines that read like a scrollbar's tick marks.</p>
        <p>Block mode amplifies natural-height variance; line mode collapses it. Pick by content shape: variable paragraph lengths → blocks; uniform list items → lines.</p>
        <h2>Closing</h2>
        <p>Keep scrolling all the way to the bottom — the active marker eventually lands on this very paragraph. By then the viewport-overlay rectangle should sit at the lower end of the minimap column, confirming you've reached the article's end.</p>
    </article>
</x-wirekit::prose>
:::

:::preview{title="Custom-themed minimap — accent color, rounded blocks, bordered column", frame="iframe", height="640px", flush="true", desktopOnly}
<style>
.docs-themed-minimap-demo {
    --reading-minimap-color-idle: color-mix(in srgb, #6366f1 25%, transparent);
    --reading-minimap-color-active: #6366f1;
    --reading-minimap-block-bg: color-mix(in srgb, #6366f1 18%, transparent);
    --reading-minimap-block-radius: 4px;
    --reading-minimap-block-gap: 3px;
    --reading-minimap-viewport-bg: color-mix(in oklab, #6366f1 18%, transparent);
    --reading-minimap-viewport-border: 1.5px solid color-mix(in oklab, #6366f1 65%, transparent);
    --reading-minimap-width: 72px;
}
.docs-themed-minimap-demo .wk-reading-minimap {
    border: 1px solid color-mix(in srgb, #6366f1 30%, transparent);
    border-radius: 8px;
    padding: 4px;
    background: color-mix(in srgb, #6366f1 4%, transparent);
}
</style>
<div class="docs-themed-minimap-demo">
<x-wirekit::reading-minimap hideBelow="none" itemSelector="h2, h3, p" itemStyle="block" width="72px" />
<x-wirekit::prose>
    <article style="padding: 1rem 5.5rem 1rem 1rem; overflow-wrap: anywhere;">
        <h2>Custom-themed minimap</h2>
        <p>Token overrides give the minimap an entirely different look without touching the component itself. This preview wraps the minimap in a scope (<code>.docs-themed-minimap-demo</code>) and overrides six tokens: idle/active stripe color, block background, block radius + gap, viewport overlay bg/border. Plus a wrapper border and soft tint on the column itself.</p>
        <p>Scroll inside this preview — the translucent rectangle in the minimap column tracks your visible region. Its top + height come from <code>scrollY / scrollHeight</code> and <code>clientHeight / scrollHeight</code> respectively, so the rectangle's vertical position + size always represent "where am I, and how much is in view". Drag the rectangle to pan the host scroll position proportionally.</p>
        <p>The minimap suppresses the viewport-overlay rectangle entirely when the article fits in the viewport without scrolling — a "you are here" affordance is meaningless when everything is already visible. As soon as the article exceeds the viewport (like this one does), the overlay appears and tracks scroll.</p>
        <h3>Pattern</h3>
        <p>The minimap is fully token-driven. Override colors, spacing, radii, geometry — anything declared in <code>:root</code> can be overridden at any descendant level. The component never hardcodes a color or sizing value; everything flows through CSS variables.</p>
        <p>This makes the minimap easy to brand: pick one or two accent colors from your design system, override the idle / active / overlay tokens, done. The component logic stays untouched.</p>
        <p>Every token has a single declaration source (this preview's <code>&lt;style&gt;</code> block), and the cascade does the rest. No JavaScript-side branching for theming; the component reads the CSS-resolved value at render time.</p>
        <p>The token approach scales to dark-mode automatically: declare a separate set under <code>.dark</code> and the same minimap re-themes when the theme toggle flips. No JS-side reactive subscriptions, no component remount — just CSS cascade resolution.</p>
        <h3>Block-style heights</h3>
        <p>This preview also uses <code>itemStyle="block"</code> — each stripe renders as a soft rectangle whose height tracks the source paragraph's natural height. The minimap reads as a content-texture rather than a sparse list of lines, which works well for articles with wildly different section lengths.</p>
        <p>Three additional tokens drive the block visuals: <code>--reading-minimap-block-bg</code> (background, defaults to a 25%-alpha mix of the idle stripe color), <code>--reading-minimap-block-radius</code> (corner radius, 2px), and <code>--reading-minimap-block-gap</code> (inter-block gap, 2px). Override any of them at <code>:root</code> for theme-wide changes.</p>
        <p>The block height is computed from the source element's natural rendered height, normalized against the article's total scrollHeight. A 200-word paragraph renders as a taller block than a 30-word callout — the minimap mirrors the visual rhythm of the source page.</p>
        <p>For dense link-lists or sidebars where every item is roughly the same height, switch back to <code>itemStyle="line"</code> (the default). The line variant renders cleaner at high density where rhythm doesn't matter, only hierarchy.</p>
        <h2>Composability</h2>
        <p>The same approach scales: drop a different override block for a "danger" article (red accent), a "promo" landing page (gradient overlay), an "archived" notice (muted everything). Each style lives in CSS, not PHP.</p>
        <p>For developers using personalization scopes (Level 3 customization), the same tokens are addressable per scope via <code>WireKit::scope('brand-blue', ['reading-minimap' =&gt; ['classes' =&gt; [...]]])</code>. The scope wrapper acts like a localized theme — anything inside the scope picks up the overrides, anything outside stays on the default token chain.</p>
        <p>This composes cleanly with the four extension props (<code>hoverPreview</code>, <code>showBookmarks</code>, <code>headingAnchors</code>, <code>autoFade</code>): opt in to whichever extensions fit the page, the theme tokens apply to all of them.</p>
        <p>Mixing modes within a single page works too: drop one minimap with <code>mode="stripes"</code> at one breakpoint and another with <code>mode="rendered"</code> at a different breakpoint via media queries. The two share zero state — purely visual variants of the same primitive.</p>
        <h3>Real-world recipes</h3>
        <p>The recipes directory ships three pre-composed minimap examples: <code>/blueprints/recipes/long-form-article</code> (default stripes + bookmark), <code>/blueprints/recipes/documentation-reader</code> (compact block-mode + heading-anchors), and <code>/blueprints/recipes/marketing-landing-toc</code> (which uses reading-toc instead, but pairs well with a minimap on long pages).</p>
        <p>Each recipe is a single Blade file you can copy into your own app. No build step beyond Composer; no JavaScript wiring beyond the WireKit script tag.</p>
        <p>The recipes are intentionally minimal — they show the canonical composition pattern (minimap + bookmark + spine) for each use case, leaving theming and content to the developer. The /blueprints/recipes/ pages on docs.wirekit.app render the recipes as live previews so you see exactly what the output looks like.</p>
        <h2>Performance characteristics</h2>
        <p>The stripe column renders one DOM node per matched item (default selector matches a, h2, h3, [data-minimap-item]). On a typical article (3-10 sections, 20-60 paragraphs), that's a sub-100-node footprint — well within the budget of any reasonable browser. The scroll handler is rAF-throttled so position updates never exceed one per frame.</p>
        <p>Click + drag-pan on the viewport overlay uses pointer events (PointerEvent API) for unified mouse + touch handling. No separate touch-event fallback; pointer events have been baseline since the Tailwind v4 browser baseline.</p>
        <p>Active-item tracking uses IntersectionObserver with a tuned rootMargin — the minimap stays in sync with the reader's progress without polling on every scroll tick.</p>
        <p>The component does not poll on scroll. Every reactive update is either a rAF callback (scroll position writes), an IntersectionObserver entry (active-index changes), or a ResizeObserver entry (responsive recalc on viewport change). Idle-state CPU usage is effectively zero.</p>
        <h3>Memory budget</h3>
        <p>Stripes-mode mounts one <code>&lt;li&gt;</code> per matched item plus the wrapper + overlay. Block-mode is identical. Rendered-mode adds a canvas element sized to the minimap dimensions — sub-MB memory at typical widths.</p>
        <p>The hover-preview extension (<code>hoverPreview="true"</code>) lazily mounts a sandboxed iframe-srcdoc on first pointermove, sanitized via the same OWASP-vector-blocking pass the rendered-mode preview used. Sub-100 KB DOM in the popover; no script execution surface (sanitizer strips all attack vectors before parse).</p>
        <h2>When to reach for rendered mode instead</h2>
        <p>If the article has a visually distinct rhythm — long sections alternating with short callouts, image-heavy figures, code blocks every few paragraphs — the <code>mode="rendered"</code> canvas variant gives a more recognizable thumbnail than the abstract stripes. Drop <code>mode="rendered"</code> on the same component to opt in.</p>
        <p>For dense link-lists or sidebars where every item is roughly the same height, stripes-mode with <code>itemStyle="line"</code> reads cleaner — the rhythm doesn't matter, the hierarchy does.</p>
        <p>The two modes share the same Alpine state (items collection, active-index tracking, viewport-overlay math). Only the rendered visual differs. Switching <code>mode</code> at runtime is supported — the component re-renders without a remount.</p>
        <h3>Closing</h3>
        <p>The minimap is the densest of the reading-* primitives but follows the same family contract: scroll-driven, IntersectionObserver-tracked, theme-aware via family-prefixed CSS variables. The token surface is intentionally wide so developer brands can re-skin without forking.</p>
        <p>Keep scrolling to the end of this preview — the viewport overlay rectangle continues to track your position all the way down. By the bottom, the rectangle should sit at the lower portion of the minimap column.</p>
    </article>
</x-wirekit::prose>
</div>
:::

#### Reading Minimap props

| Prop | Type | Default | Description |
|---|---|---|---|
| `target` | string\|null | `null` | Scroll container; null auto-resolves nearest scrollable ancestor |
| `itemSelector` | string | `'a, h2, h3, [data-minimap-item]'` | CSS selector matching items to render as stripes |
| `width` | string | `'60px'` | Column width |
| `side` | string | `'right'` | `left` or `right` edge of the host |
| `draggable` | bool | `true` | Enable click + drag-pan on the viewport overlay |
| `hideBelow` | string | `'lg'` | Tailwind responsive hide (`sm`/`md`/`lg`/`xl`/`none`) — desktop-only by default |
| `scope` | string\|null | `null` | Personalization scope |
| `mode` | string | `'stripes'` | `'stripes'` (default, abstract bars) or `'rendered'` (literal scaled-down page preview) |
| `itemStyle` | string | `'line'` | Stripe-mode visual: `'line'` (thin 2px stripes per item) or `'block'` (skeleton-style rectangles whose height tracks each source item's natural height — content-texture view) |
| `renderTarget` | string\|null | `null` | CSS selector for the source to clone in rendered-mode (defaults to `target`) |
| `hoverPreview` | bool | `false` | Extension E1 — magnified popover near cursor on minimap hover |
| `showBookmarks` | bool | `true` | Extension E2 — render a marker line for a sibling `<x-wirekit::reading-bookmark>` |
| `headingAnchors` | bool | `false` | Extension E3 — clickable mini-anchors on the outboard edge, one per h2/h3 |
| `headingLevels` | string | `'2,3'` | Extension E3 — CSV of heading levels to surface as anchors |
| `autoFadeIdle` | bool | `true` | Extension E4 — fade after 3 s of inactivity; hover restores full opacity |

#### Rendered mode

Set `mode="rendered"` to swap the abstract stripe column for a literal
scaled-down clone of the article DOM inside a sandboxed iframe. Reads
as a true page preview — you can see where headings, code blocks,
images, and figures sit relative to one another rather than just
"density of items".

```blade
<x-wirekit::reading-minimap
    render-target="article"
    mode="rendered"
    :hover-preview="true"
    :heading-anchors="true"
/>
```

How it works:

- Lazy-init via `IntersectionObserver` — the iframe is only constructed
  when the minimap actually enters the viewport.
- Source DOM is sanitized (every `<script>`, every `on*` handler,
  every `javascript:` / `data:` URI, every nested `<iframe>`,
  every inline `<style>`) before the srcdoc payload is written.
- Same-origin stylesheets are injected into the iframe head via
  `<link>` so the clone inherits the host theme.
- A `MutationObserver` on the source watches for content changes
  (Livewire morph, hot-reload edits) and rebuilds the iframe with
  a 250 ms debounce.
- Above 5 000 source-DOM tags, rendered-mode silently falls back to
  stripe-mode to keep memory + first-paint cost bounded.

The iframe itself is fully decorative: `aria-hidden="true"`,
`tabindex="-1"`, `sandbox="allow-same-origin"` (no `allow-scripts`).
All interaction lives on the wrapping element — clicks translate to
parent-window `scrollTo()`, hover triggers the optional preview popover.

#### Extensions

Four optional extensions layer on top of either rendering mode:

| Extension | Prop | What it does |
|---|---|---|
| Hover preview | `:hover-preview="true"` | Magnified 25%-scale popover follows the cursor on minimap hover, anchored near the pointer + flipped to stay inside the viewport |
| Bookmark marker | `:show-bookmarks="true"` (default on) | Auto-detects a sibling `<x-wirekit::reading-bookmark>` via its `data-reading-bookmark-key` attribute and renders a horizontal marker at the saved offset; updates live via custom event + browser `storage` event for cross-tab consistency |
| Heading anchors | `:heading-anchors="true"` | Walks the source DOM for `h2`/`h3` (configurable via `headingLevels`) and emits a clickable `<a href="#id">` on the outboard edge for each one; density guard collapses overlapping labels (`< 16 px gap`), which surface again on minimap hover |
| Auto-fade idle | `:auto-fade-idle="true"` (default on) | Fades the minimap to `--reading-minimap-idle-opacity` after 3 s of `pointermove`/`scroll`/`pointerdown` inactivity; any activity restores full opacity, hover overrides instantly |

All four extensions honor `prefers-reduced-motion: reduce` — fade
transitions collapse to 0.01 ms so users with vestibular sensitivities
see an instant state change instead of motion.

### Decision tree — TOC vs Spine vs Minimap

Now that all three navigation primitives have been introduced, the
quick pick-list:

| Use case | Pick |
|---|---|
| Marketing landing page, 3-4 flat sections | `reading-toc` |
| Long-form blog post or docs page with nested headings | `reading-spine` |
| Long article OR sidebar-nav density overview | `reading-minimap` |

The three primitives are intentionally orthogonal: `reading-toc` is
horizontal + flat, `reading-spine` is vertical + hierarchical,
`reading-minimap` is every-item density. Pick by the dominant shape
of your content, not by the use case alone.

### Reading Bookmark

Persists the reader's scroll position to localStorage and surfaces a
"Resume reading?" pill on return-visit when conditions are met
(previous-session dwell time ≥ `minDwellSeconds`, scroll moved past
`threshold * scrollHeight`).

**localStorage-only by design.** Cross-device sync is explicitly out of
scope — that's a backend feature, not a UX affordance. Developers
needing cross-device persistence build their own server-side bookmark
backed by Eloquent + a few endpoints; the WireKit primitive stays
client-side simple.

```blade
<x-wirekit::reading-bookmark key="article-{{ $post->slug }}" />
```

:::preview{title="Resume-reading pill triggered by a seeded prior visit", frame="iframe", height="520px", flush="true"}
<div
    x-data="{
        init() {
            // Seed a fake prior bookmark BEFORE the child reading-bookmark
            // component's init() reads localStorage. Alpine guarantees
            // PARENT init() runs before CHILD init() (DOM-tree order),
            // so wrapping the bookmark in this parent x-data eliminates
            // the race that two sibling x-data blocks would expose. The
            // earlier sibling-pattern with x-init on an empty div was
            // unreliable — Alpine's walk order between siblings is not
            // guaranteed by the spec, and bookmark could mount + read
            // before the seed write fired.
            try {
                localStorage.setItem('reading-bookmark-demo', JSON.stringify({
                    top: 600,
                    dwell: 90,
                    t: Date.now() - 5 * 60 * 1000,
                }));
            } catch (_) { /* private mode — ignore */ }
        }
    }"
>
    <x-wirekit::reading-bookmark key="reading-bookmark-demo" :minDwellSeconds="0" />
    <x-wirekit::prose>
        <article style="padding: 1rem; min-height: 800px;">
            <h2>Resume reading?</h2>
        <p>The pill in the bottom-right of this iframe is the resume-reading affordance. It surfaces when localStorage already holds a prior bookmark (saved on a previous visit, after the reader dwelled long enough and scrolled past the threshold). Click <strong>Resume</strong> to scroll the iframe to the saved position; click <strong>×</strong> to dismiss the pill while keeping the bookmark for the next visit.</p>
        <p>This preview seeds a fake prior bookmark on mount via inline Alpine — the real component reads it back on init and shows the pill. On a real article you would not need the seed: the bookmark writes itself as the reader scrolls, and reads itself on the next visit.</p>
        <p>The component clears the bookmark when the reader scrolls back to the top — at that point they are starting over, not resuming. It also reacts to cross-tab <code>storage</code> events, so dismissing the pill in one tab hides it in every other open tab on the same article.</p>
        <p>Scroll inside this iframe to write a fresh bookmark on top of the seeded one (the seed updates with each scroll past the 10% threshold). Reload the iframe to see the pill reappear with the latest position.</p>
        <h2>Filler — keep scrolling</h2>
        <p>Cross-device sync is intentionally out of scope. The localStorage write is per-origin per-key; if you want a cross-device "resume on phone where I left off on desktop" experience, build a server-side bookmark via Eloquent (a column on the user-article pivot table) and listen for the same scroll events the primitive emits.</p>
        <p>The pill is rendered with <code>role="status"</code> and <code>aria-live="polite"</code> — assistive technology announces the resume offer when it appears without interrupting the current reading flow.</p>
        <p>The Resume button uses the accent fill; the dismiss button is icon-only with an <code>aria-label</code>. Both are keyboard-focusable and respect the same focus-ring tokens every other interactive WireKit primitive uses.</p>
        </article>
    </x-wirekit::prose>
</div>
:::

#### Reading Bookmark props

| Prop | Type | Default | Description |
|---|---|---|---|
| `key` | string | required | localStorage key (must be unique per article) |
| `target` | string\|null | `null` | Scroll container; null falls back to `main, article` |
| `threshold` | float | `0.1` | Fraction of scrollHeight before saving |
| `promptOnReturn` | bool | `true` | Show resume pill on return-visit |
| `minDwellSeconds` | int | `30` | Minimum dwell time before bookmark saves |
| `previewMode` | bool | `false` | Sandbox-renderer escape hatch — disables localStorage IO |
| `boundary` | `'container'`\|null | `null` | When set to `'container'`, swap Tailwind `fixed` → `absolute` so the resume-prompt pill stays inside the nearest positioned ancestor. See [Scoping a primitive to its parent](#scoping-a-primitive-to-its-parent--the-boundary-prop). |
| `scope` | string\|null | `null` | Personalization scope |

### Reading Meta

Time-to-read estimate. Computes word count from the target's
`textContent` on mount, skipping nodes that don't read at prose pace
(`pre`, `code`, `figure`, `figcaption`, `img`, `picture`, `svg`,
`[data-language]`).

CJK-aware: when more than 40% of text is CJK ideographs (Chinese /
Japanese kana / Korean Hangul), falls back to character-based
estimation with a configurable `cjkCharsPerMinute` baseline (default
500) — Chinese / Japanese / Korean text has no spaces between
logographic characters, so whitespace tokenization underestimates word
count by an order of magnitude.

```blade
<x-wirekit::reading-meta :showRemaining="true" />
```

#### perParagraph mode (Medium-style inline annotations)

When `:perParagraph="true"`, the component injects small
`<span class="wk-reading-meta-paragraph">3 min</span>` annotations
immediately before each `<p>` in the target with at least
`paragraphMinWords` words (default 30). Annotations show estimated
remaining-time FROM that paragraph onward, re-computed on scroll.

```blade
<x-wirekit::reading-meta :perParagraph="true" />
```

Annotations carry `aria-hidden="true"` — the canonical screen-reader
text remains the total/remaining display. Developers fully restyle via
the `.wk-reading-meta-paragraph` class.

#### Reading Meta props

| Prop | Type | Default | Description |
|---|---|---|---|
| `target` | string\|null | `null` | Container to scan; null falls back to `main, article` |
| `wpm` | int | `225` | Words per minute (English baseline) |
| `showRemaining` | bool | `false` | Show "X min remaining" beside total |
| `perParagraph` | bool | `false` | Inject inline per-paragraph remaining-time annotations |
| `totalLabel` | string | `'min read'` | Total-estimate suffix |
| `remainingLabel` | string | `'min remaining'` | Remaining-estimate suffix |
| `paragraphLabelTemplate` | string | `'{n} min'` | Template for per-paragraph annotation text (`{n}` substitutes minutes) |
| `paragraphMinWords` | int | `30` | Minimum word count for a paragraph to receive an annotation |
| `cjkCharsPerMinute` | int | `500` | CJK-fallback character-pace baseline |
| `scope` | string\|null | `null` | Personalization scope |

## Composition: `<x-wirekit::reading-shell>`

The 80% case: one tag wraps the article body and renders sensible
defaults for progress + spine + bookmark. Density preset adjusts the
feature set; per-primitive toggles override.

```blade
<x-wirekit::reading-shell :bookmark-key="'article-' . $slug">
    {{ $slot }}
</x-wirekit::reading-shell>
```

### Density presets

| Density | Progress | Spine | Minimap | Meta | Spine expand |
|---|---|---|---|---|---|
| `comfortable` (default) | md height | on | off | off | hover |
| `compact` | sm height | on | off | off | always-md |
| `minimal` | sm height | off | off | off | hover |

Density is the starting-point shape. Per-primitive toggles override the
preset's baseline:

```blade
{{-- density="minimal" hides spine by default; :spine="true" turns it back on --}}
<x-wirekit::reading-shell density="minimal" :spine="true" :minimap="true">
    {{ $slot }}
</x-wirekit::reading-shell>
```

### Reading Shell props

| Prop | Type | Default | Description |
|---|---|---|---|
| `bookmarkKey` | string\|null | `null` | Required to enable bookmark |
| `progress` | bool\|null | `null` | Toggle (null = use density default) |
| `spine` | bool\|null | `null` | Toggle (null = use density default) |
| `minimap` | bool\|null | `null` | Toggle (null = use density default) |
| `toc` | bool\|null | `null` | Toggle (null = use density default; off in `comfortable` and `compact`, so opt in with `:toc="true"`) |
| `bookmark` | bool\|null | `null` | Toggle (null = on if bookmarkKey set) |
| `meta` | bool\|null | `null` | Toggle (null = use density default; default density off) |
| `density` | string | `'comfortable'` | `comfortable` / `compact` / `minimal` |
| `previewMode` | bool | `false` | Sandbox-renderer escape hatch |
| `boundary` | `'container'`\|null | `null` | Pass-through to every composed primitive (`reading-progress`, `reading-spine`, `reading-bookmark`). Default `null` keeps the viewport-pinned shape; `'container'` scopes every composed primitive to the nearest positioned ancestor. See [Scoping a primitive to its parent](#scoping-a-primitive-to-its-parent--the-boundary-prop). |
| `scope` | string\|null | `null` | Personalization scope |

### Power-user composition (skip the shell)

When toggles + density don't cover the customization need, compose
primitives directly:

```blade
<x-wirekit::reading-progress variant="success" indicator="dot" :milestones="true" />
<x-wirekit::reading-spine :levels="'2,3,4'" :numbered="true" :backToTop="true">
    <x-slot:filter>
        <x-wirekit::input x-model="filter" placeholder="Filter..." size="sm" />
    </x-slot:filter>
</x-wirekit::reading-spine>
<x-wirekit::reading-minimap target="#article" item-selector="h2, h3" />
<x-wirekit::reading-bookmark key="article-{{ $slug }}" :minDwellSeconds="60" />
<x-wirekit::reading-meta :wpm="200" :showRemaining="true" :perParagraph="true" />

{{ $slot }}
```

The shell covers the common defaults case, and power-users opt out to the
primitives entirely — that's the design intent.

## Scoping a primitive to its parent — the `boundary` prop

`reading-progress`, `reading-spine`, and `reading-bookmark` accept an optional `boundary` prop. Three accepted shapes:

| Value | Behavior |
|---|---|
| `null` (default) | Viewport-pinned. Every existing developer sees zero change. |
| `'container'` | Scope to the nearest positioned ancestor (sticky / Tailwind `absolute`). |
| `'<css-selector>'` (any string) | Same scoped shape as `'container'`, AND the Alpine init asserts an ancestor matching the selector exists — emits a `console.warn` when the selector matches none. Useful when the contained surface is deep inside a layout and you want a runtime sanity check. |

Two requirements when using any non-`null` boundary:

1. The parent must establish a positioning context (`position: relative` is the cheapest choice).
2. For `reading-progress`, the parent must also be a scrolling container (`overflow: auto` or `scroll`) with a defined height — `position: sticky` falls back to static positioning in a non-scrolling parent, and the bar would disappear from view.

```blade
{{-- 1. The contained reading surface — relative + scrollable. --}}
<div class="relative max-h-[24rem] overflow-y-auto" style="position: relative;">
    {{-- 2. Inside boundary, the progress bar stays put. --}}
    <x-wirekit::reading-progress boundary="container" />

    <article>
        <h2>Article body</h2>
        <p>The progress bar lives inside this card and never escapes.</p>
    </article>
</div>
```

The same pass-through is wired on `<x-wirekit::reading-shell>` — when the shell carries `boundary="container"`, every composed primitive (`reading-progress`, `reading-spine`, `reading-bookmark`) inherits it.

:::preview{title="Reading-progress contained inside a card via boundary='container'", frame="iframe", height="320px", desktopOnly}
{{-- 1. The contained reading surface establishes a positioning context
      (position: relative) AND a scrolling container (overflow-y: auto)
      with a defined height — both required for `position: sticky` to
      anchor correctly. Without these, the sticky strategy degrades
      to static and the bar disappears. --}}
<div role="region" aria-label="Contained reading surface" tabindex="0" style="position: relative; max-height: 18rem; overflow-y: auto; border: 1px solid var(--color-wk-border); border-radius: var(--radius-wk-md); padding: 0; background: var(--color-wk-bg-elevated);">
    {{-- 2. boundary="container" makes the bar use position: sticky and
          anchor to the nearest positioned ancestor — the wrapper above. --}}
    <x-wirekit::reading-progress boundary="container" height="md" variant="primary" />

    <article style="padding: 1.25rem 1.5rem;">
        <h3 style="margin: 0 0 0.5rem 0;">Containment demo</h3>
        <p style="margin: 0 0 0.75rem 0;">Scroll inside THIS card and the bar fills as the local scroll progresses — it never escapes to the outer page viewport.</p>
        <p style="margin: 0 0 0.75rem 0;">Each paragraph extends the scrollable area inside the card. Keep scrolling and the bar saturates.</p>
        <p style="margin: 0 0 0.75rem 0;">A modal body, sidebar pane, preview frame, or Livewire panel are all valid container surfaces — anything with <code>position: relative</code> + a scroll-mechanism works.</p>
        <p style="margin: 0 0 0.75rem 0;">Without <code>boundary="container"</code>, the bar would pin to the OUTER viewport instead of this card, breaking the contained-reading metaphor.</p>
        <p style="margin: 0 0 0.75rem 0;">More body text so the wrapper actually scrolls and you can see the fill react.</p>
        <p style="margin: 0;">End of body.</p>
    </article>
</div>
:::

## Mobile behavior

The reading family is responsive by design — the chrome adapts to the
viewport so the article body always gets the full reading width on a phone:

- **Vertical spine hides below `md`.** `reading-spine` defaults to
  `hideBelow="md"` (`hidden md:block`), so on screens narrower than `md`
  (48rem / 768px) the right-edge spine is not rendered at all. A vertical
  tick column is a pointer affordance — it has no place on a touch phone
  where there is no hover to expand it.
- **The article must reclaim that width on mobile.** Because the spine is
  gone below `md`, the article must NOT reserve the spine gutter there.
  Wrap the article in `class="wk-reading-content"` — it reserves the
  inline-end gutter **only at `md`+** (where the spine is visible) and uses
  the **full width below `md`**:

  ```blade
  <article class="wk-reading-content">
      <h2>...</h2>
      <p>...</p>
  </article>
  ```

  Override the gutter width per composition with
  `--reading-content-spine-gutter` (default `12rem`) when the expanded spine
  is narrower or wider than the default. NEVER hardcode an inline
  `max-width: calc(100% - 12rem)` or `padding-right: 12rem` — an inline
  style can't `@media`, so the gutter stays applied below `md` and collapses
  the article to a single character per line on a phone.
- **The horizontal `reading-toc` strip stays visible on mobile.** Unlike the
  vertical spine, the horizontal TOC strip is a mobile-appropriate
  affordance (it sits above the article and its links wrap/pan). Use it
  instead of the spine when the page genuinely needs an on-mobile table of
  contents (e.g. a documentation page with a breadcrumb).

## Best practices

1. **Token override beats component-prop override.** Override
   `--reading-progress-fill` at `:root {}` for theme-wide changes
   instead of passing `variant` to every instance.
2. **Single shell per page maximum.** The shell registers global
   scroll handlers; two shells produce two sets of listeners.
3. **Bookmark keys must be unique per article.** Use
   `bookmarkKey="article-{$slug}"` with a per-article slug.
4. **Don't mix shell + primitive ejection on the same page.** Either
   use the shell OR compose primitives directly; not both. Two
   competing IntersectionObservers on the same target produce
   undefined active-section behavior.
5. **Prefer `null` target with internal fallback.** Defaults absorb
   future framework changes (e.g. Laravel ships `<article>` instead
   of `<main>` as the content root).

## Keyboard Interaction

The reading-* family is purely supplementary content / chrome —
keyboard interaction is delegated to the underlying article HTML
(headings remain `<h2>`/`<h3>`, bookmark prompt button is keyboard-
focusable, spine entries are real `<a href>` links). Minimap is
explicitly `aria-hidden="true"` and skipped from focus order — its
purpose is visual aid; primary nav stays keyboard-driven.

## Pitfalls

- **Two reading-progress components with milestones on one page** fire
  duplicate analytics events at each threshold. Mount one, not two.
- **Bookmark key with whitespace** breaks the URL fragment matching
  used internally. Use slugs (`'docs-getting-started'`), not titles
  (`'Docs: Getting Started'`).
- **Minimap mounted without a relative parent** positions itself
  against the viewport instead of the host. Wrap the host in a
  `position: relative` container.
- **Filter slot without `x-model="filter"`** mounts an unconnected
  input — the spine's `filter` Alpine field never updates. Always
  bind via `x-model="filter"` exactly.

## Design Tokens

The reading-* family ships its own family-prefixed CSS variables so the chrome can be themed independently of the surrounding palette. The complete token surface lives in the central [Design Tokens](/theming/design-tokens) reference page (under "Reading Component Tokens"); the family-level summary below covers the most common knobs.

| Token | Used for |
| --- | --- |
| `--reading-progress-height-sm` / `--reading-progress-height-md` / `--reading-progress-height-lg` | Progress-bar thickness per `size` prop |
| `--reading-progress-dot-size` | Milestone dot size on the progress bar |
| `--reading-progress-fill` | Progress-bar fill color (defaults to `--color-wk-accent`) |
| `--reading-spine-color-active` / `--reading-spine-color-idle` | Spine tick colors |
| `--reading-spine-tick-height` / `--reading-spine-gap` | Spine tick height + gap |
| `--reading-spine-padding-x` / `--reading-spine-padding-y` | Inner padding of the spine's `<ol>` — the gutter between the ticks and the panel's edges. `padding-x` defaults to `0.5rem` (gutter from left + right aside edges). `padding-y` defaults to `0.875rem` (top + bottom breathing room — slightly larger because the panel's vertical extent stacks multiple heading rows whose typographic height needs more daylight from the panel rim than the horizontal margins do). |
| `--reading-spine-offset-top` | `boundary="container"` only — the spine pins this far (default `1rem`) below the top of the scroll container instead of scrolling away with the article. |
| `--reading-toc-link-max-width` | Per-link truncation width |
| `--reading-toc-padding-x` / `--reading-toc-padding-y` / `--reading-toc-gap` | TOC link padding + gap |
| `--reading-meta-color` / `--reading-meta-text-size` | Meta line color + size |
| `--reading-minimap-stripe-height` / `--reading-minimap-stripe-gap` | Minimap stripe geometry |
| `--reading-minimap-rendered-scale` / `--reading-minimap-rendered-opacity` / `--reading-minimap-rendered-radius` / `--reading-minimap-rendered-placeholder-bg` | Rendered-mode iframe-clone visual tuning |
| `--reading-minimap-preview-scale` / `--reading-minimap-preview-size` / `--reading-minimap-preview-bg` / `--reading-minimap-preview-border` / `--reading-minimap-preview-shadow` | Extension E1 (hover preview popover) |
| `--reading-minimap-bookmark-marker-color` / `--reading-minimap-bookmark-marker-height` | Extension E2 (bookmark marker line) |
| `--reading-minimap-anchor-text-color` / `--reading-minimap-anchor-text-color-hover` / `--reading-minimap-anchor-font-size` | Extension E3 (heading anchors) |
| `--reading-minimap-idle-opacity` / `--reading-minimap-idle-delay` / `--reading-minimap-idle-transition` | Extension E4 (auto-fade idle) |

One worked override example:

```css
/* Theme the entire family in one file: progress bar in success-green,
   spine ticks in muted neutral, minimap stripes also muted-neutral. */
:root {
    --reading-progress-fill: var(--color-wk-success);
    --reading-spine-color-active: var(--color-wk-success);
    --reading-minimap-viewport-bg: color-mix(in oklab, var(--color-wk-success) 15%, transparent);
}
```

## See Also

- [Long-form Article](/blueprints/recipes/long-form-article) — canonical blog-post recipe
- [Documentation Reader](/blueprints/recipes/documentation-reader) — heavier composition for technical docs
- [Design Tokens](/theming/design-tokens) — full token reference
