Skip to content
jjswnth/ui

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

Installation

  1. 1. Install the dependencies.

    terminal
    npm install gsap clsx tailwind-merge
  2. 2. Add the cn helper, 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. 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

PropTypeDefaultDescription
titlestring"Jswnth-ui"Set letter by letter as the section opens, sized to span the full width.
imagesstring[]One 16:9 card per entry.
brandstring"jswnth/ui"Wordmark on the left of the header.
linksReelLink[]3 linksLinks on the right of the header.
cardWidthnumber224Card width in pixels. Height follows 16:9.
heightstring"100dvh"Height of the section. Everything is sized to fit inside it.