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.
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:
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, the rail's sliding band,
bookmark pill enter / leave, minimap stripe color transitions) collapses
to 1ms under
prefers-reduced-motion: reduce via the global @media block in
dist/. Values still update on scroll — only the
interpolating transition is removed; the value itself jumps to current.
A scroll the primitives start themselves (a table-of-contents link, a
minimap block, the bookmark's resume, back-to-top) jumps instead of
gliding under reduced motion, from the system or from
data-reduce-motion="reduce" on <html>. It jumps also on a page that
sets scroll-behavior: smooth on its root, as Tailwind's scroll-smooth
does.
Print stylesheet
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.
Forced colors
Under forced colors, such as Windows High Contrast, the spine's ticks take
the reader's own palette: the text color for every section and the
selection color for the current one, the per-section fill of
fillSections included. The rail's line takes the disabled-text color
and its band the selection color. Without that the collapsed spine, which is
nothing but its ticks, would be an empty rail. The TOC marks its current
link by weight as well as color, and the weight survives the mode.
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-progresstarget=null→ tracks viewport scrollreading-spine,reading-bookmark,reading-meta,reading-minimaptarget=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").
Headings that change after the page loads
The spine and the TOC follow the headings inside their target as the
page changes. A section a Livewire update adds, removes or renames
reaches the outline in the next frame, numbered and tracked like the
sections the page loaded with, and the section the reader is in keeps
its mark. A spine inside its own target is fine: a change inside the
outline itself is no reason to collect the headings again. The minimap's
rendered mode rebuilds its copy 250 ms after the last change, as
Reading Minimap describes. The
reading time counts the article again in the next frame,
and with perParagraph it puts back the annotations an update took out
of the article.
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.
Your own x-show or wire:show
An x-show or wire:show you write on a primitive hides it in addition
to its own condition. The TOC and the spine already hide themselves
while the page has no headings, so for them your directive goes on a
<div class="contents"> around the primitive, which generates no box,
and both conditions apply. A selector like .sidebar > .wk-reading-toc
no longer matches once you add one; the marker class stays on the
primitive itself. A spine scoped to a container takes your directive on
the sticky wrapper it already has.
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.
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 |
--color-wk-info-tone, the accent until you give info a color of its own (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: 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. |
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.
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.
The spine is an <aside> named Page contents in the reader's language, and the <nav> inside
it carries the same name, so a screen reader lists it by name among the navigation on the page.
An aria-label you pass names both, and so does an aria-labelledby.
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
aria-label="Filter sections"
x-model="filter"
placeholder="Filter sections..."
size="sm"
/>
</x-slot:filter>
</x-wirekit::reading-spine>
Rail layout
layout="rail" turns the spine into an outline that stands in your page's flow, in a grid column
beside the article. Every entry stays visible with its full title, a line runs through the entries
and bends as the indent changes, and a band on that line slides to the current section.
The rail is named by a visible label, Page contents in the reader's language, which names its
<nav> landmark. An aria-label you pass becomes that label's text, and an aria-labelledby you
pass names the <nav> instead, with no label of its own.
- Placement. Give it a column of its own:
<x-wirekit::with the article and the rail in that order is the layout above. On a phone withgrid cols="1" template="md:[minmax(0,1fr)_14.375rem]"> hide-below="none", put the rail first in the markup so it reads before the article, and place it into the second column frommdwithmd:col-start-2 md:row-start-1(and the article withmd:col-start-1 md:row-start-1). - On a phone. With
hide-below="none"the rail is a disclosure belowmd: a button with the label opens the outline under it, closed at first, and tells a screen reader whether it is open. Frommdthe button is hidden and the outline always stands. - Sticky. From
mdthe rail sticks--reading-spine-offset-topbelow the top of the page. Under a sticky header, set that token, andoffset, to the header's height. - A long outline. When the outline is taller than the window, it scrolls on its own below the label and the filter, which stay in view. As the page moves on, the outline scrolls just far enough to show the current entry, and the page stays where the reader put it. Under reduced motion the outline jumps instead of gliding.
- Reading progress. With
fill-sectionsthe band fills the line from the first entry down to the current one, so the line shows how far the reader has come, and slides on from there. - What does not apply.
expand(the rail is always expanded),position,edge-offsetandboundary(they place the default spine). Do not wrap the article inwk-reading-contentbeside a rail: that class reserves a gutter for the default spine. - The current entry carries
aria-current="location", its title takes the active color, and the band is drawn twice as wide as the line, so it is marked by more than color.
Reading Spine props
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. The indent and the numbered labels start at the shallowest level the page actually uses: on a page with only h3 headings under '2,3', those are the top entries |
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 expands the spine on hover and on keyboard focus, and focus does the same; always keeps it expanded, always-md from the md breakpoint up. Any other value is reported in development and reads as hover |
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. In the rail, the band fills the line from the first entry down to the current one |
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. |
layout |
string | 'ticks' |
ticks pins the spine to an edge and expands it on hover. rail places an outline in your grid column, with a line through its entries and a band on the current one; expand, position, edge-offset and boundary do not apply to it. See Rail layout. Any other value is reported in development and reads as ticks |
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:: with hide 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" />
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.
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: reducecollapses 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:
- Long-form article density-overview — paragraphs / headings as
stripes; sits at the article's right edge. Complements (or
replaces)
reading-spinewhen paragraph-level density-overview is wanted over heading-level TOC. - 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 with a mouse or a pen → pan host scroll position proportionally. On a touch screen a swipe scrolls the page natively instead, and a tap on a stripe jumps to its section
- Hover stripe (non-touch) → tooltip with the item's label (default
item., override viatextContent 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. Theheading-anchorsextension 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
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 with a mouse or a pen. A touch always scrolls the page natively |
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
Intersection— the iframe is only constructed when the minimap actually enters the viewport.Observer - Source DOM is sanitized (every
<script>, everyon*handler, everyjavascript:/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
Mutationon the source watches for content changes (Livewire morph, hot-reload edits) and rebuilds the iframe with a 250 ms debounce.Observer - 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.
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 |
| The same, with a column beside the article for an outline that is always open | reading-spine with layout="rail" |
| 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.
Leave room for the pill at the end of the page. It is fixed to the
bottom of the viewport, so while it shows, the last lines of an article
that ends at the bottom of the page sit under it. A bottom padding as
tall as the pill, about 6rem, lets them scroll clear of it.
Elsewhere on the page the pill keeps the focus clear by itself: it marks the band it covers, so a control a reader tabs to comes to rest above it (keeping the focus clear of a pinned bar). The room at the end is for the last lines, which nothing can scroll any further.
<x-wirekit::reading-bookmark key="article-{{ $post->slug }}" />
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 |
Renders nothing, and reads and writes no localStorage: for a preview, where every bookmark on the page would show its resume prompt |
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. |
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 cjk baseline (default
500) — Chinese / Japanese / Korean text has no spaces between
logographic characters, so whitespace tokenization underestimates word
count by an order of magnitude.
perParagraph mode (Medium-style inline annotations)
When :per, the component injects small
<span class="wk-reading-meta-paragraph">3 min</span> annotations
immediately before each <p> in the target with at least
paragraph 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>
The spine as a rail
layout="rail" gives the shell's spine the rail layout. The shell then builds the
column for it: from md your article and the rail stand side by side, the rail in a column of
14.375rem, and below md the article takes the whole width while the rail stays hidden.
<x-wirekit::reading-shell layout="rail" :bookmark-key="'article-' . $slug">
<article>
{{ $content }}
</article>
</x-wirekit::reading-shell>
The rail is always expanded and stays in its column, so boundary and the density's spine
expansion do not reach it. A density or a toggle that hides the spine hides the rail, and the
article keeps the whole width.
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. |
layout |
string | 'ticks' |
The spine's layout. ticks pins it to the page's edge; rail puts it in a column beside your article from md. See The spine as a rail. Any other value is reported in development and reads as ticks |
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 aria-label="Filter sections" 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. |
'container' |
Scope to the nearest positioned ancestor (sticky / Tailwind absolute). |
'<css-selector>' (any string) |
Same scoped shape as 'container'. reading-progress also asserts that an ancestor matching the selector exists and emits a console.warn when none does, a runtime sanity check for a surface deep inside a layout. The spine and the bookmark take the selector as the scoped shape alone. |
Two requirements when using any non-null boundary:
- The parent must establish a positioning context (
position: relativeis the cheapest choice). - For
reading-progress, the parent must also be a scrolling container (overflow: autoorscroll) with a defined height —position: stickyfalls 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:: — when the shell carries boundary="container", every composed primitive (reading-progress, reading-spine, reading-bookmark) inherits it.
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-spinedefaults tohideBelow="md"(hidden md:block), so on screens narrower thanmd(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 inclass="wk-reading-content"— it reserves the inline-end gutter only atmd+ (where the spine is visible) and uses the full width belowmd:<article class="wk-reading-content"> <h2>...</h2> <p>...</p> </article>Override the gutter width per composition with
--reading-content-spine-gutter(default12rem) when the expanded spine is narrower or wider than the default. NEVER hardcode an inlinemax-width: calc(100% - 12rem)orpadding-right: 12rem— an inline style can't@media, so the gutter stays applied belowmdand collapses the article to a single character per line on a phone. -
The horizontal
reading-tocstrip 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
- Token override beats component-prop override. Override
--reading-progress-fillat:root {}for theme-wide changes instead of passingintentto every instance. - Single shell per page maximum. The shell registers global scroll handlers; two shells produce two sets of listeners.
- Bookmark keys must be unique per article. Use
bookmarkwith a per-article slug.Key="article-{$slug}" - Don't mix shell + primitive ejection on the same page. Either use the shell OR compose primitives directly; not both. Two components measuring the active section of the same target produce undefined active-section behavior.
- Prefer
nulltarget 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: relativecontainer. - Filter slot without
x-model="filter"mounts an unconnected input — the spine'sfilterAlpine field never updates. Always bind viax-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 |
With boundary="container", the spine pins this far (default 1rem) below the top of the scroll container instead of scrolling away with the article. The rail sticks this far below the top of the page from md. |
--reading-spine-rail-color / --reading-spine-rail-color-active |
The rail's line, a mix of the two border colors, and its band on the current entry with the current title, the accent text color |
--reading-spine-rail-width / --reading-spine-rail-width-active |
The line's width and the band's, at least 1px and 2px whatever the border width |
--reading-spine-rail-indent / --reading-spine-rail-gap |
How far each level steps in, and how far a title stands from the line |
--reading-spine-rail-duration |
How long the band slides to the next entry, four times --transition-wk-duration |
--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
- Long-form Article — canonical blog-post recipe
- Documentation Reader — heavier composition for technical docs
- Design Tokens — full token reference