mewo

Scroll Captions

Captions that take turns in one place as the reader scrolls, on a pinned stage: the old one fades out quickly, the new one fades in while slowly growing. Pins to its scroll container or the page. No dependencies.

No dependencies

Seen inBetween Rivers

Customize

Captions5
Per caption0.85 screens

Install

Terminal
npx shadcn@latest add @mewo/scroll-captions
Not using the registry directory?

Point the CLI straight at the item URL:

npx shadcn@latest add https://www.mewo-ui.com/r/scroll-captions.json

Props

PropTypeDefaultDescription
captionsReactNode[]four placeholder linesThe captions, shown one at a time in the same place as the reader scrolls.
stepnumber0.85Viewport heights of scroll each caption holds. The section is that much taller per caption, plus one viewport.
captionClassNamestring—Merged onto every caption: its place (centred in the stage), width and type.
onCaptionChange(index: number) => void—Called with the index of the caption that comes up, e.g. to move a timeline beside it.
classNamestring—Merged onto the section.

Source

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

export interface ScrollCaptionsProps extends Omit<React.ComponentProps<"section">, "children"> {
  /** The captions, shown one at a time in the same place as the reader scrolls. */
  captions?: React.ReactNode[]
  /** Viewport heights of scroll each caption holds. */
  step?: number
  /** Merged onto every caption: its place, width and type. */
  captionClassName?: string
  /** Called with the index of the caption that comes up (e.g. to move a timeline beside it). */
  onCaptionChange?: (index: number) => void
}

/** The nearest ancestor that scrolls vertically, or null when the page itself does. */
function scrollParent(node: HTMLElement): HTMLElement | null {
  for (let el = node.parentElement; el && el !== document.body && el !== document.documentElement; el = el.parentElement) {
    const { overflowY } = getComputedStyle(el)
    if (overflowY === "auto" || overflowY === "scroll") return el
  }
  return null
}

/** The caption that shows `progress` viewport heights after the stage pins: none until the section is half in view,
 * then one per `step`, the last one held to the end. */
export function captionAt(progress: number, count: number, step: number) {
  if (count === 0 || progress < -0.5) return -1
  return Math.min(count - 1, Math.max(0, Math.floor(progress / Math.max(0.01, step))))
}

/**
 * Captions that take turns in one place as the reader scrolls, on a pinned stage. The old one fades out quickly,
 * then the new one fades in while growing from 90%, fast at first and ever slower over five seconds, so two never
 * overlap. Pins to its nearest scroll container, or to the page. Changes are announced politely to screen readers.
 */
export function ScrollCaptions({
  captions = [
    <>Around 3000 BCE, a sculptor carved a tall <em>vessel</em> from stone.</>,
    "Centuries later, it was buried with the temple that kept it.",
    "In the 1930s, an expedition found it, broken, with an ancient repair.",
    "Today it stands in a museum, its long journey written beside it.",
  ],
  step = 0.85,
  captionClassName,
  onCaptionChange,
  className,
  style,
  ...props
}: ScrollCaptionsProps) {
  const stageRef = React.useRef<HTMLDivElement>(null)
  const onCaptionChangeRef = React.useRef(onCaptionChange)
  const [active, setActive] = React.useState(-1)
  const count = captions.length

  React.useEffect(() => {
    onCaptionChangeRef.current = onCaptionChange
  })

  React.useEffect(() => {
    const stage = stageRef.current
    // The root's ref stays the consumer's, so the section is reached through the stage.
    const section = stage?.parentElement
    if (!stage || !section) return
    const scroller = scrollParent(section)
    let shown = -1
    let raf = 0

    const update = () => {
      raf = 0
      // Inside a scroll container the stage pins to that container, not the window.
      if (scroller) section.style.setProperty("--mewo-viewport", `${scroller.clientHeight}px`)
      const viewport = scroller ? scroller.clientHeight : window.innerHeight
      const top = section.getBoundingClientRect().top - (scroller ? scroller.getBoundingClientRect().top : 0)
      const next = captionAt(-top / Math.max(1, viewport), count, step)
      if (next === shown) return
      shown = next
      setActive(next)
      if (next >= 0) onCaptionChangeRef.current?.(next)
    }
    const schedule = () => {
      if (!raf) raf = requestAnimationFrame(update)
    }

    schedule()
    const target = scroller ?? window
    target.addEventListener("scroll", schedule, { passive: true })
    window.addEventListener("resize", schedule)
    return () => {
      cancelAnimationFrame(raf)
      target.removeEventListener("scroll", schedule)
      window.removeEventListener("resize", schedule)
    }
  }, [count, step])

  return (
    <section
      data-slot="scroll-captions"
      className={cn("relative h-[calc(var(--mewo-viewport,100vh)*var(--mewo-length))]", className)}
      style={{ "--mewo-length": 1 + count * Math.max(0, step), ...style } as React.CSSProperties}
      {...props}
    >
      <div ref={stageRef} aria-live="polite" className="sticky top-0 h-[var(--mewo-viewport,100vh)] w-full overflow-hidden [container-type:size]">
        {captions.map((caption, i) => (
          <div
            key={i}
            data-slot="scroll-captions-caption"
            data-on={i === active ? "" : undefined}
            className={cn(
              "absolute top-1/2 left-1/2 w-[min(68cqh,72cqw)] -translate-x-1/2 -translate-y-1/2 text-center font-heading text-[min(3.35cqh,5.4cqw)] leading-[1.12] tracking-[-0.01em]",
              // leaving: a quick fade, then back to 90% out of sight
              "invisible scale-90 opacity-0 [transition:opacity_0.22s_ease-in,scale_0s_linear_0.22s,visibility_0s_linear_0.22s]",
              // coming up: after the old one has gone, a fade in and a long, slowing growth
              "data-on:visible data-on:scale-100 data-on:opacity-100 data-on:[transition:opacity_1.2s_cubic-bezier(0.215,0.61,0.355,1)_0.25s,scale_5s_cubic-bezier(0.19,1,0.22,1)_0.25s,visibility_0s]",
              // as specific as the data-on transition, so reduced motion wins over it
              "motion-reduce:scale-100 motion-reduce:transition-none motion-reduce:data-on:transition-none",
              captionClassName
            )}
          >
            {caption}
          </div>
        ))}
      </div>
    </section>
  )
}