Section
Headline Reel
A black opening title that sets one letter at a time, with a draggable reel of cards rising in behind the last of them.
Jswnth-ui
Jswnth-ui
import { useState } from "react";
import { HeadlineReel } from "@/components/ui/headline-reel";
const images = Array.from(
{ length: 10 },
(_, index) => `https://picsum.photos/seed/reel-${index}/640/360`,
);
export default function HeadlineReelDemo({ fullscreen }: { fullscreen?: boolean }) {
const [run, setRun] = useState(0);
return (
<div className="w-full space-y-3">
<HeadlineReel
key={run}
title="Jswnth-ui"
images={images}
height={fullscreen ? "100dvh" : "460px"}
className={fullscreen ? undefined : "rounded-card border border-line"}
/>
<button
type="button"
onClick={() => setRun((value) => value + 1)}
className="mx-auto block rounded-full border border-line bg-surface px-4 py-1.5 text-xs font-medium text-muted transition-colors hover:text-ink"
>
Replay
</button>
</div>
);
}Installation
1. Install the dependencies.
terminal npm install gsap clsx tailwind-merge2. Add the
cnhelper, if you don't already have it.lib/utils.ts import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }3. Copy the component into your project.
components/ui/headline-reel.tsx "use client"; import gsap from "gsap"; import { useCallback, useEffect, useRef, useState, type PointerEvent, } from "react"; import { cn } from "@/lib/utils"; export type ReelLink = { label: string; href: string }; type HeadlineReelProps = { /** Set letter by letter as the section opens, sized to span the full width. */ title?: string; /** One 16:9 card per entry. */ images: string[]; /** Wordmark on the left of the header. */ brand?: string; /** Links on the right of the header. */ links?: ReelLink[]; /** Card width in pixels. Height follows 16:9. */ cardWidth?: number; /** Height of the section. Everything is sized to fit inside it. */ height?: string; className?: string; }; /** Seconds between one letter landing and the next. */ const LETTER_STEP = 0.055; /** How far through the letters the cards start arriving. */ const OVERLAP = 0.68; /** The size the hidden probe is measured at, before scaling to fit. */ const PROBE_SIZE = 100; /** Height one line of the title occupies, in ems: leading plus its masks. */ const TITLE_LINE = 0.94; /** Fixed vertical space the section spends on padding and the two gaps. */ const GUTTERS = 48 + 16 + 24; const TYPE = "font-[family-name:var(--font-display,var(--font-sans))] leading-[0.82] tracking-[-0.03em] uppercase"; export function HeadlineReel({ title = "Jswnth-ui", images, brand = "jswnth/ui", links = [ { label: "Work", href: "#work" }, { label: "Studio", href: "#studio" }, { label: "Contact", href: "#contact" }, ], cardWidth = 224, height = "100dvh", className, }: HeadlineReelProps) { const rootRef = useRef<HTMLDivElement>(null); const headerRef = useRef<HTMLElement>(null); const headRef = useRef<HTMLDivElement>(null); const reelRef = useRef<HTMLDivElement>(null); const probeRef = useRef<HTMLSpanElement>(null); const railRef = useRef<HTMLDivElement>(null); const letterRefs = useRef<(HTMLElement | null)[]>([]); const cardRefs = useRef<(HTMLElement | null)[]>([]); const [fontSize, setFontSize] = useState(PROBE_SIZE); const pan = useRef({ x: 0 }); const drag = useRef<{ pointerX: number; from: number; lastX: number; lastAt: number; velocity: number; } | null>(null); const letters = [...title]; // The probe is a hidden copy at a fixed size, so measuring it never changes // as the visible title is resized — no feedback loop. The section's height is // fixed for the same reason: the title is capped against it, so it can never // grow tall enough to push the reel off screen. useEffect(() => { const root = rootRef.current; const head = headRef.current; const probe = probeRef.current; if (!root || !head || !probe) return; const fit = () => { const natural = probe.scrollWidth; if (!natural) return; const byWidth = (PROBE_SIZE * head.clientWidth) / natural; const spare = root.clientHeight - (headerRef.current?.offsetHeight ?? 0) - (reelRef.current?.offsetHeight ?? 0) - GUTTERS; const byHeight = spare > 0 ? spare / TITLE_LINE : byWidth; setFontSize(Math.max(24, Math.min(byWidth, byHeight))); }; fit(); const observer = new ResizeObserver(fit); observer.observe(root); observer.observe(head); // Sizes shift once the display face has actually loaded. document.fonts?.ready.then(fit).catch(() => {}); return () => observer.disconnect(); }, [title, cardWidth]); useEffect(() => { const context = gsap.context(() => { const letterEls = letterRefs.current.filter(Boolean) as HTMLElement[]; const cardEls = cardRefs.current.filter(Boolean) as HTMLElement[]; const timeline = gsap.timeline(); timeline.from( letterEls, { yPercent: 118, duration: 0.95, ease: "power4.out", stagger: LETTER_STEP, }, 0, ); // Cards start while the last letters are still landing, so the two // movements read as one entrance rather than two. timeline.from( cardEls, { yPercent: 140, opacity: 0, duration: 0.8, ease: "power3.out", stagger: 0.075, }, letterEls.length * LETTER_STEP * OVERLAP, ); }, rootRef); return () => context.revert(); }, [title, images]); /** How far the rail may travel before it runs out of cards. */ const limit = useCallback(() => { const rail = railRef.current; const root = rootRef.current; if (!rail || !root) return 0; return Math.min(0, root.clientWidth - rail.scrollWidth - 48); }, []); const onPointerDown = useCallback((event: PointerEvent<HTMLDivElement>) => { gsap.killTweensOf(pan.current); event.currentTarget.setPointerCapture(event.pointerId); drag.current = { pointerX: event.clientX, from: pan.current.x, lastX: event.clientX, lastAt: performance.now(), velocity: 0, }; }, []); const onPointerMove = useCallback( (event: PointerEvent<HTMLDivElement>) => { const held = drag.current; if (!held) return; const now = performance.now(); held.velocity = (event.clientX - held.lastX) / Math.max(1, now - held.lastAt); held.lastX = event.clientX; held.lastAt = now; pan.current.x = gsap.utils.clamp( limit(), 0, held.from + (event.clientX - held.pointerX), ); gsap.set(railRef.current, { x: pan.current.x }); }, [limit], ); const onPointerUp = useCallback( (event: PointerEvent<HTMLDivElement>) => { const held = drag.current; event.currentTarget.releasePointerCapture(event.pointerId); drag.current = null; if (!held) return; gsap.to(pan.current, { x: gsap.utils.clamp(limit(), 0, pan.current.x + held.velocity * 260), duration: 1.1, ease: "power3.out", onUpdate: () => gsap.set(railRef.current, { x: pan.current.x }), }); }, [limit], ); return ( <section ref={rootRef} style={{ height }} className={cn( "relative flex flex-col overflow-hidden bg-black py-6 text-white", className, )} > <header ref={headerRef} className="flex items-center justify-between px-6 text-sm" > <span className="font-medium tracking-tight">{brand}</span> <nav className="flex gap-6"> {links.map((link) => ( <a key={link.href} href={link.href} className="text-white/55 transition-colors hover:text-white" > {link.label} </a> ))} </nav> </header> <div className="flex flex-col"> <div ref={headRef} className="relative mt-4 w-full px-3"> {/* Measured, never shown. */} <span ref={probeRef} aria-hidden className={cn("pointer-events-none absolute whitespace-pre opacity-0", TYPE)} style={{ fontSize: PROBE_SIZE }} > {title} </span> <h2 className="flex justify-center" style={{ fontSize }} aria-label={title} > {letters.map((character, index) => ( <span key={index} className="inline-block overflow-hidden py-[0.06em]"> <span ref={(node) => { letterRefs.current[index] = node; }} aria-hidden className={cn("inline-block whitespace-pre", TYPE)} > {character} </span> </span> ))} </h2> </div> <div ref={reelRef} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp} className="mt-6 cursor-grab touch-pan-y select-none active:cursor-grabbing" > <div ref={railRef} className="flex w-max gap-4 px-6"> {images.map((image, index) => ( <figure key={image} ref={(node) => { cardRefs.current[index] = node; }} style={{ width: cardWidth, aspectRatio: "16 / 9" }} className="shrink-0 overflow-hidden rounded-xl bg-white/10" > <img src={image} alt="" aria-hidden draggable={false} className="size-full object-cover" /> </figure> ))} </div> </div> </div> </section> ); }
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| title | string | "Jswnth-ui" | Set letter by letter as the section opens, sized to span the full width. |
| images | string[] | — | One 16:9 card per entry. |
| brand | string | "jswnth/ui" | Wordmark on the left of the header. |
| links | ReelLink[] | 3 links | Links on the right of the header. |
| cardWidth | number | 224 | Card width in pixels. Height follows 16:9. |
| height | string | "100dvh" | Height of the section. Everything is sized to fit inside it. |