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
import { useRef } from "react";
import { InkSweep } from "@/components/ui/ink-sweep";
/** Boxed in the docs; on its own page the section takes the viewport. */
const SCREEN = 360;
export default function InkSweepDemo({ fullscreen }: { fullscreen?: boolean }) {
const scroller = useRef<HTMLDivElement>(null);
if (fullscreen) {
return <InkSweep lead="We" trail="Are The Best" screen="100dvh" />;
}
return (
<div className="w-full space-y-3">
<div
ref={scroller}
className="scrollbar-thin w-full min-w-0 overflow-y-auto overscroll-contain rounded-card border border-line"
style={{ height: SCREEN }}
>
<InkSweep
lead="We"
trail="Are The Best"
screen={`${SCREEN}px`}
container={scroller}
/>
</div>
<p className="text-center text-xs text-faint">Scroll inside the frame</p>
</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/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
| Prop | Type | Default | Description |
|---|---|---|---|
| lead | string | "We" | Sits before the arrow. |
| trail | string | "Are The Best" | Sits after the arrow, and is dragged in as the line retracts. |
| fontSize | number | 156 | Type size in pixels, before the row is fitted to the screen. |
| base | string | "#808080" | The colour everything starts in. |
| ink | string | "#000000" | The colour the sweep brings in from both edges. |
| screen | string | "100vh" | Height of one pinned screen. Match the scroll container when it is not the page. |
| container | RefObject<HTMLElement | null> | — | The scrollable ancestor, when the section lives inside one instead of the page. |