SVG Scribble Draw-On Image Hover — Self-Drawing Strokes Over a Card Grid
Goal
Build a page with a 3-row × 2-column grid of square image cards. Each card is overlaid by two oversized, scribbled SVG paths (a colored one + a light-grey one) that are invisible at rest. On mouseenter, both scribbles draw themselves on (classic strokeDasharray/strokeDashoffset line-drawing trick, offset animated from full path length → 0) while simultaneously swelling their stroke-width from 200 → 700, and the card's title rises in word-by-word from behind a mask. On mouseleave everything reverses: the scribbles un-draw and thin back to 200, and the title drops back down word-by-word (reversed order). Smooth scroll via Lenis. The star effect is the pair of thick scribbles drawing on over the photo plus the masked word-rise on the title.
Tech
Vanilla HTML/CSS/JS with ES module imports, in a fresh Vite + npm project. Install and import from npm:
gsap(3.x) plus the pluginSplitText(used for the title word-mask reveal).lenis— smooth scroll, stepped by its ownrequestAnimationFrameloop.
import gsap from "gsap";
import { SplitText } from "gsap/SplitText";
import Lenis from "lenis";
gsap.registerPlugin(SplitText);
No ScrollTrigger, no CustomEase, no Three.js. Ship exactly three files: one index.html, one styles.css, one ES-module script.js.
> ### ⚠️ MANDATORY: link the stylesheet, or the effect is invisible > index.html must be a complete HTML document (see the full skeleton below) with <link rel="stylesheet" href="./styles.css"> inside <head> and <script type="module" src="./script.js"></script> before </body>. Do not emit index.html as a loose fragment that starts at <header> — it must begin with <!doctype html>. > > The entire star effect lives in the CSS. If styles.css is not linked, nothing works: the grid collapses to a single column, the cards are no longer square or clipped, and — worst of all — the two SVG scribbles render at their native size (~2500px each), stacked in normal document flow far below the photo, so the draw-on stroke animation is never seen over the image. The GSAP timeline still runs with zero console errors, but the result is a broken ~38000px-tall page of giant loose scribbles. Three CSS rules in particular carry the effect and must be present: > ``css > .card-container { overflow: hidden; } /* clips the oversized scribbles + masked words to the card box */ > .card-container .svg-stroke { /* centers each scribble and blows it up 1.5× over the photo */ > position: absolute; top: 50%; left: 50%; > transform: translate(-50%, -50%) scale(1.5); > width: 100%; height: 100%; > } > img, svg { width: 100%; height: 100%; object-fit: cover; } /* photo AND scribble fill the square card */ > `` > Treat linking the stylesheet as a hard requirement of a correct reproduction.
Layout / HTML
Emit a complete HTML document. Below is the exact skeleton — <!doctype html>, <head> with charset/viewport/stylesheet link, <body> holding a centered <header> (big <h1>), then three .rows (each with two .card-containers), then a <footer> (big <h1>), and finally the module script. Class/ID names are load-bearing (the JS queries .card-container, .svg-stroke path, .card-title h3; the CSS colors strokes by #card-N).
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SVG Image Hover Effect</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<header><h1>The Hover State</h1></header>
<div class="row">
<div class="card-container" id="card-1">
<div class="card-img"><img src="<card 1 image>" alt="" /></div>
<div class="svg-stroke svg-stroke-1"><svg …>…path 1…</svg></div>
<div class="svg-stroke svg-stroke-2"><svg …>…path 2…</svg></div>
<div class="card-title"><h3>Synthetic Silhouette</h3></div>
</div>
<div class="card-container" id="card-2"> … <h3>Red Form Study</h3> … </div>
</div>
<div class="row">
<div class="card-container" id="card-3"> … <h3>Material Pause</h3> … </div>
<div class="card-container" id="card-4"> … <h3>Obscured Profile</h3> … </div>
</div>
<div class="row">
<div class="card-container" id="card-5"> … <h3>Muted Presence</h3> … </div>
<div class="card-container" id="card-6"> … <h3>Spatial Balance</h3> … </div>
</div>
<footer><h1>End of Interaction</h1></footer>
<script type="module" src="./script.js"></script>
</body>
</html>
- 6 cards total, ids
card-1…card-6, laid out 2 per.row. Titles in order: Synthetic Silhouette, Red Form Study, Material Pause, Obscured Profile, Muted Presence, Spatial Balance. Headerh1= "The Hover State"; footerh1= "End of Interaction". All copy is neutral demo text — no brands. - Every card has the same internal structure shown for
card-1: a.card-imgwrapping<img>, then two.svg-strokeoverlays (.svg-stroke-1then.svg-stroke-2), then a.card-titlewrapping<h3>. - Inside every card, the two
.svg-strokeoverlays are identical across all six cards — the same two SVG scribbles are repeated in each card (only the CSS stroke color differs per card, see Styling). Use these exact SVGs (theviewBox, pathd,stroke-width:200, andstroke-linecap:rounddefine the whole scribble look):
.svg-stroke-1 (first overlay — the wide scribble):
<svg width="2453" height="2273" viewBox="0 0 2453 2273" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M227.549 1818.76C227.549 1818.76 406.016 2207.75 569.049 2130.26C843.431 1999.85 -264.104 1002.3 227.549 876.262C552.918 792.849 773.647 2456.11 1342.05 2130.26C1885.43 1818.76 14.9644 455.772 760.548 137.262C1342.05 -111.152 1663.5 2266.35 2209.55 1972.76C2755.6 1679.18 1536.63 384.467 1826.55 137.262C2013.5 -22.1463 2209.55 381.262 2209.55 381.262"
stroke="#FE5E41" stroke-width="200" stroke-linecap="round" />
</svg>
.svg-stroke-2 (second overlay — the tall scribble):
<svg width="2250" height="2535" viewBox="0 0 2250 2535" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1661.28 2255.51C1661.28 2255.51 2311.09 1960.37 2111.78 1817.01C1944.47 1696.67 718.456 2870.17 499.781 2255.51C308.969 1719.17 2457.51 1613.83 2111.78 963.512C1766.05 313.198 427.949 2195.17 132.281 1455.51C-155.219 736.292 2014.78 891.514 1708.78 252.012C1437.81 -314.29 369.471 909.169 132.281 566.512C18.1772 401.672 244.781 193.012 244.781 193.012"
stroke="#6E44FF" stroke-width="200" stroke-linecap="round" />
</svg>
The inline stroke attributes (#FE5E41, #6E44FF) are overridden by CSS — the rendered colors come from the CSS variables below, not these attributes. (Because the CSS also sets img, svg { width:100%; height:100%; object-fit:cover }, these giant native width/height attributes are irrelevant once the stylesheet is linked — but they are exactly why an unstyled page explodes to ~2500px scribbles.)
Styling
Font: a clean geometric/humanist sans. Use a genuinely available family as the primary so typography is reproducible — Inter (or Google Sans Text) works well. The original references "Google Sans" via a Google Fonts @import, but that face is not a public Google Font and does not load (it silently falls back to the default sans on both the original and any reproduction), so keep it only as an optional first name in the stack. Whatever you pick, the visual weight and geometry matter more than the exact family.
/* Optional — matches the original source verbatim; note it does not actually load: */
@import url("https://fonts.googleapis.com/css2?family=Google+Sans:ital,opsz,wght@0,17..18,400..700;1,17..18,400..700&display=swap");
Palette — per-card stroke colors live in CSS variables. The colored overlay (.svg-stroke-1) is recolored per card via #card-N; the second overlay (.svg-stroke-2) is the same light grey on every card:
:root {
--card-1-stroke: #d1e639; /* lime-yellow */
--card-2-stroke: #a69763; /* muted gold */
--card-3-stroke: #ebd128; /* yellow */
--card-4-stroke: #a6a09d; /* warm grey */
--card-5-stroke: #99938a; /* taupe grey */
--card-6-stroke: #6f5f98; /* muted violet */
--card-base-stroke: #e0e0e0; /* light grey — used on svg-stroke-2 in ALL cards */
--card-copy: #000; /* title text color */
}
Wire them: #card-1 .svg-stroke-1 svg path { stroke: var(--card-1-stroke); } … #card-6 .svg-stroke-1 svg path { stroke: var(--card-6-stroke); }, and .card-container .svg-stroke-2 svg path { stroke: var(--card-base-stroke); }. Page background is default white; title copy is #000.
Reset / base:
* { margin:0; padding:0; box-sizing:border-box; }body { font-family:"Inter", "Google Sans", sans-serif; }img, svg { width:100%; height:100%; object-fit:cover; }— note both img and svg fill their box and useobject-fit:cover. This is one of the three load-bearing rules: it shrinks the ~2500px native SVGs down into the square card.
Typography (the two big scales):
h1 { font-size:clamp(3rem, 5vw, 7rem); font-weight:500; line-height:1.25; letter-spacing:-0.05rem; }h3 { font-size:clamp(2rem, 2.5vw, 3rem); font-weight:450; line-height:1.25; letter-spacing:-0.025rem; }
Header / footer: header, footer { width:100%; padding:15rem 2rem; display:flex; justify-content:center; align-items:center; text-align:center; } — very tall vertical padding so the grid sits between two roomy title blocks.
Grid rows: .row { width:100%; padding:0 1rem; margin-bottom:1rem; display:flex; gap:1rem; } (two cards side by side, 1rem gutter).
Card: .card-container { position:relative; flex:1; aspect-ratio:1; border-radius:1rem; overflow:hidden; } — square (aspect-ratio 1), rounded, and overflow:hidden is essential (it clips the oversized scribbles and the masked title words to the card box). This is load-bearing rule #2.
Stroke overlays — critical positioning (load-bearing rule #3):
.card-container .svg-stroke {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(1.5); /* centered AND blown up 1.5× */
width: 100%;
height: 100%;
}
position:absolute lifts the scribbles out of flow and stacks them over the photo; scale(1.5) makes them read as huge, overflowing gestures that get clipped by the card's rounded box. Omit this block (e.g. by forgetting the stylesheet) and the scribbles fall into normal flow at native size.
Title: .card-title { position:absolute; bottom:2rem; left:2rem; color:var(--card-copy); } (anchored to the card's bottom-left). And .card-title .word { will-change:transform; } — .word is the class SplitText assigns to each word (see JS); the will-change is a perf hint for the y-transform.
GSAP effect (the important part — be exhaustive)
Smooth-scroll wiring (Lenis, self-driven rAF)
Lenis is stepped by its own requestAnimationFrame loop (not GSAP's ticker here):
const lenis = new Lenis();
function raf(time) {
lenis.raf(time);
requestAnimationFrame(raf);
}
requestAnimationFrame(raf);
Per-card setup (loop over every .card-container)
For each card, grab cardPaths = cardContainer.querySelectorAll(".svg-stroke path") (the 2 scribble paths) and cardTitle = cardContainer.querySelector(".card-title h3").
Split the title into masked words:
const split = SplitText.create(cardTitle, {
type: "words",
mask: "words",
wordsClass: "word",
});
gsap.set(split.words, { yPercent: 100 });
type:"words" splits the h3 into per-word spans; mask:"words" wraps each word in an overflow:hidden masking element; wordsClass:"word" tags each word span with .word. The immediate gsap.set(..., { yPercent:100 }) pushes every word down by 100% of its own height, so at rest all words are hidden below their masks.
Prime each scribble path for line-drawing:
cardPaths.forEach((path) => {
const length = path.getTotalLength();
path.style.strokeDasharray = length;
path.style.strokeDashoffset = length;
});
Setting strokeDasharray === strokeDashoffset === length makes the single dash exactly as long as the whole path and pushes it fully "off the end," so each scribble is invisible at rest. Animating strokeDashoffset down to 0 draws the stroke on progressively; animating it back up to length un-draws it.
Hold one timeline handle per card (let tl;) that is killed and rebuilt on each hover event (so rapid enter/leave never queues conflicting tweens).
mouseenter — draw on + word rise
cardContainer.addEventListener("mouseenter", () => {
if (tl) tl.kill();
tl = gsap.timeline();
// both scribbles, started together at position 0
cardPaths.forEach((path) => {
tl.to(path, {
strokeDashoffset: 0,
attr: { "stroke-width": 700 },
duration: 1.5,
ease: "power2.out",
}, 0);
});
// title words rise, starting 0.35s into the timeline
tl.to(split.words, {
yPercent: 0,
duration: 0.75,
ease: "power3.out",
stagger: 0.075,
}, 0.35);
});
Details:
- Both paths animate in parallel (each
.to(..., 0)is placed at absolute position0):strokeDashoffset→0(the scribble draws itself on from start to end) and the SVG attributestroke-width200 → 700 (the line swells from a bold ribbon to a very fat one).duration:1.5,ease:"power2.out". Noteattr:{ "stroke-width": 700 }tweens the *attribute*, not a CSS prop. - Title words:
yPercent 100 → 0(rise up into their masks from below),duration:0.75,ease:"power3.out",stagger:0.075(each word 75ms after the previous, left-to-right), placed at timeline position0.35so the words start rising a beat after the scribbles begin drawing.
mouseleave — un-draw + word drop (reversed)
cardContainer.addEventListener("mouseleave", () => {
if (tl) tl.kill();
tl = gsap.timeline();
cardPaths.forEach((path) => {
const length = path.getTotalLength();
tl.to(path, {
strokeDashoffset: length,
attr: { "stroke-width": 200 },
duration: 1,
ease: "power2.out",
}, 0);
});
tl.to(split.words, {
yPercent: 100,
duration: 0.5,
ease: "power3.out",
stagger: { each: 0.05, from: "end" },
}, 0);
});
Details:
- Both paths (again started together at
0):strokeDashoffsetback to the fulllength(scribble un-draws),stroke-width700 → 200 (thins back),duration:1(faster than the 1.5s draw-on),ease:"power2.out". - Title words:
yPercent 0 → 100(drop back down out of view),duration:0.5,ease:"power3.out",stagger:{ each:0.05, from:"end" }— the stagger runs from the last word to the first, so words fall in reverse order. Placed at position0(starts immediately, unlike the 0.35 delay on enter).
No ScrollTrigger, no CustomEase, no lerp/rAF interpolation of the effect, no Three.js. Only Lenis-smoothed scrolling plus these two per-card hover timelines.
Assets / images
6 photographic images, one per card (img1.jpg … img6.jpg → card-1 … card-6), each shown in a square (1:1) box with object-fit:cover. Four restrained black-and-white editorial portraits plus two high-saturation color frames, so the per-card colored scribble and black title read clearly over them. Roles (by card title):
- Synthetic Silhouette — high-contrast black-and-white head-and-shoulders close-up of a young man with a short cropped haircut, facing the camera with a direct gaze, against a plain mid-grey studio backdrop. Dominant tones: greys and black with pale skin highlights.
- Muted Presence — clean black-and-white side profile of a woman with a sleek jaw-length black bob and a double pearl drop earring, wearing a black jacket, against a pale off-white background. Dominant tones: deep black hair and jacket over light grey. (Card title "Red Form Study" in the markup; the actual photo is this B&W profile.)
- Material Pause — dramatic low-key black-and-white portrait, the face lit from one side and half-lost to shadow, full lips catching the light, dark collar below. Dominant tones: near-black shadows with a soft grey backdrop.
- Obscured Profile — soft high-key black-and-white frame of a face veiled under draped sheer white translucent fabric, features showing faintly through the cloth. Dominant tones: near-white greys.
- Muted Presence — full-length color portrait of a woman in a deep wine-red pleated dress under a matching red wool coat, small gold hoop earring, hands at her waist, set against a saturated cobalt-blue backdrop. Dominant tones: crimson red and vivid blue. (Card title "Muted Presence" in the markup; the actual photo is this red-on-blue color portrait.)
- Spatial Balance — three-quarter color portrait of a man in an olive-green tailored suit and tie, standing in front of a pastel painterly abstract backdrop of peach, orange and teal brushstrokes. Dominant tones: olive green over warm peach and soft teal.
No brands or logos. All six frames are distinct photographs; exact framing is forgiving under object-fit:cover. Note the markup's card titles (Synthetic Silhouette, Red Form Study, Material Pause, Obscured Profile, Muted Presence, Spatial Balance) are neutral demo labels and do not literally describe every paired photo.
Behavior notes
- Trigger: hover only —
mouseenterdraws the scribbles on and raises the title;mouseleavereverses. Nothing autoplays; there is no scroll-, load-, or click-driven animation of the effect. - Kill-before-build: each handler does
if (tl) tl.kill()then makes a freshgsap.timeline(), so flicking the pointer on/off a card snaps cleanly to the new direction. - Both scribbles are the same two SVG shapes in every card — only the
.svg-stroke-1color changes per card id;.svg-stroke-2stays light grey (#e0e0e0) everywhere. - Responsive (
@media (max-width:1000px)):.row { flex-direction:column; }— the two cards stack vertically; everything else (square cards, hover timelines) is unchanged. The effect is pointer/hover-driven, so it targets desktop. - No reduced-motion guard in the original.
Acceptance check (reproduction is only faithful if all hold)
index.htmlis a full document beginning with<!doctype html>and links./styles.cssin<head>; the module./script.jsloads before</body>.- At rest: a 3×2 grid of square, rounded, clipped cards — each just the photo, with the scribbles invisible (dashoffset = length) and the title hidden below its mask. No giant loose scribbles anywhere; total page height is a few screens, not tens of thousands of pixels.
- On hovering
card-1: the lime-yellow scribble and the light-grey scribble both draw on over the photo (clipped to the rounded box) while fattening 200 → 700, and the title "Synthetic Silhouette" rises word-by-word from its bottom-left mask.mouseleavecleanly reverses both. - Console is clean (zero errors); the GSAP timeline runs on hover.