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.