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
How it works
- Built on GSAP SplitText: chars inside masked lines rise from 135% to 0 over one second on expo.out, 0.0175s apart.
- 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.
- One gsap.quickTo per letter drives a --wght custom property, and a letter only re-tweens when its weight moves by a whole unit.
- 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.
- Off on touch screens and with reduced motion. Screen readers get the whole sentence.
Source
npm i react gsap"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;
}
