Scrollytelling Panel
A pinned visual beside stepped copy, where an IntersectionObserver reading line hands the frame to whichever step you are reading.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/scrollytelling-panel.jsonPrompt
Build a React + TypeScript + Tailwind "ScrollytellingPanel" block with zod. No
animation library — one IntersectionObserver, one rAF-throttled scroll listener,
CSS transitions for the rest.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
steps: { id, eyebrow?, heading, body, caption?,
media: { src, alt, accent?: 1..5 } }[] }.
- Props = z.infer of the schema plus onActiveChange?: (index, step) => void,
onRetry?, mediaSide?: "left" | "right", swap?: "fade" | "rise" | "zoom",
stickyOffset?: number, activeLine?: number (0..1), stepHeight?: string,
parallax?: boolean, mediaClassName?, className. forwardRef to the <section>
and spread the rest of the props onto it.
- The active index is internal state: the consumer ships steps and gets told
which one is current, it does not drive it.
Behavior
- Four first-class branches: loading (frame + three copy slabs at their real
proportions, role=status), empty ("no steps yet" panel — a ready response
carrying zero steps lands here too), error (message + "Try again" only when
onRetry exists), ready (the pinned story).
- Layout: flex column on small screens, two columns from lg. Media first in DOM
so it stacks above the copy; mediaSide="right" moves it with lg:order-2. The
media column is position: sticky at stickyOffset — on mobile a flex item's
containing block is the whole section, and at lg the grid needs items-start
so the item does not stretch to the row and leave sticky nothing to travel.
A ::before strip paints the gap the offset opens above the parked frame so
copy never shows through it. Stacked, the frame is as tall as the section is
wide, which can be taller than the scrollport it parks in — cap it
(max-h-[40vh] lg:max-h-none, object-cover absorbs the crop) so the copy keeps
a place to be read.
- Active step: ONE IntersectionObserver over the step <li>s, with a root of the
nearest scrollable ancestor (walk the ancestors, skipping overflow: clip) so a
story inside a modal or a panel behaves like one on the page. rootMargin
collapses the root to a 1%-tall band at activeLine (default 0.45):
`-45% 0px -54% 0px`. A step is active while it covers the band; when the band
falls in the gutter between two steps nobody claims it and the last one keeps
the frame. Stacked, the parked media is opaque and owns the top of the port,
so the band would sit behind it: measure the parked column (the two columns
overlapping horizontally is what "stacked" means, at any breakpoint) with a
ResizeObserver and push the band below it — otherwise the step that lights up
is the one the visual is covering. Publish through one funnel that compares
against a ref, so onActiveChange fires once per handover; the first resolution
of a story only seeds the state, since a page can load already scrolled into
it (restored scroll, deep link, back navigation). Disconnect on unmount;
rebuild only when the step id sequence, the line or the status change.
- Visual swap: all step media stacked absolutely in the frame, only the active
one at opacity 1. swap picks the axis — fade (opacity only), rise (translate-y
8 -> 0), zoom (scale 1.05 -> 1). Inactive layers are aria-hidden. Each layer
keeps its own onError + a ref probe (a cached image can fail before hydration
attaches onError) and falls back to a labelled placeholder, never a broken glyph.
- Progress: one passive, capture-phase scroll listener plus resize, ResizeObserver
and an IntersectionObserver that pauses the loop off screen; every burst
collapses into one requestAnimationFrame. The loop writes a hairline rail
scaleX and a --sp-zoom push-in straight to style — no re-render — and puts
--sp-progress (0..1) on the section as a hook for the host's own scroll-linked
CSS; nothing inside the block reads it.
- Pointer: fine pointers get a small parallax — the image drifts away from the
cursor by 2% of the frame — from a passive pointermove on the frame,
rAF-throttled, reset on pointerleave/pointercancel. The image is drawn 6%
larger than its frame (3% of bleed per side) so neither the parallax nor the
push-in can ever expose an edge. matchMedia("(pointer: coarse)") switches the
parallax off entirely so nothing competes with touch scrolling.
- Reduced motion: read prefers-reduced-motion with a live matchMedia listener
(useSyncExternalStore, server snapshot false). When it matches, the scroll loop
is never started, transitions are motion-reduce:transition-none so the swap is
instant, and the rail counts steps instead of pixels. Copy is plain, always
visible text in every case — nothing is hidden waiting for an animation.
- Navigation: the dots under the frame are real buttons — aria-current="step",
aria-label "Step n: heading", scrollIntoView({ block: "center" }) with
behavior "auto" under reduced motion. A visually hidden aria-live="polite"
region announces "Step n of N: heading" on every handover.
Rendering & styling
- Semantic tokens only: bg-card frame, bg-background sticky wrapper, border /
border-primary rail, bg-primary for the active dot and progress bar,
text-muted-foreground for inactive headings and body. Decorative colour is
var(--chart-1..5), picked per step by media.accent and cycling by index when
it is missing — it tints the blurred halo behind the frame and the dot beside
the eyebrow, never text.
- Each step <li> carries the border-l rail, so consecutive steps form one
continuous line; the active one flips to border-primary.
- cn() merges className; focus-visible rings on every button; steps keep a
minHeight (stepHeight) — that is the scroll runway one step owns.
Customization levers
- Pace: stepHeight is the runway per step (default clamp(13rem, 44vh, 24rem));
shorten it for a dense story, lengthen it to let one visual breathe.
- Reading line: activeLine moves the handover point (0.3 = high in the port,
feels eager; 0.6 = late, feels deliberate). Widen the 1% band only if steps
can be shorter than the band. Stacked (below lg) it is a floor, not a
position: the line is pushed under the parked media whenever that sits lower,
so on a phone it follows the frame height instead of the raw fraction.
- Swap character: fade / rise / zoom, plus the duration-700 ease-out on the
layers; ZOOM (0.05) is the whole-story push-in and PARALLAX (0.02) the pointer
travel — set both to 0 for a completely still frame, and keep PARALLAX below
half of OVERSCAN - 1 if you raise it.
- Frame shape: mediaClassName owns the aspect box (aspect-[4/3] default) —
aspect-[16/10], a fixed height, or rounded-none for a full-bleed edition. It
also owns the stacked height cap (max-h-[40vh] lg:max-h-none by default): the
parked column plus its clearance has to leave the copy at least half a
stepHeight of the scrollport, so cap it in px instead of vh when the story
lives in a short nested scroller.
- Scroll hook: the section carries --sp-progress (0 where the story's top edge
meets the reading line, 1 at its bottom edge) on every frame. Nothing in the
block reads it — it is there for your own scroll-linked CSS (a page tint, a
counter, a parallax layer of your own). It is removed when reduced motion
stops the loop, so give your rule a fallback value.
- Media kind: the layer renders an <img>; swap it for <video muted playsInline>,
a chart or a Lottie by replacing MediaLayer's body — the swap classes and the
aria-hidden rule stay the same.
- Chrome: the dots row, the caption line, the progress rail and the halo are
independent siblings; delete any of them without touching the observer.Concepts
- Reading line, not a zone — the observer's negative root margins shrink the scrollport to a 1%-tall band. At most one step can cover it, so "which step is active" needs no tie-break, and when the band falls between two steps nobody claims it: the last step keeps the frame instead of flickering.
- The line has to clear the visual — stacked, the parked media is opaque and owns the top of the scrollport, so a line at a raw fraction of the port can land behind it and light up a step whose copy the frame is covering. The parked column is measured and the line is pushed below it; side by side there is nothing to clear and
activeLineis used as given. - Pin needs somewhere to travel — sticky only moves inside its containing block. Stacked, the media is a flex item of the whole section; side by side it is a grid item that must not stretch to the row height, or the pin silently does nothing.
- One handover funnel — index changes route through a single publish() guarded by a ref, so the swap, the
onActiveChangecallback and the polite announcement all fire exactly once per step, whatever fired them (observer, dot click, keyboard). The first resolution of a story only seeds that state: a page can load already scrolled into the story, and where the reader starts is not a handover. - Style writes, not renders — the scroll loop sets the rail transform and the push-in scale straight on the elements, and publishes
--sp-progresson the section for your own CSS. React re-renders only when the step actually changes hands, so a fast scroll costs one frame of layout maths. - Motion is the garnish, never the content — copy is ordinary always-visible text and the dots are ordinary buttons. Reduced motion removes the loop and the transitions; a coarse pointer removes the parallax. Nothing about the story is only reachable by scrolling.
Waitlist
A viral waitlist block: validated email capture, a joined panel that reports your place and derives the rest of the maths, a referral link worth a stated number of places, and a leaderboard teaser that pins your own row.
Sticky Footer Reveal
A footer that sits behind the page — the content is an opaque sheet on a higher z-index and the last screenful of scroll slides it off the pinned footer, with a plain in-flow fallback when nothing scrolls.