Skip to content
jjswnth/ui

Section

Ink Sweep

A statement line where black closes in from both edges while a looping arrow retracts its own straight run, dragging the closing words into place.

WeAre The Best
WeAre The Best

Scroll inside the frame

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/ink-sweep.tsx
    "use client";
    
    import gsap from "gsap";
    import { ScrollTrigger } from "gsap/ScrollTrigger";
    import { useEffect, useRef, useState, type RefObject } from "react";
    import { cn } from "@/lib/utils";
    
    type InkSweepProps = {
      /** Sits before the arrow. */
      lead?: string;
      /** Sits after the arrow, and is dragged in as the arrow's line retracts. */
      trail?: string;
      /** Type size in pixels, before the row is fitted to the screen. */
      fontSize?: number;
      /** The colour everything starts in. */
      base?: string;
      /** The colour the sweep brings in from both edges. */
      ink?: string;
      /** Height of one pinned screen. The page default is a viewport tall. */
      screen?: string;
      /** The scrollable ancestor, when this lives inside one instead of the page. */
      container?: RefObject<HTMLElement | null>;
      className?: string;
    };
    
    /** The arrow's straight run, before and after the sweep. */
    const LINE_LONG = 1200;
    const LINE_SHORT = 90;
    
    /** Breathing room either side of the settled row. */
    const PAD = 80;
    
    const CIRCLE = 120;
    
    /** How much of the sweep a badge takes to turn through 180 degrees. */
    const FLIP_FOR = 0.18;
    
    /** Nearest scrolling ancestor, or null when the page itself is the scroller. */
    function findScroller(node: HTMLElement | null) {
      let parent = node?.parentElement ?? null;
    
      while (parent && parent !== document.body) {
        const overflowY = getComputedStyle(parent).overflowY;
        if (overflowY === "auto" || overflowY === "scroll") return parent;
        parent = parent.parentElement;
      }
    
      return null;
    }
    
    export function InkSweep({
      lead = "We",
      trail = "Are The Best",
      fontSize = 156,
      base = "#808080",
      ink = "#000000",
      screen = "100vh",
      container,
      className,
    }: InkSweepProps) {
      const rootRef = useRef<HTMLDivElement>(null);
      const trackRef = useRef<HTMLDivElement>(null);
      const stageRef = useRef<HTMLDivElement>(null);
      const rowRef = useRef<HTMLDivElement>(null);
    
      // Two identical rows are stacked: a grey one, and a black one revealed by a
      // single edge travelling left to right. Both layers have to move as one.
      const clipRef = useRef<HTMLElement | null>(null);
      const lineRefs = useRef<(HTMLElement | null)[]>([]);
      const leadRefs = useRef<(HTMLElement | null)[]>([]);
      const trailRefs = useRef<(HTMLElement | null)[]>([]);
    
      const [fit, setFit] = useState(1);
      const [rowX, setRowX] = useState(PAD);
      const [stageWidth, setStageWidth] = useState(0);
    
      useEffect(() => {
        gsap.registerPlugin(ScrollTrigger);
    
        const scroller = container?.current ?? findScroller(trackRef.current);
        const context = gsap.context(() => {
          const lines = lineRefs.current.filter(Boolean) as HTMLElement[];
          const leads = leadRefs.current.filter(Boolean) as HTMLElement[];
          const trails = trailRefs.current.filter(Boolean) as HTMLElement[];
          if (!stageWidth) return;
    
          gsap.set(lines, { width: LINE_LONG });
          gsap.set(clipRef.current, { "--wipe": "0%" });
          gsap.set([...leads, ...trails], { rotate: 0 });
    
          // The ink front crosses the screen at a constant rate, so where it meets
          // a badge is pure geometry. The row settles centred, which puts the two
          // badges the same distance in from their own edge.
          const inset = (rowX + CIRCLE * fit) / stageWidth;
          const centred = (at: number) => Math.max(0, at - FLIP_FOR / 2);
    
          const timeline = gsap.timeline({
            scrollTrigger: {
              trigger: trackRef.current,
              scroller: scroller ?? undefined,
              start: "top top",
              end: "bottom bottom",
              scrub: true,
            },
          });
    
          // One front, travelling the full width once. The line retracts alongside
          // it, pulling everything after it leftwards to meet the ink.
          timeline
            .to(lines, { width: LINE_SHORT, duration: 1, ease: "power2.inOut" }, 0)
            .to(clipRef.current, { "--wipe": "100%", duration: 1, ease: "none" }, 0)
            .to(
              leads,
              { rotate: 180, duration: FLIP_FOR, ease: "power2.inOut" },
              centred(inset),
            )
            .to(
              trails,
              { rotate: 180, duration: FLIP_FOR, ease: "power2.inOut" },
              centred(1 - inset),
            );
        }, rootRef);
    
        return () => context.revert();
      }, [container, lead, trail, fontSize, fit, rowX, stageWidth]);
    
      useEffect(() => {
        const stage = stageRef.current;
        if (!stage) return;
    
        const measure = () => {
          const row = rowRef.current;
          const line = lineRefs.current[0];
          if (!row || !line) return;
    
          // Width the row settles at, derived without depending on the line's
          // current width — so this stays correct mid-animation.
          const settled = row.offsetWidth - line.offsetWidth + LINE_SHORT;
          const available = stage.clientWidth - PAD * 2;
          const next = Math.min(1, available / settled);
    
          setFit(next);
          setRowX((stage.clientWidth - settled * next) / 2);
          setStageWidth(stage.clientWidth);
          ScrollTrigger.refresh();
        };
    
        const observer = new ResizeObserver(measure);
        observer.observe(stage);
        return () => observer.disconnect();
      }, []);
    
      const layer = (index: number) => ({
        rowRef: index === 0 ? rowRef : undefined,
        onLine: (node: HTMLElement | null) => {
          lineRefs.current[index] = node;
        },
        onLead: (node: HTMLElement | null) => {
          leadRefs.current[index] = node;
        },
        onTrail: (node: HTMLElement | null) => {
          trailRefs.current[index] = node;
        },
      });
    
      return (
        <div ref={rootRef} className={cn("relative", className)}>
          <div ref={trackRef} style={{ height: `calc(${screen} * 2)` }}>
            <div
              ref={stageRef}
              className="sticky top-0 overflow-hidden bg-[#f1f1f1]"
              style={{ height: screen }}
            >
              <Layer color={base} x={rowX} fit={fit} {...layer(0)} lead={lead} trail={trail} fontSize={fontSize} />
    
              <Layer
                color={ink}
                x={rowX}
                fit={fit}
                {...layer(1)}
                lead={lead}
                trail={trail}
                fontSize={fontSize}
                clipRef={(node) => {
                  clipRef.current = node;
                }}
                clipPath="inset(0 calc(100% - var(--wipe, 0%)) 0 0)"
              />
            </div>
          </div>
        </div>
      );
    }
    
    function Layer({
      lead,
      trail,
      fontSize,
      color,
      x,
      fit,
      rowRef,
      onLine,
      onLead,
      onTrail,
      clipRef,
      clipPath,
    }: {
      lead: string;
      trail: string;
      fontSize: number;
      color: string;
      x: number;
      fit: number;
      rowRef?: RefObject<HTMLDivElement | null>;
      onLine: (node: HTMLElement | null) => void;
      onLead: (node: HTMLElement | null) => void;
      onTrail: (node: HTMLElement | null) => void;
      clipRef?: (node: HTMLElement | null) => void;
      clipPath?: string;
    }) {
      return (
        <div ref={clipRef} className="absolute inset-0" style={{ clipPath }}>
          <div
            ref={rowRef}
            className="absolute top-1/2 flex w-max items-center gap-8 whitespace-nowrap"
            style={{ left: x, color, transform: `translateY(-50%) scale(${fit})`, transformOrigin: "left center" }}
          >
            <Badge onRef={onLead} />
    
            <span
              className="font-[family-name:var(--font-display,var(--font-sans))] leading-[0.9] tracking-[-0.02em]"
              style={{ fontSize }}
            >
              {lead}
            </span>
    
            <Arrow onLine={onLine} />
    
            <span
              className="font-[family-name:var(--font-display,var(--font-sans))] leading-[0.9] tracking-[-0.02em]"
              style={{ fontSize }}
            >
              {trail}
            </span>
    
            <Badge onRef={onTrail} />
          </div>
        </div>
      );
    }
    
    /** A circled arrow that reads clearly when it flips through 180 degrees. */
    function Badge({ onRef }: { onRef: (node: HTMLElement | null) => void }) {
      return (
        <span
          ref={onRef}
          className="grid shrink-0 place-items-center rounded-full"
          style={{
            width: CIRCLE,
            height: CIRCLE,
            border: "5px solid currentColor",
          }}
        >
          <svg
            width={CIRCLE * 0.42}
            height={CIRCLE * 0.42}
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            strokeWidth={2.6}
            strokeLinecap="round"
            strokeLinejoin="round"
            aria-hidden="true"
          >
            <path d="M6 18 18 6" />
            <path d="M9 6h9v9" />
          </svg>
        </span>
      );
    }
    
    /**
     * The loop is fixed; the straight run after it is a separate element so it can
     * be retracted, dragging everything to its right along with it.
     */
    function Arrow({ onLine }: { onLine: (node: HTMLElement | null) => void }) {
      return (
        <span className="flex shrink-0 items-center" aria-hidden="true">
          <svg width={210} height={120} viewBox="0 0 210 120" fill="none">
            <path
              d="M0 60H34C52 60 62 48 62 32C62 14 74 4 90 4C106 4 118 14 118 32V86C118 102 130 112 146 112C162 112 174 102 174 86V74C174 66 182 60 196 60H210"
              stroke="currentColor"
              strokeWidth={6}
              strokeLinecap="round"
              strokeLinejoin="round"
            />
          </svg>
    
          <span
            ref={onLine}
            className="block shrink-0 rounded-full bg-current"
            style={{ height: 6, width: LINE_SHORT, marginLeft: -3, marginRight: -3 }}
          />
    
          <svg width={26} height={40} viewBox="0 0 26 40" fill="none">
            <path
              d="M4 4L20 20L4 36"
              stroke="currentColor"
              strokeWidth={6}
              strokeLinecap="round"
              strokeLinejoin="round"
            />
          </svg>
        </span>
      );
    }

Props

PropTypeDefaultDescription
leadstring"We"Sits before the arrow.
trailstring"Are The Best"Sits after the arrow, and is dragged in as the line retracts.
fontSizenumber156Type size in pixels, before the row is fitted to the screen.
basestring"#808080"The colour everything starts in.
inkstring"#000000"The colour the sweep brings in from both edges.
screenstring"100vh"Height of one pinned screen. Match the scroll container when it is not the page.
containerRefObject<HTMLElement | null>The scrollable ancestor, when the section lives inside one instead of the page.