Add these to your <head> or before closing </body> tag:
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/SplitText.min.js"></script>
.row {
width: 100%;
padding: 0 1rem;
margin-bottom: 1rem;
display: flex;
gap: 1rem;
}
[data-svg-hover] {
position: relative;
flex: 1;
aspect-ratio: 1;
border-radius: 1rem;
overflow: hidden;
cursor: pointer;
}
[data-svg-hover] .card-img {
width: 100%;
height: 100%;
}
[data-svg-hover] .svg-stroke {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(1.5);
width: 100%;
height: 100%;
pointer-events: none;
}
[data-svg-hover] .card-title {
position: absolute;
bottom: 2rem;
left: 2rem;
}
[data-svg-hover] .card-title .word {
will-change: transform;
}
@media (max-width: 1000px) {
.row {
flex-direction: column;
}
}
gsap.registerPlugin(SplitText);
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll("[data-svg-hover]").forEach((card) => {
// Read data attributes with defaults
const strokeColor = card.dataset.strokeColor || "#e67339";
const strokeBase = card.dataset.strokeBase || "#e0e0e0";
const titleColor = card.dataset.titleColor || "#000";
const duration = parseFloat(card.dataset.duration) || 1.5;
const stagger = parseFloat(card.dataset.stagger) || 0.075;
// Apply stroke colors from data attributes
const stroke1Path = card.querySelector(".svg-stroke-1 path");
const stroke2Path = card.querySelector(".svg-stroke-2 path");
if (stroke1Path) stroke1Path.setAttribute("stroke", strokeColor);
if (stroke2Path) stroke2Path.setAttribute("stroke", strokeBase);
// Apply title color
const titleEl = card.querySelector(".card-title");
if (titleEl) titleEl.style.color = titleColor;
// Get SVG paths and setup stroke dash
const paths = card.querySelectorAll(".svg-stroke path");
const titleH3 = card.querySelector(".card-title h3");
// SplitText for word-by-word reveal
const split = SplitText.create(titleH3, {
type: "words",
mask: "words",
wordsClass: "word",
});
gsap.set(split.words, { yPercent: 100 });
// Initialize stroke dash offsets
paths.forEach((path) => {
const length = path.getTotalLength();
path.style.strokeDasharray = length;
path.style.strokeDashoffset = length;
});
let tl;
// MOUSEENTER - draw strokes in + reveal title
card.addEventListener("mouseenter", () => {
if (tl) tl.kill();
tl = gsap.timeline();
paths.forEach((path) => {
tl.to(
path,
{
strokeDashoffset: 0,
attr: { "stroke-width": 700 },
duration: duration,
ease: "power2.out",
},
0,
);
});
tl.to(
split.words,
{
yPercent: 0,
duration: 0.75,
ease: "power3.out",
stagger: stagger,
},
0.35,
);
});
// MOUSELEAVE - retract strokes + hide title
card.addEventListener("mouseleave", () => {
if (tl) tl.kill();
tl = gsap.timeline();
paths.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,
);
});
});
});
<!-- Default stroke color -->
<div data-svg-hover>
<div class="card-img">
<img src="your-image.jpg" alt="" />
</div>
<div class="svg-stroke svg-stroke-1">
<svg ...><path .../></svg>
</div>
<div class="svg-stroke svg-stroke-2">
<svg ...><path .../></svg>
</div>
<div class="card-title">
<h3>Card Title</h3>
</div>
</div>
<!-- Custom colors + slower animation -->
<div
data-svg-hover
data-stroke-color="#eb3828"
data-stroke-base="#d0d0d0"
data-title-color="#fff"
data-duration="2"
data-stagger="0.1"
>
...
</div>
| Attribute | Default | Description |
|---|---|---|
data-svg-hover |
required | Enables the hover animation on the card |
data-stroke-color |
#e67339 |
Primary SVG stroke color (stroke-1) |
data-stroke-base |
#e0e0e0 |
Secondary SVG stroke color (stroke-2) |
data-title-color |
#000 |
Color of the card title text |
data-duration |
1.5 |
Stroke draw-in duration in seconds |
data-stagger |
0.075 |
Delay between each word reveal |
The effect combines two animation techniques triggered on hover:
SVG Stroke Animation: Each card has two overlapping
SVG paths positioned absolutely over the image. On load, each path's
stroke-dasharray and stroke-dashoffset are
set to the path's total length, making it invisible. On hover, GSAP
animates strokeDashoffset to 0 (drawing the path in) and
increases stroke-width from 200 to 700 (filling the
card). On leave, the reverse happens.
SplitText Title Reveal: The card title is split into
individual words using GSAP SplitText with mask: "words".
Words start offset below their container (yPercent: 100)
and animate up into view with a stagger. On leave, words slide back
down from the end.
<!-- Each card needs this structure inside [data-svg-hover] -->
.card-img → Contains the <img> element
.svg-stroke-1 → Primary colored SVG path overlay
.svg-stroke-2 → Secondary colored SVG path overlay
.card-title > h3 → Title text (split into words by SplitText)