Outline Text Filled by Media — the word is hollow until the image fills it
Goal
Build a pinned stage where a giant word sits hollow — stroked, transparent fill — over a photograph, and a scroll-driven wipe uncovers a second copy of the same word filled with that photograph, letter by letter, left to right.
Use it when the name of something has to *be* the image: a season title, a film title, a record sleeve.
Tech
Vanilla HTML/CSS/JS with ES modules: gsap + ScrollTrigger, and lenis.
Wire Lenis to ScrollTrigger — this is not optional:
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((t) => lenis.raf(t * 1000));
gsap.ticker.lagSmoothing(0);
Lenis animates its own scroll position; ScrollTrigger reads the native one. Without that first line the two clocks drift and the triggers fire at the wrong moment or not at all — the component looks broken in a way that never reaches the console. lagSmoothing(0) stops GSAP from swallowing a long frame, which on a scrubbed mechanic shows up as a jump.
The mechanic in one line
Two copies of the same word, pixel-aligned, one stroked and hollow and one filled via background-clip: text; a clip-path wipe uncovers the filled copy.
<div class="word" aria-label="Undertow">
<span class="hollow" aria-hidden="true">Undertow</span>
<span class="filled" aria-hidden="true">Undertow</span>
</div>
The four decisions that ARE this component
1. Two copies, not one element that changes.
-webkit-text-stroke with a transparent fill and a background clipped to text are different paint paths. Any crossfade between them flickers on the sub-pixel edge of every letter. Two stacked copies with a clip-path wipe are exact, cheap and perfectly reversible.
2. The alignment is the whole job.
.word { display: grid; }
.word span { grid-area: 1 / 1; /* identical everything else */ }
Both copies share one grid cell and identical typography. Everything that affects where a glyph lands — family, size, weight, line-height, letter-spacing, padding, white-space — must be identical in the two rules. Drift them by a single pixel and the outline shows as a ghost beside the fill: the effect is dead and it looks like a rendering bug.
What the filled copy *does* add is only paint, never metrics: background-image, background-size, background-position, background-clip: text, and the clip-path wipe. If you find yourself adding anything to the filled rule that is not in that list, it will move the text.
3. The fill image comes from the background's own src, set in JS.
filled.style.setProperty("--fill", `url("${bg.getAttribute("src")}")`);
Two reasons. The letters end up in register with what is behind them, so the word reads as a window cut into the photograph rather than as a word containing a different picture. And there is one place to change the photo instead of two.
4. The wipe finishes early — at 78% of the track.
const w = gsap.utils.clamp(0, 1, p / 0.78);
If it completes exactly as the section leaves, the reader never sees the finished word. The payoff has to land while the sentence is still on screen, and then hold. The last fifth of the track is that hold.
The light comes up with it
bg.style.opacity = 0.42 + w * 0.3;
Brightening the background in step with the wipe is what makes it read as light arriving rather than as a mask sliding across. It is one line and it changes the whole register of the effect.
Details
padding-block: 0.08emon the word: atclamp(3.2rem, 15vw, 13rem)the glyphs overflow their
box and clip against the ascenders.
aria-labelon the wrapper,aria-hiddenon both copies — otherwise the word is announced
twice.
-webkit-text-strokehas no universal standard equivalent. Where it is unsupported the hollow
copy is simply invisible and the filled one carries the section: degrade, not break.
Reduced motion
Show the word already filled (clip-path: none) and collapse the track. The image inside the letters is the point and it survives; only the wipe goes.
Adapting
Change the word (one word, up to nine or ten characters — the wipe needs width to read), the photograph, the palette, the type. A heavy serif or a fat grotesk both work; a light weight does not, because there is not enough counter area for the image to show. Keep: two copies, one grid cell, the fill sourced from the background, the early finish, and the background brightening in step.