Scrubbed Character Colour Reveal
A paragraph sits at 16% strength and resolves character by character as it crosses the viewport, with the reveal position pinned to the scrollbar rather than to a clock. Scroll back up and the characters ghost out again in reverse, because a scrubbed timeline is a function of position, not a playback. The stagger and durations are ratios of the scroll range rather than seconds, so the pacing per character is unchanged whether the paragraph is thirty words or three hundred.
Scrubbed Character Colour Reveal
Live preview
The real component, running in this page.
Install
Copies the source into your project. Nothing is imported from us at runtime.
npx shadcn@latest add https://kinetic-seven-taupe.vercel.app/r/scrub-char-color-reveal.jsonPrompt
The whole technique, written for an agent.
Build it with your agent
A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.
Source
2 files, exactly as installed.
API
Every parameter, its type and what happens when you change it.
| Parameter | Type | Default | Description |
|---|---|---|---|
| text | string | — | Body copy. Split to words then characters in JSX. |
| dim | number | = 0.16 | Resting strength of an unrevealed character, 0-1. |
| stagger | number | = 0.1 | Timeline units between adjacent characters. |
| charDuration | number | = 0.35 | Timeline units for one character to resolve. |
| scrub | number | = 0.75 | Seconds of catch-up between the scrollbar and the reveal. |
| start | string | = 'top 78%' | ScrollTrigger start. |
| end | string | = 'bottom 55%' | ScrollTrigger end. |
| scroller | Element | null | = null | Scroll container, when the text is not in the document scroller. |
| className | string | — | Merged onto the paragraph via cn. |
How it works
The technique in order, not the code in order.
The text is split in JSX —
text.split(" ")for word boxes,Array.from(word)for oneinline-blockspan per character — so React owns every node and nothing mutates the tree behind its back.One
gsap.to(chars, { opacity: 1, stagger, duration, ease: "power1.out", scrollTrigger: {...} })does the whole reveal. GSAP builds the stagger internally as a single tween whose total length isduration + (n - 1) * stagger.ScrollTrigger maps that total length onto the distance between
startandend, which is whystagger: 0.1andduration: 0.35are ratios, not seconds: each character begins 0.1/total of the way through the scroll range and takes 0.35/total to resolve. Change the paragraph length and the pacing per character is identical; change the scroll range and everything rescales together.The resting opacity is written as a JSX inline style on each character, so the pre-scroll frame — including the server render and the no-JS render — is already the dim state, and GSAP has a real DOM value to tween away from.
The paragraph carries
aria-label={text}and the character spans are inside anaria-hiddenwrapper, so assistive technology reads one paragraph rather than two hundred single letters.ease: "power1.out"applies *within* each character's own resolve, not across the stagger. The stagger itself remains linear against scroll, which is what keeps the sweep pinned to the scrollbar.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| DIM | = 0.16 | Resting strength as opacity on currentColor. Below ~0.1 the unread text is illegible and the block reads as a loading skeleton; above ~0.3 the contrast between resolved and unresolved is too weak for the sweep to be visible while scrolling. |
| STAGGER | = 0.1 | Timeline units between adjacent characters. Combined with CHAR_DURATION it sets the width of the resolve front: roughly 3-4 characters are mid-transition at any moment. Larger and the front narrows to one character at a time, which reads as a typewriter rather than a wash. |
| CHAR_DURATION | = 0.35 | 3.5x the stagger, so several characters are always resolving together. Equal to the stagger it becomes a hard edge sweeping the paragraph; much larger and the front is so soft the reveal has no direction. |
| SCRUB | = 0.75 | Seconds of catch-up. scrub: true welds the reveal to the scrollbar exactly, which on a trackpad exposes every jitter in the input. Three quarters of a second smooths it without ever feeling like the text is animating on its own. |
| START / END | = 'top 78%' / 'bottom 55%' | The reveal begins when the paragraph top is a little above the fold and completes when its bottom passes just past the middle. Ending at bottom top means the last characters only resolve as the paragraph leaves the screen, so the reader never sees the finished text. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
The resting opacity collapses to 1 in the JSX, so the paragraph renders fully resolved, and the effect returns before registering ScrollTrigger. The text is never left dim waiting for a scroll that will not drive it.
ctx.revert() on the gsap.context scoped to the paragraph. That kills the staggered tween, disposes the ScrollTrigger instance so it stops measuring a detached node, and strips the inline opacity GSAP wrote — leaving React's own inline dim value intact.
Scroll the paragraph up through the viewport: characters brighten in a soft front sweeping left to right and top to bottom, and the front position tracks the scrollbar rather than a clock. Scroll back up and it reverses. Turn on a screen reader and the paragraph is announced once as whole sentences, not letter by letter. With reduced motion enabled the paragraph is at full strength on first paint.
Pitfalls
- Splitting text before the webfont has loaded. Character boxes are measured against the fallback face, so widths and line breaks are wrong and never re-flow when the real font arrives — see
font-safe-text-splitfor thedocument.fonts.readygate and the debounced re-split this needs at scale. - Treating
staggeranddurationas seconds. Underscrubthey are proportions of the scroll range; a value tuned as "0.35 seconds" behaves completely differently the moment the trigger distance changes. - Building a separate tween per character. That is one ScrollTrigger per character on a paragraph of two hundred, and refresh cost becomes visible on resize. A single staggered tween is one trigger.
- Leaving the character spans exposed to assistive technology. A screen reader announces every letter individually —
aria-labelon the paragraph plusaria-hiddenon the spans is not optional here. - Setting the resting opacity from
gsap.setin the effect. The first paint then shows the paragraph at full strength before dimming it, which is a visible flash on every load. - Splitting straight to characters with no word boxes. Line breaks land inside words and the paragraph becomes unreadable at narrow widths.
- Forgetting
scrollerwhen the paragraph lives in anoverflow-y-autopanel. ScrollTrigger measures the window instead and the reveal fires at the wrong scroll position or not at all.
Notes
- The split is declarative JSX, deliberately: GSAP
SplitTextis reserved for the two components whose subject *is* SplitText (nested-splittext-restore,font-safe-text-split). Here a React split gives the same per-character targets with no DOM mutation and no extra dependency for the consumer. - Opacity on
currentColorrather than a literal colour tween, so the component is correct in any theme without a per-context variant.