Skip to main content
WireKit
Copy for LLM

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.

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:

reading-shell with defaults

This preview is designed for larger viewports. Open this page on a desktop to try the live, interactive demo.

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 1ms 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.

All primitives carry a @media print { display: none !important } rule scoped via marker classes (.wk-reading-progress, .wk-reading-spine, .wk-reading-toc, .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.

Bar (default)
Dot indicator

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.

The chapter dividers the segments prop paints have their own hook, --reading-progress-segment, which defaults to var(--color-wk-border-strong) — so they follow the theme and stay readable in dark mode. Retint them independently of the bar:

/* 1. The bar itself. */
:root { --reading-progress-fill: var(--color-wk-accent); }

/* 2. The chapter dividers drawn at each `segments` position. */
:root { --reading-progress-segment: var(--color-wk-text-muted); }

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: fixedposition: 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.
scope string|null null Personalization scope

Reading Spine

Looking for canonical layout-wiring? The on-page-toc recipe 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, follows the reader's scroll position, and expands on hover or focus.

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 spine finds that container on its own: a click scrolls it, the current section is measured from its top, and back-to-top returns it to the start. Nothing to configure.

Default spine

This preview is designed for larger viewports. Open this page on a desktop to try the live, interactive demo.

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

<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

Headings a demo brought with it A page that embeds live component demos in its prose gets the demos' own headings listed as sections — an accordion's panel titles, a carousel's product names. Pass exclude with the selector of whatever wraps them:

{{-- 1. A container selector alone will miss them: the demos sit INSIDE the article
       that holds the real sections, so anything holding one holds the other. --}}
<x-wirekit::reading-spine target="article.prose" exclude=".demo-surface" />

Headings inside an aria-hidden="true" subtree are skipped without any configuration, and so is a permalink marker inside a heading — the spine reads a heading's accessible text, not its raw textContent.

Prop Type Default Description
target string|null null Scroll container to scan; null falls back to main, article
exclude string|null null Selector for subtrees whose headings are not the page's structure — an embedded demo, a related-links sidebar. target cannot do this when the subtree sits inside the article that holds the real sections
levels string '2,3' Comma-separated heading levels to include
position string 'right' left or right viewport edge
edge-offset string|null null How far in from that edge, as a CSS length. null keeps the 1rem the spine has always used; "0" sits it flush against the window. Set --reading-spine-edge-offset in your stylesheet to move every spine at once.
expand string 'hover' hover / focus / always / always-md
offset string '6rem' Active-detection threshold — how far below the top of the page, or of the region your shell scrolls in, a heading must come before it counts as the current section. Not a distance from the edge; that is edge-offset above.
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 fixedabsolute so the spine stays inside the nearest positioned ancestor instead of the viewport edge. See Scoping a primitive to its parent.
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), 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.

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

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). A jump stands its heading 24px below the strip, and the active section is decided at that same line: the last heading at or above it is marked. So the link you pressed is the one that stays marked, whether the page scrolls or a region inside it does.

A jump keeps its section marked until you move the page yourself, however long the smooth scroll takes. At the end of the page, where a short last section can never reach the line, the last heading in view is marked. A strip pinned to the bottom (position="bottom") covers nothing at the top, so a jump stands the heading 24px below the top.

The reading family in one page

This preview is designed for larger viewports. Open this page on a desktop to try the live, interactive demo.

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)
  • Every visual part — the stripe column, the viewport rectangle, the rendered canvas, the bookmark marker, the tooltip and the hover preview — carries aria-hidden="true": it is an alternative view of navigation the underlying primary nav already exposes. The heading-anchors extension is the exception, because it renders real links: its <nav> keeps an accessible name and stays in the accessibility tree, so what a mouse user can click a screen-reader user can also reach
Rendered mode — literal scaled-down preview

This preview is designed for larger viewports. Open this page on a desktop to try the live, interactive demo.

Minimap stripes + viewport overlay tracking scroll

This preview is designed for larger viewports. Open this page on a desktop to try the live, interactive demo.

Block-style stripes — :itemStyle=block (content-texture, skeleton-style rectangles)

This preview is designed for larger viewports. Open this page on a desktop to try the live, interactive demo.

Custom-themed minimap — accent color, rounded blocks, bordered column

This preview is designed for larger viewports. Open this page on a desktop to try the live, interactive demo.

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 Magnified popover near the cursor on minimap hover
showBookmarks bool true Render a marker line for a sibling <x-wirekit::reading-bookmark>
headingAnchors bool false Clickable mini-anchors, one per h2/h3 — on the outboard edge, or on the inboard edge when the outboard side has no room
headingLevels string '2,3' CSV of heading levels to surface as anchors
autoFadeIdle bool true 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".

<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 — or on the inboard edge when the outboard side has no room, as on a minimap pinned against the viewport, and a label too wide for either side ends in an ellipsis; 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.

Heading anchors in practice

The anchors are real links, one per h2 and h3. In the preview below the minimap is pinned against the right edge of the frame, so there is no room outside it: the strip moves inside, and each label carries its own backdrop so it stays readable over the text beneath. A label that would land too close to the one above it collapses, and reappears while the pointer is over the minimap.

Heading anchors beside the minimap

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.

<x-wirekit::reading-bookmark key="article-{{ $post->slug }}" />
Resume-reading pill triggered by a seeded prior visit

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 fixedabsolute so the resume-prompt pill stays inside the nearest positioned ancestor. See Scoping a primitive to its parent.
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.

Time-to-read estimate above the article

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.

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

<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:

{{-- 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.
scope string|null null Personalization scope

Power-user composition (skip the shell)

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

<x-wirekit::reading-progress intent="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.
{{-- 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.

Reading-progress contained inside a card via boundary='container'

This preview is designed for larger viewports. Open this page on a desktop to try the live, interactive demo.

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:

    <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 intent 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). The minimap's visual layers are aria-hidden="true" and hold no tab stop — their purpose is visual aid, and primary nav stays keyboard-driven. The one exception is heading-anchors: those are real links, so they are tabbable in source order and announced from a <nav> named "Page sections". An anchor that collapses under the density guard is painted at zero opacity until hover, so it carries tabindex="-1" and is skipped rather than becoming an invisible tab stop.

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 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 height 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 Hover preview popover
--reading-minimap-bookmark-marker-color / --reading-minimap-bookmark-marker-height Bookmark marker line
--reading-minimap-anchor-text-color / --reading-minimap-anchor-text-color-hover / --reading-minimap-anchor-font-size Heading anchors
--reading-minimap-anchor-inboard-bg / --reading-minimap-anchor-inboard-padding / --reading-minimap-anchor-inboard-radius Backdrop of a heading anchor that moved inside because the outboard edge had no room
--reading-minimap-idle-opacity / --reading-minimap-idle-delay / --reading-minimap-idle-transition Auto-fade idle

One worked override example:

/* 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

Updated in WireKit v2.54.0 (2026-09-19)

Was this page helpful?

Thank you for your feedback!

Voting requires cookies or local storage. What we store