The Hover State

Synthetic Silhouette

Synthetic Silhouette

Red Form Study

Red Form Study

Material Pause

Material Pause

Obscured Profile

Obscured Profile

Muted Presence

Muted Presence

Spatial Balance

Spatial Balance

1. CDN Scripts

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>

2. Required CSS

.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;
  }
}

3. JavaScript

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,
      );
    });
  });
});

4. HTML Usage

<!-- 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>

5. Data Attributes Reference

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

6. How It Works

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.

7. Required HTML Structure

<!-- 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)