Variable weight headline

The headline rises in letter by letter, then each letter thins out as the cursor nears it, on the font's own weight axis

Type
Effect
Built with
React
Published
Access
Free
Live demoOpen on its own ↗

How it works

  1. Built on GSAP SplitText: chars inside masked lines rise from 135% to 0 over one second on expo.out, 0.0175s apart.
  2. After the reveal, each letter's weight eases between 560 and 200 by distance to the pointer, falling off linearly across about three times the font size.
  3. One gsap.quickTo per letter drives a --wght custom property, and a letter only re-tweens when its weight moves by a whole unit.
  4. It needs a variable font with a weight axis. Source Serif 4 Variable runs from 200 to 900, and the optical size axis stays automatic.
  5. Off on touch screens and with reduced motion. Screen readers get the whole sentence.

Source

npm i react gsap
TextReveal.tsx
"use client";

import { usePathname } from "next/navigation";
import { useEffect } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { SplitText } from "gsap/SplitText";
import { introHandover, introPending, onIntroRelease } from "../lib/intro";

gsap.registerPlugin(ScrollTrigger, SplitText);

/**
 * Headline motion. One effect on every heading (Abbas, 23 Oct; one signature, as juncastudio.com keeps one):
 *
 * Fill — `data-text-reveal` (any value but "rise"; "chars" and "lines" are kept as aliases)
 *   The Statement's letter fill, played once instead of scrubbed: split into inline chars (kerning kept), each
 *   char's `--fill` runs 0 → 1, which styles.css maps to opacity 0.2 → 1 and colour from `--text-fill-start` to the
 *   heading's own colour. The sweep runs left to right over min(1.2s, 0.5 + chars/200), h3–h6 quicker
 *   (min(0.7s, 0.35 + chars/300)); each char fills over 0.3s, linear, so a soft band of letters moves across.
 *   `data-text-reveal-load` plays on load (the hero; on a first visit, once the intro lets go); everything else plays once as it scrolls past 85% of the
 *   viewport, and headings that arrive together queue 0.09s apart. Headings inside a `[data-reveal]` card wait
 *   0.12s for the card. `data-text-reveal-manual` waits for a `reveal:play` event instead, for headings ScrollTrigger
 *   can't place (the service slides, which move inside a pinned stage). Screen readers get the whole sentence (an
 *   aria-label; the chars are aria-hidden).
 *
 * Text intros (Abbas, 2 Nov 2026; the engineering after juncastudio.com's text reveal, the look our own):
 *   Label — `data-text-reveal="label"`, small Inter labels. Each word first shows as a skeleton bar, as a product's
 *     UI does while it loads; then its letters ink in over it left to right and the bar fades, in about 0.45s. Only
 *     opacity changes, so the line never moves. Plays once like a heading (queued, scroll or manual); screen readers
 *     get a plain copy, the letters are aria-hidden.
 *   Intro — `data-text-reveal="intro"`, the first paragraph under a heading: the fill a word at a time, scrubbed with
 *     scroll (it fills as the paragraph rises from 85% to 55% of the viewport, and empties on the way back). With
 *     `data-text-reveal-manual` (a pinned slide's copy, which never scrolls past) it plays instead, starting once its
 *     group's heading is two thirds filled. Off on phones, so reading is never held up.
 *   Answer — `data-text-reveal="answer"`, an FAQ answer: the same word fill, played each time its question opens; the
 *     answer that starts open plays as it scrolls in.
 *   Anything that waits on scroll plays anyway once it has been on screen for 2.5s, and everything still waiting on
 *   screen plays when the page is scrolled to its end (the footer's last labels never reach the 85% line).
 *
 * Rise — `data-text-reveal="rise"`, after the extrafazant.nl hero (the Library's weight-headline demo keeps it):
 *   Split into masked lines; each char rises from yPercent 135 to 0 over 1s, expo.out, 0.0175s apart.
 *
 * Weight hover — `data-font-weight-hover` (+ `data-weight-rest`, `data-weight-near`, `data-radius-em`)
 *   Each char's variable-font weight follows the pointer: rest + (near − rest) × (1 − d / r),
 *   eased with gsap.quickTo on --wght (0.4s, power2.out). Starts after the reveal; off on touch and
 *   with reduced motion.
 */

const ease = "expo.out";
const staggerChars = 0.0175;
// The fill: each char's own fade, the sweep across a heading (large and small), and the queue between headings.
const charFill = 0.3;
const sweepFor = (chars: number, small: boolean) => (small ? Math.min(0.7, 0.35 + chars / 300) : Math.min(1.2, 0.5 + chars / 200));
const queueGap = 0.09;
let queueEnd = 0;
// Labels: the bars come in (a beat apart per word), the letters ink in across the label, each word's bar fading as
// its letters land.
const skeleton = { barIn: 0.12, barGap: 0.03, sweep: 0.3, charIn: 0.12, barOut: 0.16 };
// Played word fills (intros, answers): each word over 0.35s, 40 words a second, the whole at most 0.9s.
const wordFill = 0.35;
const wordPace = 1 / 40;
const wordCap = 0.9;
// A played intro starts once its heading is this far filled.
const handoffAt = 0.65;
// Scrubbed intros fill between these scroll positions.
const introStart = "top 85%";
const introEnd = "top 55%";
// A reveal still waiting after this long on screen plays anyway (milliseconds).
const stuckAfter = 2500;

// The queue never holds a reveal back more than this, however many arrive at once (a jump down the page).
const queueMax = 0.36;

/** Claims the next slot in the queue of reveals arriving together; returns the wait before this one. */
const queued = () => {
  const now = gsap.ticker.time;
  const wait = Math.min(queueMax, Math.max(0, queueEnd - now));
  queueEnd = now + wait + queueGap;
  return wait;
};

type Revealed = HTMLElement & { _split?: SplitText; _revealed?: boolean; _handoff?: (() => void)[]; _handedOff?: boolean };
type Revertible = { revert: () => void };
type Guard = (element: Revealed, play: () => void) => () => void;
/** What every reveal in one pass shares: its cleanups, the watchdog's guard and the cue. */
type Pass = { stops: (() => void)[]; guard: Guard; cue: (element: Revealed, play: () => void, target?: Element) => void };

/** The heading has filled far enough for the intros waiting on it. */
const handOff = (element: Revealed) => {
  element._handedOff = true;
  element._handoff?.splice(0).forEach((start) => start());
};

const finish = (element: Revealed) => {
  element._revealed = true;
  handOff(element);
  element.dispatchEvent(new CustomEvent("reveal:done"));
};

/** The Library demo's rise: masked chars rising in, on load or once in view. */
function riseSplit(element: Revealed, onLoad: boolean, delay: number) {
  return SplitText.create(element, {
    type: "chars,words,lines",
    linesClass: "line",
    charsClass: "char",
    mask: "lines",
    // Weight-hover headings keep their split: char positions are measured once the reveal ends.
    autoSplit: !element.hasAttribute("data-font-weight-hover"),
    onSplit(self) {
      element._split = self;
      return gsap.fromTo(self.chars, { yPercent: 135 }, {
        yPercent: 0,
        duration: 1,
        ease,
        stagger: staggerChars,
        delay,
        scrollTrigger: onLoad ? undefined : { trigger: element, start: "top 85%", once: true },
        onComplete: () => finish(element),
      });
    },
  });
}

/**
 * The scroll cue: plays each watched element's reveal once its top passes 85% of the viewport, read from the live
 * layout (one observer for every reveal). Text at the very end of the page can never rise that far (the footer
 * cards' bottom row of labels stops near 89%), so once the page is scrolled to its end, whatever is still waiting
 * on screen plays too.
 */
function scrollCue(stops: (() => void)[]) {
  const plays = new Map<Element, () => void>();
  const play = (target: Element) => {
    observer.unobserve(target);
    plays.get(target)?.();
    plays.delete(target);
  };
  const observer = new IntersectionObserver((entries) => {
    entries.forEach(({ isIntersecting, boundingClientRect, target }) => {
      // Past the line on the way down, or already above it (a jump down the page); never while it has no box.
      if (isIntersecting || (boundingClientRect.top <= 0 && boundingClientRect.height > 0)) play(target);
    });
  }, { rootMargin: "0px 0px -15% 0px" });
  let frame = 0;
  const atEnd = () => {
    frame = 0;
    if (!plays.size || window.scrollY + window.innerHeight < document.documentElement.scrollHeight - 2) return;
    plays.forEach((_, target) => {
      const box = target.getBoundingClientRect();
      if (box.height > 0 && box.top < window.innerHeight) play(target);
    });
  };
  const onScroll = () => {
    frame ||= requestAnimationFrame(atEnd);
  };
  window.addEventListener("scroll", onScroll, { passive: true });
  stops.push(() => {
    observer.disconnect();
    plays.clear();
    cancelAnimationFrame(frame);
    window.removeEventListener("scroll", onScroll);
  });
  return (target: Element, reveal: () => void) => {
    plays.set(target, reveal);
    observer.observe(target);
  };
}

/**
 * Plays `play` when the element's cue comes: its section's `reveal:play` (`data-text-reveal-manual`, or at once if
 * that was already sent), otherwise as it (or `target`) scrolls in.
 */
function cueFor(watch: ReturnType<typeof scrollCue>, stops: (() => void)[]): Pass["cue"] {
  return (element, play, target = element) => {
    if (element.hasAttribute("data-text-reveal-manual")) {
      if (element.hasAttribute("data-text-reveal-played")) play();
      else {
        element.addEventListener("reveal:play", play, { once: true });
        stops.push(() => element.removeEventListener("reveal:play", play));
      }
      return;
    }
    watch(target, play);
  };
}

/** The fill: inline chars (no transforms, so no masks and no re-split on resize) filling left to right. */
function fillSplit(element: Revealed, onLoad: boolean, delay: number, { stops, guard, cue }: Pass) {
  // The spoken name keeps a space where a <br> breaks the line (SplitText's own label runs "designerA full…"). Read
  // from a copy, not innerText, which is empty for a heading the browser hasn't laid out yet (far below the fold).
  const copy = element.cloneNode(true) as HTMLElement;
  copy.querySelectorAll("br").forEach((br) => br.replaceWith(" "));
  const label = (copy.textContent ?? "").replace(/\s+/g, " ").trim();
  const split = SplitText.create(element, { type: "chars,words", tag: "span", charsClass: "fill-char", wordsClass: "fill-word" });
  element.setAttribute("aria-label", label);
  element._split = split;
  const small = /^H[3-6]$/.test(element.tagName);
  const tween = gsap.fromTo(split.chars, { "--fill": 0 }, {
    "--fill": 1,
    duration: charFill,
    ease: "none",
    stagger: { amount: Math.max(0, sweepFor(split.chars.length, small) - charFill), ease: "power1.inOut" },
    paused: true,
    onComplete: () => {
      if (onLoad) gsap.ticker.lagSmoothing(500, 33);
      finish(element);
    },
  });
  // Intros waiting on this heading start once it is two thirds filled (added after creation: GSAP renders the start
  // state as it builds the tween, before `tween` exists).
  tween.eventCallback("onUpdate", () => {
    if (!element._handedOff && tween.progress() >= handoffAt) handOff(element);
  });
  // Extra lead before the fill, for the hero after the intro.
  let lead = 0;
  const play = () => {
    const wait = queued();
    // On load (the hero) the scene may be compiling shaders and blocking the main thread for seconds. On wall-clock
    // time (no lag smoothing, GSAP's default restored after) the fill lands on schedule after a stall, instead of
    // crawling on, faint, behind it.
    if (onLoad) gsap.ticker.lagSmoothing(0);
    tween.delay(delay + lead + wait).play();
  };
  if (onLoad) {
    // After the intro, the hero waits for the counter's digits to roll clear of it.
    const handover = introPending() ? introHandover : 0;
    stops.push(onIntroRelease(() => {
      lead = handover;
      play();
    }));
  } else {
    // Headings a ScrollTrigger can't place (the service slides, moved inside a pinned stage) wait for their section's
    // `reveal:play`; the rest play as they scroll in.
    cue(element, guard(element, play));
  }
  return split;
}

/** A small label: skeleton bars, then the letters inking in over them (see the header). */
function labelSplit(element: Revealed, delay: number, { guard, cue }: Pass): Revertible {
  // A plain copy for screen readers; the split letters sit in an aria-hidden holder beside it.
  const copy = document.createElement("span");
  copy.className = "visually-hidden";
  copy.textContent = (element.textContent ?? "").replace(/\s+/g, " ").trim();
  const holder = document.createElement("span");
  holder.setAttribute("aria-hidden", "true");
  holder.append(...Array.from(element.childNodes));
  element.append(copy, holder);
  // aria "none": the holder is already hidden, and the copy speaks for it.
  const split = SplitText.create(holder, { type: "words,chars", tag: "span", wordsClass: "skeleton-word", charsClass: "skeleton-char", aria: "none" });
  gsap.set(split.words, { "--bar": 0 });
  gsap.set(split.chars, { "--letter": 0 });

  const timeline = gsap.timeline({ paused: true, onComplete: () => finish(element) });
  timeline.to(split.words, { "--bar": 1, duration: skeleton.barIn, ease: "none", stagger: skeleton.barGap });
  const start = skeleton.barIn + skeleton.barGap * (split.words.length - 1);
  const last = Math.max(1, split.chars.length - 1);
  let index = 0;
  split.words.forEach((word) => {
    const first = start + (index / last) * skeleton.sweep;
    word.querySelectorAll(".skeleton-char").forEach((char) => {
      timeline.to(char, { "--letter": 1, duration: skeleton.charIn, ease: "none" }, start + (index / last) * skeleton.sweep);
      index += 1;
    });
    const end = start + ((index - 1) / last) * skeleton.sweep;
    timeline.to(word, { "--bar": 0, duration: end - first + skeleton.barOut, ease: "power1.in" }, first);
  });

  cue(element, guard(element, () => timeline.delay(delay + queued()).play()));
  return {
    revert() {
      timeline.kill();
      split.revert();
      element.replaceChildren(...Array.from(holder.childNodes));
    },
  };
}

/** The played word fill of an intro or answer, from faint. */
const wordTween = (words: Element[]) =>
  gsap.fromTo(words, { "--fill": 0 }, {
    "--fill": 1,
    duration: wordFill,
    ease: "none",
    stagger: { amount: Math.max(0, Math.min(wordCap - wordFill, words.length * wordPace)) },
    paused: true,
  });

/** An intro paragraph: scrubbed with scroll, or played after its group's heading (see the header). */
function introSplit(element: Revealed, delay: number, { stops, guard, cue }: Pass): Revertible {
  // Words, not letters, so screen readers read the paragraph as it is: no aria-label or aria-hidden (aria "none").
  const split = SplitText.create(element, { type: "words", tag: "span", wordsClass: "fill-word--intro", aria: "none" });
  if (!element.hasAttribute("data-text-reveal-manual")) {
    // Each word fills over 0.35 of the scroll range, starting evenly across the rest of it.
    const tween = gsap.fromTo(split.words, { "--fill": 0 }, {
      "--fill": 1,
      duration: wordFill,
      ease: "none",
      stagger: { amount: 1 - wordFill },
      scrollTrigger: { trigger: element, start: introStart, end: introEnd, scrub: true },
    });
    stops.push(() => tween.scrollTrigger?.kill());
    return { revert: () => (tween.kill(), split.revert()) };
  }
  const tween = wordTween(split.words);
  tween.eventCallback("onComplete", () => finish(element));
  const start = () => tween.delay(delay + queued()).play();
  const group = element.closest("[data-text-reveal-group]") ?? element.closest("section");
  const heading = group?.querySelector<Revealed>(":is(h1, h2, h3, h4, h5, h6)[data-text-reveal]");
  const play = () => {
    if (!heading || heading._handedOff || heading._revealed) start();
    else (heading._handoff ??= []).push(start);
  };
  cue(element, guard(element, play));
  return { revert: () => (tween.kill(), split.revert()) };
}

/** An FAQ answer: the word fill each time its question opens; the one open at first plays as it scrolls in. */
function answerSplit(element: Revealed, { stops, guard, cue }: Pass): Revertible {
  const details = element.closest("details");
  // As an intro: plain words, so the answer and any link in it read as they are.
  const split = SplitText.create(element, { type: "words", tag: "span", wordsClass: "fill-word--intro", aria: "none" });
  let entered = false;
  let tween: gsap.core.Tween | null = null;
  const play = () => {
    tween?.kill();
    tween = wordTween(split.words).play();
  };
  if (details?.open) gsap.set(split.words, { "--fill": 0 });
  const enter = () => {
    entered = true;
    if (!details || details.open) play();
  };
  cue(element, details?.open ? guard(element, enter) : enter, details ?? element);
  const toggle = () => {
    if (details?.open && entered) play();
  };
  details?.addEventListener("toggle", toggle);
  stops.push(() => details?.removeEventListener("toggle", toggle));
  return { revert: () => (tween?.kill(), split.revert()) };
}

/**
 * The failsafe: a reveal still waiting once it has been on screen for 2.5s plays anyway, so a missed trigger never
 * leaves text faint or hidden. `guard` wraps a reveal's play so it runs once, whichever comes first.
 */
function watchdog(stops: (() => void)[]): Guard {
  const waiting = new Map<Revealed, { play: () => void; seen: number }>();
  const timer = window.setInterval(() => {
    const now = performance.now();
    waiting.forEach((entry, element) => {
      // On screen means its middle is in the viewport and nothing hides it.
      const box = element.getBoundingClientRect();
      const middle = (box.top + box.bottom) / 2;
      const shown = box.width >= 1 && middle > 0 && middle < window.innerHeight && element.checkVisibility({ visibilityProperty: true });
      if (!shown) entry.seen = 0;
      else if (!entry.seen) entry.seen = now;
      else if (now - entry.seen >= stuckAfter) entry.play();
    });
  }, 1000);
  stops.push(() => {
    window.clearInterval(timer);
    waiting.clear();
  });
  return (element, play) => {
    // A manual reveal belongs to its section's own scroll (the service slides). It can't be judged from its box
    // either: a waiting slide's copy sits exactly over the current slide's, clipped out of view.
    if (element.hasAttribute("data-text-reveal-manual")) return play;
    let played = false;
    const once = () => {
      if (played) return;
      played = true;
      waiting.delete(element);
      play();
    };
    waiting.set(element, { play: once, seen: 0 });
    return once;
  };
}

const reducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Captures (posters, HUD checks) hold the finished headline, but still let a poster drive the hover.
const capturing = () => document.documentElement.dataset.capture === "true";

function initReveal(root: ParentNode) {
  const elements = Array.from(root.querySelectorAll<Revealed>("[data-text-reveal]"));
  const splits: Revertible[] = [];
  const stops: (() => void)[] = [];
  const reduced = reducedMotion() || capturing();
  // Intro paragraphs stay still on phones.
  const phone = window.matchMedia(`(max-width: ${process.env.DESIGN_BREAKPOINT_MOBILE})`).matches;
  const pass: Pass = { stops, guard: watchdog(stops), cue: cueFor(scrollCue(stops), stops) };

  elements.forEach((element) => {
    element.dataset.textReady = "true";
    const kind = element.dataset.textReveal;
    if (reduced || element._split || (kind === "intro" && phone)) {
      finish(element);
      return;
    }
    const onLoad = element.hasAttribute("data-text-reveal-load");
    const delay = (element.closest("[data-reveal]") ? 0.12 : 0) + (parseFloat(element.dataset.textRevealDelay ?? "") || 0);
    try {
      if (kind === "label") splits.push(labelSplit(element, delay, pass));
      else if (kind === "intro") splits.push(introSplit(element, delay, pass));
      else if (kind === "answer") splits.push(answerSplit(element, pass));
      else splits.push(kind === "rise" ? riseSplit(element, onLoad, delay) : fillSplit(element, onLoad, delay, pass));
    } catch {
      // A heading that cannot be split simply shows, unanimated.
      element._revealed = true;
    }
  });

  return () => {
    stops.forEach((stop) => stop());
    splits.forEach((split) => split.revert());
    elements.forEach((element) => {
      delete element._split;
      delete element._revealed;
      delete element._handoff;
      delete element._handedOff;
    });
  };
}

interface WeightChar {
  el: HTMLElement;
  cx: number;
  cy: number;
  rest: number;
  near: number;
  radius: number;
  last: number;
  set: (value: number) => void;
}

function initWeightHover(root: ParentNode) {
  if (reducedMotion() || (!capturing() && window.matchMedia("(hover: none), (pointer: coarse)").matches)) return () => {};
  const hosts = Array.from(root.querySelectorAll<Revealed>("[data-font-weight-hover]"));
  if (!hosts.length) return () => {};

  let chars: WeightChar[] = [];
  const pointer = { x: 0, y: 0, active: false };
  const cleanups: (() => void)[] = [];

  const measure = () => {
    chars.forEach((char) => {
      const rect = char.el.getBoundingClientRect();
      char.cx = rect.left + rect.width / 2 + window.scrollX;
      char.cy = rect.top + rect.height / 2 + window.scrollY;
    });
  };

  const collect = () => {
    chars = [];
    hosts.forEach((host) => {
      const split = host._split;
      if (!split) return;
      const style = getComputedStyle(host);
      const restAttr = parseFloat(host.dataset.weightRest ?? "");
      const rest = Number.isFinite(restAttr) ? restAttr : parseFloat(style.fontWeight) || 400;
      const nearAttr = parseFloat(host.dataset.weightNear ?? "");
      const near = Number.isFinite(nearAttr) ? nearAttr : rest;
      const radius = (parseFloat(host.dataset.radiusEm ?? "") || 3.1) * parseFloat(style.fontSize);
      split.chars.forEach((node) => {
        const el = node as HTMLElement;
        el.style.setProperty("--wght", String(rest));
        el.style.fontVariationSettings = "'wght' var(--wght)";
        chars.push({ el, cx: 0, cy: 0, rest, near, radius, last: rest, set: gsap.quickTo(el, "--wght", { duration: 0.4, ease: "power2.out" }) });
      });
    });
    measure();
  };

  // After the reveal, re-split as plain chars so the heading can reflow on resize without masks.
  const resplit = () => {
    hosts.forEach((host) => {
      host._split?.revert();
      host._split = SplitText.create(host, { type: "chars,words", charsClass: "char" });
    });
    collect();
  };

  const tick = () => {
    if (!pointer.active) return;
    for (const char of chars) {
      const distance = Math.hypot(pointer.x - char.cx, pointer.y - char.cy);
      const amount = distance >= char.radius ? 0 : 1 - distance / char.radius;
      const lo = Math.min(char.rest, char.near);
      const hi = Math.max(char.rest, char.near);
      const weight = Math.min(hi, Math.max(lo, char.rest + (char.near - char.rest) * amount));
      if (Math.abs(weight - char.last) < 1) continue;
      char.last = weight;
      char.set(weight);
    }
  };

  const move = (event: PointerEvent) => {
    pointer.active = true;
    pointer.x = event.pageX;
    pointer.y = event.pageY;
  };
  let frame = 0;
  const onResize = () => {
    cancelAnimationFrame(frame);
    frame = requestAnimationFrame(resplit);
  };

  const start = () => {
    resplit();
    gsap.ticker.add(tick);
    window.addEventListener("pointermove", move, { passive: true });
    window.addEventListener("resize", onResize, { passive: true });
  };

  const pending = hosts.filter((host) => host.hasAttribute("data-text-reveal") && !host._revealed);
  if (pending.length) {
    let waiting = pending.length;
    pending.forEach((host) => {
      const done = () => {
        waiting -= 1;
        if (waiting === 0) start();
      };
      host.addEventListener("reveal:done", done, { once: true });
      cleanups.push(() => host.removeEventListener("reveal:done", done));
    });
  } else {
    start();
  }

  return () => {
    cleanups.forEach((cleanup) => cleanup());
    cancelAnimationFrame(frame);
    gsap.ticker.remove(tick);
    window.removeEventListener("pointermove", move);
    window.removeEventListener("resize", onResize);
    chars.forEach((char) => {
      gsap.killTweensOf(char.el);
      char.el.style.removeProperty("--wght");
      char.el.style.fontVariationSettings = "";
    });
  };
}

/** Runs the headline reveal and weight hover for a subtree; returns a cleanup. Reused by the Library demo. */
export function initHeadlineMotion(root: ParentNode = document) {
  const stopReveal = initReveal(root);
  const stopHover = initWeightHover(root);
  return () => {
    stopHover();
    stopReveal();
  };
}

/** Mounted once in the root layout: every page's headlines, re-wired on route change, after fonts load. */
export function TextReveal() {
  const pathname = usePathname();
  useEffect(() => {
    let cleanup: (() => void) | null = null;
    let cancelled = false;
    // Wait for fonts so lines break where they will finally sit, but never longer than a beat.
    const fontsReady = Promise.race([document.fonts.ready, new Promise((resolve) => setTimeout(resolve, 1200))]);
    void fontsReady.then(() => {
      if (cancelled) return;
      cleanup = initHeadlineMotion(document);
      ScrollTrigger.refresh();
    });
    return () => {
      cancelled = true;
      cleanup?.();
    };
  }, [pathname]);
  return null;
}

Made by Abbas Alabura

Want this level of care
in your product?