Skip to content
09 / 10Scroll7 min read

Scrub

The statement on the home page is not triggered by scroll. It's 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, which is 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 isn't 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's no sequence left at all. Try both on the demo, it takes ten seconds and you'll never forget the difference.

ScrubStatement.tsx
// Scroll inks the sentence in, word by word. Edit the line freely,
// nothing downstream counts the words.
const STATEMENT = "Worlds rendered live in the browser. You feel them before you read them.";
const WORDS = STATEMENT.split(" ");

const STAGGER = 0.5; // beats between one word starting and the next
const WORD_DUR = 1; // beats one word takes to resolve
const DWELL = 2.5; // beats the finished sentence holds before the panel unpins
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, and that's deliberate: the reader's hand is already supplying the easing. Put a curve on top and a word accelerates through a stretch of scroll the wheel was crossing at constant speed, which reads as the page holding an opinion about when you should be reading.

ScrubDemo.tsx
const STATEMENT = "Worlds rendered live in the browser. You feel them before you read them.";
const WORDS = STATEMENT.split(" ");
/** how long one word takes to resolve, in the timeline's own beats */
const WORD_DUR = 1;

// The homepage sum, by hand: a time on the playhead in, thirteen word states
// out. Nothing is remembered, so the same time always gives the same sentence.
function Sentence({ time, stagger }: { time: number; stagger: number }) {
  return (
    <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 = 0.12 + p * 0.88;
        const blur = (1 - p) * 6;
        const y = (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>
        );
      })}
    </p>
  );
}
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 may 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's the most valuable one in the component.

Here's why. 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 starts 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 instead of an exit, bought with a tween of nothing.

ScrubStatement.tsx
// all fifteen words for the life of the page. The provider's own comment is
// explicit that hundreds of resident layers cost real memory, and it
// releases its own; this component was the one holding them permanently.
words.forEach((w) => (w.style.willChange = "opacity, transform, filter"));
const release = () => words.forEach((w) => (w.style.willChange = "auto"));

const ctx = gsap.context(() => {
  const tl = gsap.timeline({
    scrollTrigger: {
      trigger: sectionRef.current,
      start: "top top",
      end: "bottom bottom",
      scrub: 0.6,
    },
  });

  tl.fromTo(
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've stopped. Stop halfway and the sentence stays halfway: some words solid, one mid-stroke, the rest still ghosts. That state is worth checking on any scrubbed build, because a timeline that only looks right at 0 and 1 is a triggered animation wearing a costume.

Sticky, not pinned

ScrollTrigger has pin, and this doesn't 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 doesn't collapse. A layout edit performed in the middle of a scroll. The scroll position here belongs to Lenis, eased toward 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 I chose not to pick, 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 map onto that. The beats are ratios. The 245vh is what turns them into distance.

ScrubStatement.tsx
        trigger: sectionRef.current,
        start: () => `bottom ${TOP_FADE.full + rise()}px`,
        end: () => `bottom ${TOP_FADE.gone + rise()}px`,
        onUpdate: (self) => setFade(self.progress),
        onRefresh: (self) => setFade(self.progress),
      });
    }
  }, sectionRef);
  return () => {
    release();
    ctx.revert();
  };
}, [reduced]);

return (
  <section
    ref={sectionRef}
    // the Lab's scrub article deep-links here
    id="scrub"
    className="relative z-[1]"
    style={{ height: reduced ? "auto" : "245svh" }}
  >
    <div
      ref={panelRef}
      className="flex min-h-[100svh] items-center px-gutter py-[clamp(60px,12svh,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) => (
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 lives inside the attribute rather than the markup, because inline-block siblings would otherwise close up against each other.

Why go to that trouble? The ghost state. Twelve per cent opacity under a 6px blur is an automatic contrast failure as real text, and it's the state a checker or a scraper meets first, because neither of them scrolls. As generated content there's no body copy to judge. The real sentence lives once, above, in an sr-only paragraph, and the visual copy is aria-hidden. Two renderings, one STATEMENT constant, so they can't drift.

P.S. This one is running on the site right now.

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

AlexanderSmith

Locating the studio25.2048°N · 55.2708°E