Skip to content
09 / 10Scroll7 min read

Scrub

The statement on the home page is not triggered by scroll — it is played by it. Thirteen words ink in, half a beat apart, and the beat that matters most is the one where nothing animates at all.

Used on this site

This runs on the home page, just past the selected-work list: “Worlds rendered live in the browser. You feel them before you read them.” inks in word by word as you scroll it, holds still, then leaves. Scroll it slowly, then scroll back up — it un-inks, because scroll is the only clock it has.

Paused
The scroll position slider is the scroll bar. Take stagger to zero and the sentence inks as one block — the version that reads as a paragraph brightening rather than as writing.

Scroll does not start this animation — scroll is the animation’s clock.

A triggered animation is fire-and-forget. The reader crosses a line, something plays at its own speed, and from that moment the two are strangers: scroll back up and nothing rewinds. A scrubbed timeline binds the playhead to a range of scroll instead, so the reader is not an audience — their hand is on the transport, and the animation can neither get ahead of them nor fall behind.

The statement is one timeline over thirteen words. Each starts at opacity: 0.12 under a 6px blur, ten pixels below where it belongs, and resolves to full contrast, no blur, no offset. Nothing in that list is unusual. What makes it read as handwriting rather than as thirteen fades is the spacing, an empty beat at the end, and the fact that the panel it happens on is not pinned.

Half a beat behind

STAGGER is 0.5 and WORD_DUR is 1, so a word begins inking when the word before it is halfway resolved and two or three are always mid-stroke. That overlap is the effect. Set the stagger equal to the duration and the words queue, arriving one finished block at a time like a slideshow; set it to zero and there is no sequence left at all.

ScrubStatement.tsx
// The signature scrubbed moment: the sentence sits as faint "ghost" type and scroll
// inks it in, word by word. Edit the line freely. (Lenis-safe: a tall track section +
// a sticky inner panel gives the pinned feel without ScrollTrigger's pin.)
const STATEMENT = "Worlds rendered live in the browser. You feel them before you read them.";
const WORDS = STATEMENT.split(" ");

// word i inks over [i*STAGGER, i*STAGGER + WORD_DUR] on the timeline
const STAGGER = 0.5;
const WORD_DUR = 1;
// empty timeline beat appended after the last word — the inked statement holds
// pinned for DWELL/(inking+DWELL) of the scrub track before releasing
const DWELL = 2.5;
Three constants and the sentence itself. The line is meant to be edited — nothing downstream counts the words.

Thirteen words at that spacing make the inking (13 − 1) × 0.5 + 1 = 7 beats long. The ease is none, deliberately: the reader’s hand is already supplying the easing. Any other curve and a word accelerates through a stretch of scroll the wheel was crossing at a constant rate, which reads as the page holding an opinion about when you should be reading.

ScrubDemo.tsx
const WORD_DUR = 1;
// the timeline's own length, in the same abstract beats GSAP counts in
const inking = (WORDS.length - 1) * stagger + WORD_DUR;
const total = inking + dwell;
const time = (reduced ? 1 : progress) * total;

return (
  <>
    <div className="absolute inset-0 flex items-center px-[clamp(20px,5vw,56px)]">
      <p className="font-serif text-[clamp(22px,4.4vw,54px)] leading-[1.08] tracking-[-.02em]">
        {WORDS.map((w, i) => {
          const start = i * stagger;
          const p = Math.max(0, Math.min(1, (time - start) / WORD_DUR));
          const opacity = reduced ? 1 : 0.12 + p * 0.88;
          const blur = reduced ? 0 : (1 - p) * 6;
          const y = reduced ? 0 : (1 - p) * 10;
          return (
            <span
              key={i}
              className="inline-block"
              style={{
                opacity,
                filter: blur ? `blur(${blur.toFixed(2)}px)` : "none",
                transform: y ? `translateY(${y.toFixed(2)}px)` : "none",
              }}
            >
              {w}
              {i < WORDS.length - 1 ? " " : ""}
            </span>
          );
        })}
The demo does the same sum by hand — same 0.12, same 6px, same ten pixels of rise — with the scroll position replaced by a slider.
ConstantDefault
STAGGER0.5the gap between one word starting and the next. At 1 the words queue; at 0 the sentence is a single fade
WORD_DUR1how long one word takes to resolve. Words overlap by WORD_DUR − STAGGER
DWELL2.5the empty beat at the end — 2.5 of the timeline’s 9.5, so roughly a quarter of the track is a hold
scrub0.6seconds the playhead is allowed to take catching up to the scroll position. This lag is what reads as weight
track height245vhthe timeline expressed as distance. 145vh of it is scrubbed, once the sticky panel’s own screen is spent

The beat where nothing happens

tl.to({}, { duration: DWELL }, ">") tweens an empty object for two and a half beats. No target, no property, no visible change — the line exists to occupy time, and it is the most valuable one in the component.

Without it the last word resolves at the end of the timeline, which is the end of the ScrollTrigger’s range, which is the moment the panel begins to leave. The reader never sees the finished sentence standing still; they watch it complete and depart in one gesture. Two and a half beats of nothing is 26% of a 9.5-beat timeline, so about 38vh of scroll passes with the statement fully inked and motionless — a landing rather than an exit.

ScrubStatement.tsx
const ctx = gsap.context(() => {
  const tl = gsap.timeline({
    scrollTrigger: {
      trigger: sectionRef.current,
      start: "top top",
      end: "bottom bottom",
      scrub: 0.6,
    },
  });
  tl.fromTo(
    words,
    { opacity: 0.12, filter: "blur(6px)", y: 10 },
    { opacity: 1, filter: "blur(0px)", y: 0, stagger: STAGGER, ease: "none", duration: WORD_DUR }
  );
  // dwell: an empty beat after the last word resolves, so the fully-inked
  // statement HOLDS centred for ~a third of a screen of scroll before the
  // panel un-pins — a landing, not an instant exit. (The taller track keeps
  // the word-inking pace unchanged; only the hold is added.)
  tl.to({}, { duration: DWELL }, ">");
The whole timeline: one fromTo across every word, then a tween of an empty object that does nothing on purpose.

scrub: 0.6 supplies the rest of the feel. A scrub of true welds the playhead to the scroll position exactly; a number gives it that many seconds to catch up, so the words trail the wheel slightly and settle after you have stopped. Stop halfway and the sentence stays halfway — some words solid, one mid-stroke, the rest still ghosts. That state is worth checking, because a scrubbed timeline that only looks right at 0 and 1 is a triggered animation wearing a costume.

Sticky, not pinned

ScrollTrigger has pin, and this does not use it. Pin earns the held-still feel by taking the element out of flow, fixing it, and inserting a spacer of exactly its height so the document does not collapse — a layout edit performed in the middle of a scroll. The scroll position here belongs to Lenis, eased towards a target at lerp: 0.08 and feeding ScrollTrigger by hand through lenis.on("scroll", ScrollTrigger.update). Rewriting document height underneath that is a fight worth not picking, particularly when CSS already ships the feature.

So the section is a plain tall track at 245vh, and the panel inside it is position: sticky, top: 0, min-h-screen. The panel holds the viewport for the length of the track, and the track — never the panel — is the trigger. start: "top top" to end: "bottom bottom" across 245vh leaves 145vh of real scroll once the panel’s own screen is subtracted, and the timeline’s 9.5 beats are mapped onto it. The beats are ratios; the 245vh is what turns them into distance.

ScrubStatement.tsx
return (
  <section
    ref={sectionRef}
    className="relative z-[1]"
    style={{ height: reduced ? "auto" : "245vh" }}
  >
    <div
      ref={panelRef}
      className="flex min-h-screen items-center px-gutter py-[clamp(60px,12vh,140px)]"
      style={{ position: reduced ? "static" : "sticky", top: 0 }}
    >
      {/* The real sentence for screen readers; the scrubbed copy below is presentation.
          Each visual word renders via ::before/attr() so its faint pre-scroll ghost
          state isn't judged as low-contrast body text — it inks to full contrast on
          scroll (and is fully opaque under reduced motion). */}
      <p className="sr-only">{STATEMENT}</p>
      <p ref={paraRef} aria-hidden className="max-w-[1180px] font-serif text-[clamp(34px,6vw,108px)] font-normal leading-[1.04] tracking-[-.02em]">
        {WORDS.map((w, i) => (
          <span
            key={i}
            ref={(el) => {
              wordRefs.current[i] = el;
            }}
            data-word={w + (i < WORDS.length - 1 ? " " : "")}
            className="inline-block will-change-[opacity,transform,filter] before:content-[attr(data-word)]"
            style={reduced ? undefined : { opacity: 0.12, filter: "blur(6px)", transform: "translateY(10px)" }}
          />
        ))}
      </p>
    </div>
  </section>
);
Track, sticky panel, the sentence for screen readers, and thirteen empty spans.

A sentence with no text in it

The visible words are empty span elements. Each carries its own word in a data-word attribute, and the glyphs arrive through a ::before rule whose content is attr(data-word) — generated content, not a text node. The trailing space is part of the attribute rather than the markup, because inline-block siblings would otherwise close up.

The reason is the ghost state. Twelve per cent opacity under a 6px blur is an automatic contrast failure as real text, and it is the state a checker or a scrape encounters first, because neither of them scrolls. As generated content there is no body copy to judge. The sentence itself lives once, above, in an sr-only paragraph; the visual copy is aria-hidden. Two renderings, one STATEMENT constant, so they cannot drift.

Scroll · GSAP ·
← Back to the lab
Creative Web Designer & Developer25.2048°N · 55.2708°E

AlexanderSmith

Locating the studio25.2048°N · 55.2708°E