Blocks

Scrollytelling Panel

A pinned visual beside stepped copy, where an IntersectionObserver reading line hands the frame to whichever step you are reading.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"
import type { ScrollytellingPanelData, ScrollytellingPanelItem } from "./scrollytelling-panel.contract"

/**
 * The reading line is a hairline band, not a zone: at most one step can cover 1% of the
 * scrollport, so "which step is active" never needs a tie-break between two candidates.
 */
const BAND = 1
/**
 * Pointer parallax travel as a fraction of the frame's own size (fine pointers only).
 * Kept below the per-side bleed OVERSCAN buys, so the image can never slide off its frame

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/scrollytelling-panel.json

Prompt

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 activeLine is 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 onActiveChange callback 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-progress on 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.

On This Page