---
title: Long-form Article
description: Canonical reading-shell composition for blog posts and feature articles — progress bar, sidebar spine, return-bookmark, time-to-read.
visibility: guest
draft: false
---

# Long-form Article

The canonical pattern for blog-post / feature-article pages: a single `<x-wirekit::reading-shell>` wrapper provides the reading-progress bar, the sidebar mini-TOC, and the return-bookmark prompt. Drop a `<x-wirekit::reading-meta>` next to the article header for the time-to-read estimate.

Sensible defaults for a typical 1500–4000 word article: 3px primary progress bar pinned to viewport top, right-side spine with hover-expand at md+, bookmark prompt with 30-second dwell threshold. Honors `prefers-reduced-motion: reduce` end-to-end.

## Full Composition

:::preview{title="Canonical long-form-article shell", frame="iframe", height="640px"}
<x-wirekit::reading-shell :previewMode="true" bookmarkKey="long-form-article-demo">
    <x-wirekit::prose>
        <header style="margin-bottom: 2rem;">
            <h1>The Long Title — Why composition beats configuration</h1>
            <x-wirekit::text intent="muted" as="p">By Author Name · Published 2026-04-30</x-wirekit::text>
            <x-wirekit::reading-meta :showRemaining="true" />
        </header>
        <article>
            <h2 id="lfa-intro">Introduction</h2>
            <p>Open with a strong hook. The reading-progress bar at the top of this iframe gives the reader an immediate sense of "how much is left" — the same pattern Medium and Substack use on every modern blog. Scroll this preview to watch the bar fill, the right-side spine track active sections, and the bookmark prompt arm itself for a return visit.</p>
            <p>The four primitives — progress bar, sidebar spine, bookmark prompt, time-to-read meta — compose into the canonical long-form layout. Each owns its own state; none of them communicate with the others. That keeps the composition robust to per-component overrides without tangling concerns.</p>
            <p>What you're reading right now is rendered inside an isolated iframe so the spine on the right scopes its TOC to this article only — not to the surrounding docs page. Try hovering or focusing the right edge to expand the spine and see the section labels. Click any tick to smooth-scroll to that heading.</p>
            <h2 id="lfa-context">Context</h2>
            <p>Build the case. The right-side reading-spine ticks each section with a tiny dash. h3 ticks render narrower than h2 ticks — visual depth at minified scale. Hover the spine to expand it into a fully-labeled vertical TOC; the active section is highlighted as you scroll.</p>
            <p>The spine uses an IntersectionObserver to detect which heading is currently in the viewport. When the active heading changes, the spine dispatches a <code>wirekit:reading-spine:section-changed</code> event you can listen to from your own Alpine or Livewire components — useful for syncing a deep-link or analytics ping without hand-coding the scroll math.</p>
            <p>The progress bar is a separate primitive but shares no state with the spine. It computes its own scroll percentage on every <code>scroll</code> event (rAF-throttled) and fills the bar from 0% to 100% as the reader works through the article. By default it sits 3px tall, pinned to the very top of the viewport.</p>
            <h3 id="lfa-context-history">Some Background</h3>
            <p>The reading-shell sugar wrapper composes all four primitives in a single tag. Density presets (<code>comfortable</code>, <code>compact</code>, <code>minimal</code>) flip per-primitive defaults; explicit toggles always win over the density baseline. The shell is the 80%-case sugar — power users who need finer control compose the primitives directly without the shell.</p>
            <p>You can mix shell-level density with per-primitive overrides. For example, <code>density="minimal" :spine="true"</code> shows the spine even though minimal-density would normally hide it. The override always wins; density is just a starting-point shape, not a lockout.</p>
            <h2 id="lfa-argument">The Argument</h2>
            <p>Make the point. The active section indicator slides smoothly between ticks as the reader scrolls. <code>prefers-reduced-motion: reduce</code> is honored end-to-end — every transition collapses to 1ms via a global media query, so motion-sensitive readers never see a slide animation.</p>
            <p>The smooth-scroll math accounts for the viewport-top offset (default 96px) so the destination heading lands just below the progress bar, not flush against it. Pass <code>offset="6rem"</code> on individual primitives or via the shell to change the value without overriding the visual positioning.</p>
            <p>Click any spine link to smooth-scroll to that heading. The URL hash updates via <code>history.replaceState()</code> rather than <code>pushState()</code> — back-button still goes to the previous page, not to the previous heading. Saves the user from a 12-press back-button repro to escape the article.</p>
            <h2 id="lfa-evidence">Evidence</h2>
            <p>Back up the argument. The bookmark saves scroll position to <code>localStorage</code> every second. On a return visit, after the reader has dwelled at least 30 seconds, a "Resume reading?" pill appears bottom-right with the saved offset. Click it to smooth-scroll back to where they left off.</p>
            <p>The bookmark key is developer-supplied — pass <code>bookmarkKey="post-@{{ $post->slug }}"</code> so each article has its own saved position. The dwell threshold (default 30s) and the storage interval (default 1s) are both tunable. Set <code>:minDwellSeconds="120"</code> for a stricter "only show on serious readers" trigger.</p>
            <p>If the reader has never visited before, no pill renders — bookmark only appears for return visits with non-zero saved offset. No noisy "Welcome back" prompts on first read.</p>
            <h2 id="lfa-conclusion">Conclusion</h2>
            <p>Wrap it up. Click any spine link to smooth-scroll to that heading; the URL hash updates without a full anchor jump. The four primitives compose into the canonical long-form layout — drop the shell wrapper, pass a bookmark key, and the rest is opinionated defaults. When you need finer control, decompose into the primitives directly: the shell covers the common case, and dropping to primitives covers everything else.</p>
            <p>The right time to reach for primitive composition is when you need a per-primitive prop the shell doesn't expose — a custom progress color, numbered spine ticks, a different bookmark dwell threshold. Otherwise, ship the shell.</p>
        </article>
    </x-wirekit::prose>
</x-wirekit::reading-shell>
:::

## Decomposed Composition

For the same UX without the shell wrapper — useful when you need to interleave other components or wrap the article body in a custom container.

:::preview{title="Manual composition (no shell)", frame="iframe", height="640px"}
<x-wirekit::reading-progress />
<x-wirekit::reading-spine />
<x-wirekit::reading-bookmark :previewMode="true" key="lfa-manual-demo" />
<x-wirekit::prose>
    <header style="margin-bottom: 2rem;">
        <h1>Article Title — Manual primitive composition</h1>
        <x-wirekit::reading-meta :showRemaining="true" />
    </header>
    <article>
        <h2 id="lfa-m-1">Section A</h2>
        <p>Same UX as the shell wrapper, with full per-component control. Each of the four primitives — progress, spine, bookmark, meta — sits as a sibling of the article body. The shell is sugar over this exact composition; once you need a prop the shell doesn't pass through, drop the shell and mount the primitives directly.</p>
        <p>This decomposed form is also the right shape when the article body lives inside a custom layout that the shell's div wrapper would interfere with — a flex/grid container, a Livewire component boundary, or a fully-custom typographic shell.</p>
        <h2 id="lfa-m-2">Section B</h2>
        <p>Useful when the article body sits inside a custom layout. The reading-spine still pins to the right viewport edge via <code>position: fixed</code>; the reading-progress still pins to the top. They don't care where they live in the DOM tree — they care about the page-level scroll context.</p>
        <p>That said, both components scope their heading-collection to the nearest matching ancestor (<code>main</code>, <code>article</code>) of their own DOM position. So if you place them inside one specific article, they pick up that article's headings rather than something elsewhere on the page.</p>
        <h2 id="lfa-m-3">Section C</h2>
        <p>The bookmark primitive carries its own <code>x-data</code> Alpine state and saves the scroll offset to <code>localStorage</code> under the developer-supplied <code>key</code>. <code>:previewMode="true"</code> is set here so the bookmark won't actually persist between iframe reloads — useful for documentation but never for production.</p>
        <p>Time-to-read estimation runs once on mount: it counts visible words inside the nearest <code>&lt;article&gt;</code>, divides by the configured words-per-minute (default 225), and renders the result. <code>:showRemaining="true"</code> updates the displayed value as the reader scrolls — the meta becomes a live "x minutes remaining" rather than a static "y minutes" estimate.</p>
        <h2 id="lfa-m-4">Section D</h2>
        <p>The decomposed form is approximately 5% of developers; the shell is the right answer for the other 95%. Reach for the manual form when you need a prop, slot, or layout that the shell doesn't accommodate. Otherwise the shell's defaults are the canonical pattern.</p>
        <p>One subtle benefit of manual composition: you can interleave OTHER WireKit components between the primitives — a banner above the progress bar, a sidebar nav alongside the spine, a comments section that the bookmark prompt politely sits above. The shell renders everything as direct children of one wrapper, which constrains layout flexibility.</p>
    </article>
</x-wirekit::prose>
:::

## Density variants

Pass `density="compact"` for a slimmer profile (sm-height bar, always-md spine expand) or `density="minimal"` for the bare-minimum chrome (sm bar only, no spine, no meta). The preview shows the compact density — the spine sits permanently expanded and the progress bar is the slim variant.

:::preview{title="Compact density — slimmer bar, always-expanded spine", frame="iframe", height="420px"}
<x-wirekit::reading-shell :previewMode="true" bookmarkKey="lfa-compact-demo" density="compact">
    <x-wirekit::prose density="compact">
        <article>
            <h2 id="lfa-d-1">Compact density</h2>
            <p>The spine on the right is always expanded under <code>density="compact"</code> — the reader doesn't need to hover to see section labels. The progress bar at the top renders at the smaller height so it consumes less vertical space.</p>
            <h2 id="lfa-d-2">When to use</h2>
            <p>Compact is the right shape for a developer-tools doc-site reader who knows where they are and wants the TOC permanently visible. Comfortable is the default for marketing-style blog posts where readers prefer the chrome to recede.</p>
            <h2 id="lfa-d-3">Per-primitive overrides still apply</h2>
            <p>Override individual props on top of the density baseline — e.g. <code>density="compact" :spine="false"</code> hides the spine even though compact would normally keep it expanded.</p>
        </article>
    </x-wirekit::prose>
</x-wirekit::reading-shell>
:::

:::source{language="blade"}
{{-- Comfortable (default) — full chrome --}}
<x-wirekit::reading-shell bookmarkKey="post-{{ $post->slug }}">
    <article>...</article>
</x-wirekit::reading-shell>

{{-- Compact — denser display, spine always-visible at md+ --}}
<x-wirekit::reading-shell bookmarkKey="post-{{ $post->slug }}" density="compact">
    <article>...</article>
</x-wirekit::reading-shell>

{{-- Minimal — progress bar only --}}
<x-wirekit::reading-shell bookmarkKey="post-{{ $post->slug }}" density="minimal">
    <article>...</article>
</x-wirekit::reading-shell>
:::

## Power-user composition (skip the shell)

When per-primitive customization is needed beyond what toggles + density cover, mount the primitives directly. The shell covers the common defaults case; anything beyond it is served by composing the primitives.

:::preview{title="Custom: success-colored progress + numbered spine + reading-meta", frame="iframe", height="640px"}
<div style="overflow-x: hidden;">
    <x-wirekit::reading-progress intent="success" :milestones="true" />
    <x-wirekit::reading-spine :levels="'2,3'" :numbered="true" />
    <x-wirekit::reading-bookmark :previewMode="true" key="lfa-power-demo" :minDwellSeconds="120" :threshold="0.05" />
    <x-wirekit::prose density="compact">
        <article class="wk-reading-content">
            <header style="margin-bottom: 1rem;">
                <h1>Power-user composition</h1>
                <x-wirekit::reading-meta :wpm="200" :showRemaining="true" />
            </header>
            <h2 id="lfa-p-1">Customized primitives</h2>
            <p>The progress bar above is the success-colored variant with milestone ticks. The spine on the right is numbered (1, 2, 3...). The bookmark uses a tighter 120-second dwell so it surfaces sooner on a return visit.</p>
            <h2 id="lfa-p-2">When you need this</h2>
            <p>The shell exposes the 80%-case knobs (density, per-primitive toggles). Reach for primitive composition when you need a prop the shell doesn't pass through — a custom progress color, numbered spine ticks, an aggressive bookmark dwell threshold.</p>
            <h2 id="lfa-p-3">Trade-off</h2>
            <p>Manual composition is more verbose but gives you every knob each primitive exposes. The shell is the right starting point — only switch to primitives once you hit a real customization that the shell doesn't accommodate.</p>
        </article>
    </x-wirekit::prose>
</div>
:::

:::source{language="blade"}
{{-- Custom: success-colored progress + numbered spine + tight bookmark + meta --}}
<x-wirekit::reading-progress intent="success" :milestones="true" />
<x-wirekit::reading-spine :levels="'2,3'" :numbered="true" :backToTop="true" />
<x-wirekit::reading-bookmark
    key="post-{{ $post->slug }}"
    :minDwellSeconds="120"
    :threshold="0.05"
/>
<x-wirekit::reading-meta :wpm="200" :showRemaining="true" />

<article>...</article>
:::

## Why this composition

- **Progress bar** at the top: glanceable "how much is left" cue — Medium / Substack convention.
- **Sidebar spine** on the right: keeps the TOC accessible without consuming horizontal real estate (collapsed = 1.25rem; expanded = 16rem only on hover/focus).
- **Bookmark prompt** bottom-right: only surfaces on return-visit, only after meaningful dwell time — never noisy.
- **Time-to-read meta** above the body: sets expectations BEFORE the reader commits.

The four primitives don't communicate; each owns its own state. That makes the composition robust to changes in any one of them — you can opt out per-component or override individual props without breaking the others.

## Related

- [Reading](/components/reading) — the consolidated family page with every primitive + composition + family contracts
