Spotlight Stroke Draw — Pinned Scroll Section Where Thick Diagonal SVG Strokes Paint Over the Screen and Un-paint
Goal
Build a three-section scroll page whose star effect is a pinned "spotlight" section where 13 thick, parallel, diagonal SVG strokes draw themselves on — one after another in a scattered, staggered order — until they completely blanket the viewport, then, at the moment of full cover, the centered headline swaps underneath ("WAIT FOR IT" → "THERE IT IS") and three cartoon sparkles pop, after which the same strokes un-draw in reverse, wiping themselves off the far end to reveal the new message. The whole sequence is one GSAP timeline scrubbed to a pinned ScrollTrigger (4 viewport-heights of scroll), with Lenis smooth scrolling. The strokes use the classic strokeDasharray/strokeDashoffset line-drawing trick, and each stroke is doubled into a dark outline + yellow fill for a comic-ink look.
Tech
Vanilla HTML/CSS/JS with ES module imports, in a fresh Vite project. Install and import from npm:
gsap(3.x) plus the pluginScrollTrigger.lenis— smooth scroll, wired into GSAP's ticker andScrollTrigger.update.
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
gsap.registerPlugin(ScrollTrigger);
Run everything inside document.addEventListener("DOMContentLoaded", …). No other plugins, no framework. No SplitText, no CustomEase, no lerp/rAF interpolation, no Three.js.
Layout / HTML
Three top-level <section>s: an intro hero, the spotlight (the pinned stage), and an outro hero. Inside the spotlight, in this DOM order: two overlapping message blocks, then the strokes SVG, then the sparkles SVG. Paint order matters — the strokes SVG sits *after* (on top of) both message blocks so the drawn strokes cover the text, and the sparkles SVG is last (on top of the strokes). Class names are load-bearing.
<section class="intro">
<h1>Blink and you'll miss it</h1>
</section>
<section class="spotlight">
<div class="spotlight-content spotlight-content-in">
<h2>Wait for it</h2>
<p>
The good part is closer, just one scroll away. Don't blink, or you'll
miss the moment it turns.
</p>
</div>
<div class="spotlight-content spotlight-content-out">
<h2>There it is</h2>
<p>
That's the kind of payoff worth holding out for. Clean, sharp, and
right when you needed it.
</p>
</div>
<div class="strokes">
<svg viewBox="0 0 1600 900" preserveAspectRatio="xMidYMid slice" xmlns="http://www.w3.org/2000/svg">
<g fill="none" stroke="#FFF280" stroke-linecap="round">
<path class="stroke" d="M -251 -42 C 156 -405 595 -695 1176 -648" stroke-width="150" />
<path class="stroke" d="M -195 90 C 212 -273 651 -562 1232 -516" stroke-width="150" />
<path class="stroke" d="M -138 223 C 269 -140 707 -430 1288 -383" stroke-width="150" />
<path class="stroke" d="M -82 355 C 325 -8 764 -297 1345 -250" stroke-width="150" />
<path class="stroke" d="M -26 488 C 381 125 820 -165 1401 -118" stroke-width="150" />
<path class="stroke" d="M 30 620 C 438 257 876 -32 1457 15" stroke-width="150" />
<path class="stroke" d="M 87 753 C 494 390 932 101 1513 147" stroke-width="150" />
<path class="stroke" d="M 143 885 C 550 522 989 233 1570 280" stroke-width="150" />
<path class="stroke" d="M 199 1018 C 606 655 1045 366 1626 412" stroke-width="150" />
<path class="stroke" d="M 255 1150 C 663 788 1101 498 1682 545" stroke-width="150" />
<path class="stroke" d="M 312 1283 C 719 920 1157 631 1738 677" stroke-width="150" />
<path class="stroke" d="M 368 1416 C 775 1053 1214 763 1795 810" stroke-width="150" />
<path class="stroke" d="M 424 1548 C 831 1185 1270 896 1851 942" stroke-width="150" />
</g>
</svg>
</div>
<svg class="sparkles" viewBox="0 0 1600 900" preserveAspectRatio="xMidYMid slice" xmlns="http://www.w3.org/2000/svg">
<g fill="#fff" stroke="#141414" stroke-width="4" stroke-linejoin="round">
<g transform="translate(360 230) scale(1.5)">
<path class="sparkle" vector-effect="non-scaling-stroke" d="M 0 -55 C 8 -16 16 -8 55 0 C 16 8 8 16 0 55 C -8 16 -16 8 -55 0 C -16 -8 -8 -16 0 -55 Z" />
</g>
<g transform="translate(1180 520) scale(1)">
<path class="sparkle" vector-effect="non-scaling-stroke" d="M 0 -55 C 8 -16 16 -8 55 0 C 16 8 8 16 0 55 C -8 16 -16 8 -55 0 C -16 -8 -8 -16 0 -55 Z" />
</g>
<g transform="translate(640 730) scale(0.65)">
<path class="sparkle" vector-effect="non-scaling-stroke" d="M 0 -55 C 8 -16 16 -8 55 0 C 16 8 8 16 0 55 C -8 16 -16 8 -55 0 C -16 -8 -8 -16 0 -55 Z" />
</g>
</g>
</svg>
</section>
<section class="outro">
<h1>Told you it was worth it</h1>
</section>
<script type="module" src="./script.js"></script>
Notes on structure:
- Keep the 13 stroke
<path>dstrings andstroke-width="150"exactly as above. They are 13 near-parallel, gently-curved cubic beziers marching top-to-bottom (each start-pointysteps down ~132 units), each 150 units thick in the1600×900viewBox — since 150 > the 132 vertical spacing, the bands overlap and fully tile the viewport when all are drawn.preserveAspectRatio="xMidYMid slice"makes the SVG cover-fill the container.stroke-linecap="round"gives rounded ends. - The stroke group is
fill="none" stroke="#FFF280"(pale yellow) — the JS will clone each path into a dark outline layer at runtime (see effect). - Keep the 3 sparkle
<path>dand their wrapping<g transform>s exactly — three four-pointed "twinkle" shapes at(360,230) scale(1.5),(1180,520) scale(1),(640,730) scale(0.65), white fill with a#1414144pxnon-scaling-strokeoutline. Each.sparklestarts attransform: scale(0)(hidden) via CSS. - Copy is neutral demo text — use it verbatim, no brands.
Styling
Fonts (Google Fonts): Barlow Condensed (all weights 100–900, italics too; used for the big uppercase headings) and DM Sans (variable optical-size + weight; used for the spotlight paragraph).
@import url("https://fonts.googleapis.com/css2?family=Barlow+Condensed:ital,wght@0,100..900;1,100..900&family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap");
Global reset: * { margin:0; padding:0; box-sizing:border-box; }. body { font-family:"Barlow Condensed", sans-serif; }.
Sections — each is a full-screen panel with the same pink→yellow gradient:
section {
position: relative;
width: 100%;
height: 100svh;
background: linear-gradient(90deg, #ff668c 0%, #fff280 100%); /* hot-pink → pale-yellow */
color: #141414; /* near-black text */
overflow: hidden;
}
Intro / outro heroes — one big centered uppercase headline:
.intro, .outro {
display: flex; align-items: center; justify-content: center;
text-align: center; padding: 2rem;
}
.intro h1, .outro h1 {
text-transform: uppercase;
font-weight: 900;
font-size: clamp(2.5rem, 7vw, 6rem);
letter-spacing: -2%;
line-height: 0.8;
max-width: 15ch;
}
Spotlight messages — two absolutely-stacked, centered blocks filling the section:
.spotlight-content {
position: absolute; inset: 0;
display: flex; flex-direction: column;
justify-content: center; align-items: center;
text-align: center; padding: 0 8vw;
}
.spotlight-content h2 {
text-transform: uppercase; font-weight: 900;
font-size: clamp(2.2rem, 5.5vw, 4.5rem);
letter-spacing: -2%; line-height: 0.9; max-width: 14ch;
}
.spotlight-content p {
margin-top: 1.5rem;
font-family: "DM Sans", sans-serif;
font-size: clamp(1.1rem, 1.8vw, 1.6rem);
font-weight: 400; line-height: 1.25; max-width: 25ch;
}
.spotlight-content-out { opacity: 0; } /* second message starts hidden */
Overlay layers:
.strokes {
position: absolute; inset: -10%;
width: 120%; height: 120%; /* over-sized so bands overshoot the edges */
pointer-events: none;
}
.strokes svg { width: 100%; height: 100%; overflow: visible; }
.stroke { will-change: stroke-dashoffset; }
.sparkles {
position: absolute; inset: 0;
width: 100%; height: 100%;
overflow: visible; pointer-events: none;
}
.sparkle { will-change: transform; transform: scale(0); }
Responsive: @media (max-width:1000px) { .spotlight-content { padding: 0 6vw; } }.
GSAP effect (the important part — be exhaustive)
Module-level constants (use these exact values):
const STROKE_STAGGER = 0.045; // base per-step stagger (timeline seconds)
const STROKE_DRAW_TIME = 1.25; // base per-stroke draw duration
const OUTLINE_WIDTH = 7; // dark outline is 7 units wider than the yellow fill
const SPOTLIGHT_PIN_HEIGHT = 4; // pin lasts 4 viewport-heights of scroll
const STROKE_DRAW_ORDER = [0, 12, 2, 10, 4, 8, 6, 1, 3, 5, 7, 9, 11]; // scattered reveal order
1. Lenis + GSAP ticker wiring
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000)); // ticker seconds → Lenis ms
gsap.ticker.lagSmoothing(0);
2. Double each stroke into outline + fill (the comic-ink trick)
For every .stroke path, clone it into a dark outline painted *behind* a slightly-narrower yellow fill, then prime both for line-drawing. Build an array of { layers:[outline, fill], length }:
const strokes = Array.from(document.querySelectorAll(".stroke")).map((fill) => {
const fillWidth = parseFloat(fill.getAttribute("stroke-width")); // 150
const outline = fill.cloneNode(true);
outline.setAttribute("stroke", "#141414"); // dark outline, still width 150
fill.setAttribute("stroke-width", fillWidth - OUTLINE_WIDTH); // yellow fill narrowed to 143
fill.before(outline); // outline inserted BEFORE → paints underneath
const length = fill.getTotalLength();
const layers = [outline, fill];
layers.forEach((layer) => {
layer.style.strokeDasharray = length;
layer.style.strokeDashoffset = length; // one dash the full length, pushed off the end → invisible
});
return { layers, length };
});
Result per stroke: a #141414 outline (150 wide) under a #FFF280 fill (143 wide) → a ~3.5px dark border rims each yellow band. Both layers animate together (they share the layers array). All strokes start fully hidden.
const sparkles = gsap.utils.toArray(".sparkle");
const messageBefore = document.querySelector(".spotlight-content-in");
const messageAfter = document.querySelector(".spotlight-content-out");
3. Per-stroke timing helpers
order is the position within STROKE_DRAW_ORDER (0…12), strokeIndex is which of the 13 paths it draws.
const startTime = (order) => order * STROKE_STAGGER; // 0, 0.045, 0.09, …
const timingWobble = (order) => (order % 2 === 0 ? 0 : STROKE_STAGGER * 0.6); // odd orders get +0.027 jitter
const drawDuration = (order) => STROKE_DRAW_TIME + (order % 3) * 0.12; // 1.25 / 1.37 / 1.49 cycling
4. The scrubbed, pinned ScrollTrigger
One master timeline (no repeat), driven entirely by a pinned ScrollTrigger:
const timeline = gsap.timeline();
ScrollTrigger.create({
trigger: ".spotlight",
start: "top top",
end: () => `+=${window.innerHeight * SPOTLIGHT_PIN_HEIGHT}px`, // pins for 4 viewport-heights
pin: true,
pinSpacing: true,
scrub: 1, // 1s catch-up smoothing between scroll and timeline
animation: timeline,
});
5. Draw-in phase (strokes paint over the screen)
Precompute each step's start-time and duration, and the moment everything is covered:
const drawSteps = STROKE_DRAW_ORDER.map((strokeIndex, order) => ({
strokeIndex,
at: startTime(order) + timingWobble(order),
duration: drawDuration(order),
}));
const coveredAt = Math.max(...drawSteps.map((s) => s.at + s.duration)); // ≈ 2.012
drawSteps.forEach(({ strokeIndex, at, duration }) => {
timeline.to(
strokes[strokeIndex].layers,
{ strokeDashoffset: 0, duration, ease: "power2.out" }, // length → 0: band draws itself on
at, // absolute position on the timeline
);
});
Each stroke's strokeDashoffset tweens length → 0 on power2.out, so the yellow-with-dark-border band snakes into view. Because at = order*0.045 (+0.027 on odd orders) and the draw order is the scattered [0,12,2,10,4,8,6,1,3,5,7,9,11], the bands appear in a shuffled, overlapping cascade rather than plain top-to-bottom — by coveredAt ≈ 2.012s the 13 overlapping bands fully tile the viewport, hiding the message underneath.
Concrete first few steps (for reference): order 0 → stroke 0 at 0.000 dur 1.25; order 1 → stroke 12 at 0.072 dur 1.37; order 2 → stroke 2 at 0.090 dur 1.49; order 3 → stroke 10 at 0.162 dur 1.25; … order 11 → stroke 9 at 0.522 dur 1.49 (this is the last-to-finish → sets coveredAt).
6. The message swap (instant, hidden under full cover)
At coveredAt, snap the visible message out and the second one in — invisible because the strokes fully blanket the screen at that instant:
timeline.set(messageBefore, { opacity: 0 }, coveredAt);
timeline.set(messageAfter, { opacity: 1 }, coveredAt);
7. Draw-out phase (strokes wipe off the far end, reverse order)
Reverse the draw order and continue each dashoffset past zero into negative — the drawn band slides off in its own drawing direction (it does not rewind), uncovering the new message. Positions are relative to coveredAt, reusing the same startTime/timingWobble/drawDuration cadence:
[...STROKE_DRAW_ORDER].reverse().forEach((strokeIndex, order) => {
const { layers, length } = strokes[strokeIndex];
timeline.to(
layers,
{ strokeDashoffset: -length, duration: drawDuration(order), ease: "power2.in" },
coveredAt + startTime(order) + timingWobble(order),
);
});
Note the ease flips to power2.in for the exit (vs power2.out on the entrance). Total timeline runs ~4s of "content time," stretched across the 4-viewport pinned scroll by the scrub.
8. Sparkle pops (three twinkles around the reveal moment)
Each sparkle scales up with a back-overshoot while rotating, then scales back to 0, staggered 0.25s apart, centered just before coveredAt:
sparkles.forEach((sparkle, index) => {
const popAt = coveredAt - 0.4 + index * 0.25; // ≈ 1.612, 1.862, 2.112
timeline
.fromTo(
sparkle,
{ scale: 0, rotate: -60, transformOrigin: "center" },
{ scale: 1, rotate: 60, duration: 0.5, ease: "back.out(2)" },
popAt,
)
.to(
sparkle,
{ scale: 0, rotate: 140, duration: 0.5, ease: "back.in(2)" },
popAt + 0.6, // start shrinking 0.6s after popping in
);
});
Pop-in: scale 0→1, rotate -60°→60°, back.out(2). Pop-out: scale 1→0, rotate 60°→140°, back.in(2). The three sparkles flash white right as the strokes finish covering and the headline turns over.
Assets / images
None. The entire visual is CSS gradient + type + two inline SVGs (the 13 strokes and the 3 sparkles). Do not add image files.
Behavior notes
- Trigger: scroll only, fully scrubbed inside a pinned section. Nothing autoplays; parking the scroll freezes the strokes mid-draw, and reversing scroll un-covers the screen and reverts the message swap.
- Fresh load (scroll=0): intro hero visible; the spotlight (once reached) shows "WAIT FOR IT" with all strokes hidden and all sparkles at
scale(0); the "THERE IT IS" block isopacity:0. - Layering is essential: strokes SVG paints over both messages; sparkles SVG paints over the strokes; both overlays are
pointer-events:none.overflow:visibleon the SVGs plus the120%over-sized.strokescontainer let the thick bands overshoot the edges with no gaps. - The
enduses a function (() => …) so the pin length re-resolves on refresh/resize against the currentwindow.innerHeight. - No reduced-motion guard in the original.
Using this outside its demo page
This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.
- Palette on
:root—--ink,--rose,--coral,--gold,--cream. These names are not namespaced and they collide:--inkis defined by 164 of the 219 components in this catalogue,--paperby 94,--mutedby 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix. - **Rules on
*,body** — the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it. - Smooth scroll (Lenis) — this creates its own Lenis instance, and a page may only have one. If your project already runs Lenis, drop the setup shown above and reuse the existing instance, keeping the
lenis.on("scroll", ScrollTrigger.update)wiring once. Two instances fight over the same scroll and stutter visibly, with no error in the console.
Adapting this to React
Everything above describes a standalone document: a DOMContentLoaded listener that wires a Lenis instance into gsap.ticker, doubles each of the 13 .stroke paths into a dark outline layer by cloning it, and hands one un-repeated gsap.timeline() to a single pinned, scrubbed ScrollTrigger on .spotlight. It runs once, on a page that never unmounts, so nothing here was ever written to be undone. React withdraws that guarantee, and this component is worse-positioned than most to survive the loss, because half its setup is not a GSAP call at all — it is cloneNode, insertBefore and raw attribute writes that gsap.context never records.
Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. Run this setup twice without tearing the first pass down and three things go wrong at once: two Lenis instances both pump raf off the same gsap.ticker; two pins land on .spotlight, each inserting its own spacer and doubling the section's scrollable length, so the four-viewport pin no longer corresponds to the scroll distance SPOTLIGHT_PIN_HEIGHT was meant to reserve; and the .stroke tree ends up cloned on top of itself — the second pass's document.querySelectorAll(".stroke") matches not just the 13 original paths but the 13 outline clones the first pass already inserted, because cloneNode(true) copies the class="stroke" attribute along with everything else. The second pass then clones those clones, shrinks their stroke-width a second time from an already-shrunk value, and builds a strokes array with duplicate, mis-sized entries feeding competing tweens. None of this throws. The bands just come out the wrong width and color, drawn over each other, and it will not reproduce in a production build, because React only double-invokes effects in development.
*(1) The entry point* — delete the document.addEventListener("DOMContentLoaded", ...) wrapper; by the time a React component mounts, that event has already fired, so the listener would simply never run. Move its body into a useEffect with an empty dependency array. gsap.registerPlugin(ScrollTrigger) and the module-level constants (STROKE_STAGGER, STROKE_DRAW_TIME, OUTLINE_WIDTH, SPOTLIGHT_PIN_HEIGHT, STROKE_DRAW_ORDER) are one-time, page-wide configuration and pure data — leave them where they are, at module scope outside the component, instead of re-declaring the order array on every mount.
*(2) Element lookups* — scope .spotlight, .stroke, .sparkle, .spotlight-content-in and .spotlight-content-out to a root ref on the wrapper that contains all three sections; gsap.context's selector text resolves against that ref, so .intro and .outro don't strictly need to be inside it, but .spotlight and everything nested under it do. document.querySelectorAll(".stroke") is the one lookup here that needs more than scoping — it needs to be idempotent, or the double-clone failure mode above repeats on every remount regardless of how carefully the rest of the effect is scoped. Capture the 13 original fill paths once, before any outline exists, and don't re-query for them later; the cleanup described next is what keeps the next mount's query from finding leftovers.
*(3) Cleanup* — wrap the outline-cloning pass, the timeline, and the ScrollTrigger.create call in one gsap.context scoped to the root ref:
useEffect(() => {
const ctx = gsap.context(() => {
// Lenis + ticker wiring, the outline-cloning pass, the timeline, ScrollTrigger.create
}, rootRef);
return () => {
gsap.ticker.remove(onTick);
lenis.destroy();
outlines.forEach((node) => node.remove());
fills.forEach((fill, i) => fill.setAttribute("stroke-width", originalWidths[i]));
ctx.revert();
};
}, []);
ctx.revert() kills the pinned ScrollTrigger — removing its spacer and restoring .spotlight to unpinned layout — and reverts every strokeDashoffset tween on every layer back to the value it held when the timeline was built. What it does not reach is threefold. First, gsap.ticker.add((time) => lenis.raf(time * 1000)): a ticker subscription is neither a tween nor a trigger, and it is the only thing driving Lenis here (this script has no requestAnimationFrame loop of its own), so it needs an explicit gsap.ticker.remove against the same function reference. Second, the Lenis instance itself needs destroy() — its lenis.on("scroll", ScrollTrigger.update) subscription goes with it, no separate teardown required. Third, the outline-cloning pass is plain DOM mutation: cloneNode, fill.before(outline), the stroke/stroke-width attribute writes, and the direct layer.style.strokeDasharray = length / strokeDashoffset = length assignments that prime the line-drawing trick before any tween touches the layers. None of that goes through gsap.set or a tween, so gsap.context never saw it and ctx.revert() walks past it untouched. Keep the array of inserted outline nodes and the original stroke-width values around specifically so the cleanup can remove the clones and put the 13 fills back to their un-narrowed width — that is what keeps the next mount's querySelectorAll(".stroke") seeing exactly 13 plain paths instead of an accumulating clone tree.
This component owns its Lenis instance and is written as a complete page — correct as long as it stays one. Folded into a larger app that already runs smooth scroll, lift new Lenis() to the app shell and have this effect subscribe to the existing instance instead of fighting it for the same wheel event; two instances driven off the same gsap.ticker will otherwise both call raf on every tick, and the scrub on .spotlight will visibly stutter against ScrollTrigger.update firing twice per scroll event.