Back to Gallery f06

Hover Underline

FREE

Animated link underlines with three GSAP variants: an exit-through slide, a marker-style fill sweep, and a hand-drawn SVG wave. One data attribute per link.

hover-effectmicro-interactionunderline beginner

About This Effect

A drop-in underline animation system for links. Add a single data attribute to any anchor and the script injects the underline, highlight, or SVG wave for you, so your markup stays clean. Three variants cover the classic exit-through slide, a marker-style fill that sweeps up behind the text, and a playful wave that draws itself in with a springy settle. GSAP core only, no plugins required.

What's Included

  • Three variants selected with one data attribute: slide, fill, and wave
  • Classic exit-through slide: grows from the left, exits through the right, never reverses backwards
  • Marker-style fill that sweeps up behind the text like a highlighter
  • SVG wave underline that draws in with a subtle elastic settle
  • Zero markup overhead: the script injects every decoration element automatically
  • Per-link colour override via data-underline-color
  • Full keyboard parity: focus and blur mirror every hover animation
  • GSAP core only, no plugins and no wrapper elements required

Perfect For

  • Navigation menus with a polished exit-through hover state
  • Editorial and blog body copy with inline links that invite interaction
  • Portfolio and agency sites with playful hand-drawn wave accents
  • Marketing pages with marker-style highlights that pull attention to key links
  • Footer and sitemap link lists with consistent micro-interactions on every anchor

How It Works

Each link marked with data-underline gets a decoration element injected at runtime: a span for the slide and fill variants, an inline SVG path for the wave. The slide variant swaps transform-origin between left and right around a gsap.fromTo scaleX tween, which is what makes the underline exit through the far side instead of reversing. The fill scales scaleY from a bottom origin and exits through the top, while the wave animates strokeDashoffset past zero so the line draws in and out along the same direction, finished with an elastic scaleY settle via gsap.to. Handlers live in a Map and are removed in the gsap.matchMedia cleanup, and prefers-reduced-motion users get a plain CSS underline with no JavaScript animation.

Difficulty Beginner
Includes HTML + JS + CSS source, documentation, lifetime updates
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Hover Underline</title>
    <link rel="stylesheet" href="assets/style.css">
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&family=Syne:wght@400;500;600;700;800&display=swap" rel="stylesheet">
</head>
<body>
    <main class="container">
        <header class="page-header">
            <h1 class="page-title">Hover Underline</h1>
            <p class="page-subtitle">Animated link underlines with three variants: slide, fill, and wave. One data attribute, keyboard included.</p>
        </header>

        <!-- Example 1: All three variants -->
        <section class="example">
            <div class="example-header">
                <span class="example-number">01</span>
                <h2 class="example-title">Three Variants</h2>
            </div>
            <div class="example-content">
                <p class="example-instruction">Hover or tab through the links below. Each variant is chosen with a single <code>data-underline</code> attribute; the script injects the underline for you.</p>

                <nav class="demo-nav" aria-label="Underline variant demos">
                    <div class="demo-nav__group">
                        <span class="label">slide</span>
                        <a href="#work" data-underline>Work</a>
                        <a href="#studio" data-underline="slide">Studio</a>
                    </div>
                    <div class="demo-nav__group">
                        <span class="label">fill</span>
                        <a href="#projects" data-underline="fill">Projects</a>
                        <a href="#about" data-underline="fill">About</a>
                    </div>
                    <div class="demo-nav__group">
                        <span class="label">wave</span>
                        <a href="#journal" data-underline="wave">Journal</a>
                        <a href="#contact" data-underline="wave">Contact</a>
                    </div>
                </nav>

                <p class="demo-copy">
                    They work inline too. The default <a href="#slide" data-underline>slide variant</a> grows
                    from the left and exits through the right, the
                    <a href="#fill" data-underline="fill">fill variant</a> sweeps up behind the text like a
                    marker, and the <a href="#wave" data-underline="wave">wave variant</a> draws itself in
                    with a springy settle. You can also override the colour per link with
                    <a href="#color" data-underline="wave" data-underline-color="#22d3ee">data-underline-color</a>.
                </p>
            </div>
        </section>
    </main>

    <!-- GSAP Core -->
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>

    <!-- Effect code -->
    <script src="assets/script.js"></script>
</body>
</html>
/**
 * Hover Underline
 *
 * Animated link underlines with three variants: an exit-through slide,
 * a marker-style fill sweep, and an SVG wave that draws itself in.
 * The script injects all decoration elements, so links only need
 * a data-underline attribute.
 *
 * @plugins none (GSAP core only)
 * @techniques hover-effect, micro-interaction, underline
 */

window.addEventListener('DOMContentLoaded', function initHoverUnderline() {
    const SVG_NS = 'http://www.w3.org/2000/svg';
    const WAVE_PATH = 'M0 4 Q 12.5 0 25 4 T 50 4 T 75 4 T 100 4';

    const ctx = gsap.context(function gsapContextCallback() {
        const mm = gsap.matchMedia();

        mm.add({
            isMotion: '(prefers-reduced-motion: no-preference)',
            isReduced: '(prefers-reduced-motion: reduce)'
        }, function matchMediaCallback(context) {
            const { isMotion } = context.conditions;

            // Reduced motion: CSS supplies a plain static underline.
            // No decoration is injected and no JS animation runs.
            if (!isMotion) return;

            const links = document.querySelectorAll('a[data-underline]');
            const handlers = new Map();
            const injected = [];

            // ============================================
            // VARIANT BUILDERS
            // Each returns { enter, leave } handler pair
            // ============================================

            function buildSlide(link) {
                const line = document.createElement('span');
                line.className = 'hu-line';
                line.setAttribute('aria-hidden', 'true');
                link.appendChild(line);
                injected.push(line);

                gsap.set(line, { scaleX: 0, transformOrigin: 'left center' });

                return {
                    enter: function slideEnter() {
                        gsap.killTweensOf(line);
                        // Grow from the left edge
                        gsap.set(line, { transformOrigin: 'left center' });
                        gsap.fromTo(line, { scaleX: 0 }, {
                            scaleX: 1,
                            duration: 0.45,
                            ease: 'power3.out'
                        });
                    },
                    leave: function slideLeave() {
                        gsap.killTweensOf(line);
                        // Swap origin so the line exits through the right
                        // instead of reversing backwards
                        gsap.set(line, { transformOrigin: 'right center' });
                        gsap.to(line, {
                            scaleX: 0,
                            duration: 0.45,
                            ease: 'power3.out'
                        });
                    }
                };
            }

            function buildFill(link) {
                const fill = document.createElement('span');
                fill.className = 'hu-fill';
                fill.setAttribute('aria-hidden', 'true');
                link.appendChild(fill);
                injected.push(fill);

                gsap.set(fill, { scaleY: 0, transformOrigin: 'center bottom' });

                return {
                    enter: function fillEnter() {
                        gsap.killTweensOf(fill);
                        // Sweep up from the baseline like a marker
                        gsap.set(fill, { transformOrigin: 'center bottom' });
                        gsap.fromTo(fill, { scaleY: 0 }, {
                            scaleY: 1,
                            duration: 0.35,
                            ease: 'power2.out'
                        });
                    },
                    leave: function fillLeave() {
                        gsap.killTweensOf(fill);
                        // Continue upwards and exit through the top
                        gsap.set(fill, { transformOrigin: 'center top' });
                        gsap.to(fill, {
                            scaleY: 0,
                            duration: 0.35,
                            ease: 'power2.out'
                        });
                    }
                };
            }

            function buildWave(link) {
                const svg = document.createElementNS(SVG_NS, 'svg');
                svg.setAttribute('class', 'hu-wave');
                svg.setAttribute('viewBox', '0 0 100 8');
                svg.setAttribute('preserveAspectRatio', 'none');
                svg.setAttribute('aria-hidden', 'true');

                const path = document.createElementNS(SVG_NS, 'path');
                path.setAttribute('d', WAVE_PATH);
                path.setAttribute('fill', 'none');
                path.setAttribute('stroke', 'currentColor');
                path.setAttribute('stroke-width', '2');
                path.setAttribute('stroke-linecap', 'round');
                svg.appendChild(path);

                link.appendChild(svg);
                injected.push(svg);

                const length = path.getTotalLength();
                gsap.set(path, {
                    strokeDasharray: length,
                    strokeDashoffset: length
                });

                return {
                    enter: function waveEnter() {
                        gsap.killTweensOf([path, svg]);
                        // Draw the wave in from the left
                        gsap.fromTo(path, { strokeDashoffset: length }, {
                            strokeDashoffset: 0,
                            duration: 0.5,
                            ease: 'power2.out'
                        });
                        // Subtle vertical settle as the line lands
                        gsap.fromTo(svg, { scaleY: 1.6 }, {
                            scaleY: 1,
                            duration: 0.7,
                            ease: 'elastic.out(1.2, 0.4)',
                            transformOrigin: 'center bottom'
                        });
                    },
                    leave: function waveLeave() {
                        gsap.killTweensOf([path, svg]);
                        // Push the offset past zero so the wave keeps
                        // travelling and exits through the right
                        gsap.to(path, {
                            strokeDashoffset: -length,
                            duration: 0.4,
                            ease: 'power2.in'
                        });
                        gsap.to(svg, { scaleY: 1, duration: 0.2 });
                    }
                };
            }

            // ============================================
            // LINK SETUP
            // ============================================
            links.forEach(function initLink(link) {
                const variant = link.dataset.underline || 'slide';
                const color = link.dataset.underlineColor;

                link.classList.add('hu-link');
                if (color) link.style.setProperty('--hu-color', color);

                let pair;
                if (variant === 'fill') {
                    pair = buildFill(link);
                } else if (variant === 'wave') {
                    pair = buildWave(link);
                } else {
                    pair = buildSlide(link);
                }

                // Mouse and keyboard get the same animation
                link.addEventListener('mouseenter', pair.enter);
                link.addEventListener('mouseleave', pair.leave);
                link.addEventListener('focus', pair.enter);
                link.addEventListener('blur', pair.leave);

                handlers.set(link, {
                    mouseenter: pair.enter,
                    mouseleave: pair.leave,
                    focus: pair.enter,
                    blur: pair.leave
                });
            });

            // ============================================
            // CLEANUP
            // ============================================
            return function cleanup() {
                handlers.forEach(function removeHandlers(handlerObj, el) {
                    Object.keys(handlerObj).forEach(function removeHandler(eventType) {
                        el.removeEventListener(eventType, handlerObj[eventType]);
                    });
                });
                handlers.clear();

                injected.forEach(function removeNode(node) {
                    gsap.killTweensOf(node);
                    const child = node.firstElementChild;
                    if (child) gsap.killTweensOf(child);
                    node.remove();
                });
                injected.length = 0;
            };
        });
    });

    // Store context for SPA cleanup
    window.gsapContext = ctx;
});
window.addEventListener("DOMContentLoaded",function(){const e="http://www.w3.org/2000/svg",t=gsap.context(function(){gsap.matchMedia().add({isMotion:"(prefers-reduced-motion: no-preference)",isReduced:"(prefers-reduced-motion: reduce)"},function(t){const{isMotion:n}=t.conditions;if(!n)return;const s=document.querySelectorAll("a[data-underline]"),r=new Map,o=[];return s.forEach(function(t){const n=t.dataset.underline||"slide",s=t.dataset.underlineColor;let a;t.classList.add("hu-link"),s&&t.style.setProperty("--hu-color",s),a="fill"===n?function(e){const t=document.createElement("span");return t.className="hu-fill",t.setAttribute("aria-hidden","true"),e.appendChild(t),o.push(t),gsap.set(t,{scaleY:0,transformOrigin:"center bottom"}),{enter:function(){gsap.killTweensOf(t),gsap.set(t,{transformOrigin:"center bottom"}),gsap.fromTo(t,{scaleY:0},{scaleY:1,duration:.35,ease:"power2.out"})},leave:function(){gsap.killTweensOf(t),gsap.set(t,{transformOrigin:"center top"}),gsap.to(t,{scaleY:0,duration:.35,ease:"power2.out"})}}}(t):"wave"===n?function(t){const n=document.createElementNS(e,"svg");n.setAttribute("class","hu-wave"),n.setAttribute("viewBox","0 0 100 8"),n.setAttribute("preserveAspectRatio","none"),n.setAttribute("aria-hidden","true");const s=document.createElementNS(e,"path");s.setAttribute("d","M0 4 Q 12.5 0 25 4 T 50 4 T 75 4 T 100 4"),s.setAttribute("fill","none"),s.setAttribute("stroke","currentColor"),s.setAttribute("stroke-width","2"),s.setAttribute("stroke-linecap","round"),n.appendChild(s),t.appendChild(n),o.push(n);const r=s.getTotalLength();return gsap.set(s,{strokeDasharray:r,strokeDashoffset:r}),{enter:function(){gsap.killTweensOf([s,n]),gsap.fromTo(s,{strokeDashoffset:r},{strokeDashoffset:0,duration:.5,ease:"power2.out"}),gsap.fromTo(n,{scaleY:1.6},{scaleY:1,duration:.7,ease:"elastic.out(1.2, 0.4)",transformOrigin:"center bottom"})},leave:function(){gsap.killTweensOf([s,n]),gsap.to(s,{strokeDashoffset:-r,duration:.4,ease:"power2.in"}),gsap.to(n,{scaleY:1,duration:.2})}}}(t):function(e){const t=document.createElement("span");return t.className="hu-line",t.setAttribute("aria-hidden","true"),e.appendChild(t),o.push(t),gsap.set(t,{scaleX:0,transformOrigin:"left center"}),{enter:function(){gsap.killTweensOf(t),gsap.set(t,{transformOrigin:"left center"}),gsap.fromTo(t,{scaleX:0},{scaleX:1,duration:.45,ease:"power3.out"})},leave:function(){gsap.killTweensOf(t),gsap.set(t,{transformOrigin:"right center"}),gsap.to(t,{scaleX:0,duration:.45,ease:"power3.out"})}}}(t),t.addEventListener("mouseenter",a.enter),t.addEventListener("mouseleave",a.leave),t.addEventListener("focus",a.enter),t.addEventListener("blur",a.leave),r.set(t,{mouseenter:a.enter,mouseleave:a.leave,focus:a.enter,blur:a.leave})}),function(){r.forEach(function(e,t){Object.keys(e).forEach(function(n){t.removeEventListener(n,e[n])})}),r.clear(),o.forEach(function(e){gsap.killTweensOf(e);const t=e.firstElementChild;t&&gsap.killTweensOf(t),e.remove()}),o.length=0}})});window.gsapContext=t});
/* ============================================
   GSAP VAULT DESIGN SYSTEM
   ============================================ */

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

:root {
    /* Core colors */
    --black: #0a0a0a;
    --dark: #111111;
    --surface: #1a1a1a;
    --surface-light: #242424;
    --border: #2a2a2a;
    --border-light: #3a3a3a;

    /* Text hierarchy */
    --text: #ffffff;
    --text-secondary: #b0b0b0;
    --text-muted: #999999;

    /* Accent */
    --lime: #c8ff00;
    --lime-dim: #a8d900;

    /* Set chosen accent */
    --accent: var(--lime);

    /* Easing - matches GSAP patterns */
    --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
    --ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
    --ease-out-cubic: cubic-bezier(0.33, 1, 0.68, 1);
}

html {
    scroll-behavior: auto;
}

body {
    background: var(--black);
    color: var(--text);
    font-family: 'Space Grotesk', system-ui, sans-serif;
    font-weight: 400;
    overflow-x: hidden;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    line-height: 1.6;
}

::selection {
    background: var(--accent);
    color: var(--black);
}

/* ============================================
   CUSTOM SCROLLBAR
   ============================================ */
::-webkit-scrollbar {
    width: 10px;
}

::-webkit-scrollbar-track {
    background: var(--black);
}

::-webkit-scrollbar-thumb {
    background: var(--surface-light);
    border-radius: 5px;
    border: 2px solid var(--black);
}

::-webkit-scrollbar-thumb:hover {
    background: var(--border-light);
}

/* ============================================
   TYPOGRAPHY
   ============================================ */

/* Syne for display/headlines */
h1, h2, h3, h4, h5, h6 {
    font-family: 'Syne', system-ui, sans-serif;
    font-weight: 700;
    line-height: 1.1;
    letter-spacing: -0.03em;
}

/* JetBrains Mono for code/labels */
code {
    font-family: 'JetBrains Mono', monospace;
    font-size: 0.85em;
    background: var(--surface);
    padding: 0.15em 0.4em;
    border-radius: 4px;
}

/* ============================================
   LAYOUT
   ============================================ */
.container {
    max-width: 1000px;
    margin: 0 auto;
    padding: 5rem 2rem;
}

/* ============================================
   PAGE HEADER
   ============================================ */
.page-header {
    text-align: center;
    margin-bottom: 6rem;
    padding-top: 2rem;
}

.page-title {
    font-size: clamp(2.5rem, 7vw, 4rem);
    font-weight: 700;
    letter-spacing: -0.03em;
    margin-bottom: 1rem;
}

.page-subtitle {
    font-size: 1.125rem;
    color: var(--text-secondary);
    max-width: 500px;
    margin: 0 auto;
}

/* ============================================
   EXAMPLE SECTIONS
   ============================================ */
.example {
    margin-bottom: 8rem;
    padding-bottom: 4rem;
    border-bottom: 1px solid var(--border);
}

.example:last-child {
    margin-bottom: 0;
    padding-bottom: 0;
    border-bottom: none;
}

.example-header {
    display: flex;
    align-items: center;
    gap: 1rem;
    margin-bottom: 2rem;
}

.example-number {
    font-family: 'JetBrains Mono', monospace;
    font-size: 0.75rem;
    font-weight: 600;
    color: var(--accent);
    letter-spacing: 0.1em;
}

.example-title {
    font-family: 'Space Grotesk', system-ui, sans-serif;
    font-size: 0.875rem;
    font-weight: 500;
    text-transform: uppercase;
    letter-spacing: 0.1em;
    color: var(--text-muted);
}

.example-content {
    padding-left: 3rem;
}

.example-instruction {
    font-size: 1rem;
    color: var(--text-secondary);
    margin-bottom: 2rem;
    line-height: 1.7;
}

/* ============================================
   LABELS
   ============================================ */
.label {
    display: inline-block;
    font-family: 'JetBrains Mono', monospace;
    font-size: 0.7rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.15em;
    color: var(--accent);
}

/* ============================================
   EFFECT-SPECIFIC STYLES
   ============================================ */

/* Base link treatment (class added by the script) */
a[data-underline] {
    position: relative;
    display: inline-block;
    color: var(--text);
    text-decoration: none;
}

a[data-underline]:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 4px;
    border-radius: 2px;
}

/* Slide variant: injected line */
.hu-line {
    position: absolute;
    left: 0;
    right: 0;
    bottom: -3px;
    height: 2px;
    background: var(--hu-color, var(--accent));
    transform: scaleX(0);
    pointer-events: none;
}

/* Fill variant: injected highlight behind the text */
.hu-fill {
    position: absolute;
    inset: -0.05em -0.25em;
    background: var(--hu-color, var(--accent));
    border-radius: 3px;
    transform: scaleY(0);
    z-index: -1;
    pointer-events: none;
}

a[data-underline="fill"] {
    transition: color 0.25s var(--ease-out-cubic);
}

a[data-underline="fill"]:hover,
a[data-underline="fill"]:focus-visible {
    color: var(--black);
}

/* Wave variant: injected SVG */
.hu-wave {
    position: absolute;
    left: 0;
    bottom: -8px;
    width: 100%;
    height: 8px;
    color: var(--hu-color, var(--accent));
    pointer-events: none;
    overflow: visible;
}

/* Demo layout: nav-style row */
.demo-nav {
    display: flex;
    flex-wrap: wrap;
    gap: 2rem 4rem;
    margin-bottom: 3.5rem;
}

.demo-nav__group {
    display: flex;
    align-items: baseline;
    gap: 1.75rem;
}

.demo-nav__group .label {
    min-width: 3.5rem;
}

.demo-nav a {
    font-size: 1.25rem;
    font-weight: 500;
}

/* Demo layout: inline paragraph links */
.demo-copy {
    font-size: 1.125rem;
    color: var(--text-secondary);
    max-width: 620px;
    line-height: 1.9;
}

.demo-copy a {
    color: var(--text);
    font-weight: 500;
}

/* ============================================
   RESPONSIVE
   ============================================ */
@media (max-width: 768px) {
    .container {
        padding: 3rem 1.5rem;
    }

    .page-header {
        margin-bottom: 4rem;
    }

    .example {
        margin-bottom: 5rem;
        padding-bottom: 3rem;
    }

    .example-content {
        padding-left: 0;
    }

    .demo-nav {
        flex-direction: column;
        gap: 1.5rem;
    }
}

/* ============================================
   REDUCED MOTION
   Plain static underline, no JS animation
   ============================================ */
@media (prefers-reduced-motion: reduce) {
    *,
    *::before,
    *::after {
        animation-duration: 0.01ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.01ms !important;
    }

    a[data-underline] {
        text-decoration: underline;
        text-decoration-color: var(--hu-color, var(--accent));
        text-decoration-thickness: 2px;
        text-underline-offset: 4px;
    }

    .hu-line,
    .hu-fill,
    .hu-wave {
        display: none;
    }
}

/* ============================================
   SCREEN READER ONLY (for ARIA live regions)
   ============================================ */
.sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
}

Animated link underlines with three variants: an exit-through slide, a marker-style fill sweep, and a hand-drawn SVG wave. The script injects all decoration elements, so you only add one data attribute to plain links.

Quick Start

1. Add to your HTML <head>:

<link rel="stylesheet" href="path/to/style.css">

2. Add before closing </body> tag:

<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script src="path/to/script.js"></script>

3. Add data-underline to any link:

<!-- Slide (default): grows from the left, exits through the right -->
<a href="/work" data-underline>Work</a>

<!-- Fill: highlight sweeps up behind the text like a marker -->
<a href="/about" data-underline="fill">About</a>

<!-- Wave: SVG underline draws in with a springy settle -->
<a href="/contact" data-underline="wave">Contact</a>

That is all the markup you need. The underline span (or SVG) is injected automatically.

Options

Attribute Values Default Description
data-underline slide, fill, wave slide Which underline variant to use. An empty value falls back to slide
data-underline-color Any CSS colour Accent colour from CSS Per-link colour override for the underline, fill, or wave

Examples

Navigation Row

HTML:

<nav>
  <a href="/work" data-underline>Work</a>
  <a href="/studio" data-underline>Studio</a>
  <a href="/journal" data-underline="wave">Journal</a>
</nav>

Inline Links in Body Copy

HTML:

<p>
  Read the <a href="/guide" data-underline>full guide</a> or jump straight to
  the <a href="/examples" data-underline="fill">examples</a>.
</p>

Custom Colour

HTML:

<a href="/pricing" data-underline="wave" data-underline-color="#22d3ee">Pricing</a>

CSS Classes

These are created by the script; you can restyle them in your own CSS.

Class Description
.hu-line Injected slide underline (position, thickness, colour)
.hu-fill Injected marker highlight behind the text
.hu-wave Injected SVG wave underline

The colour of all three reads from the --hu-color custom property, falling back to your accent colour. data-underline-color sets --hu-color on the individual link.

How the Exit-Through Slide Works

The slide variant never plays the enter animation in reverse. On mouseenter the transform-origin is set to the left edge and the line scales from 0 to 1, so it grows out of the left. On mouseleave the origin swaps to the right edge before scaling back to 0, so the line appears to continue travelling and exit through the right. The wave variant uses the same idea with strokeDashoffset: entering animates the offset to 0, leaving pushes it past zero so the line keeps moving in the same direction.

Accessibility

  • Keyboard parity: every mouseenter/mouseleave handler is also bound to focus/blur, so tabbing through links plays exactly the same animations as hovering
  • Focus visible: links get a visible :focus-visible outline in the accent colour
  • Reduced motion: with prefers-reduced-motion: reduce, no decoration is injected and no JavaScript animation runs; links get a plain static CSS underline instead
  • Semantic HTML: the effect only targets real <a> elements, and injected decorations are marked aria-hidden="true" so screen readers ignore them

Reduced Motion Behavior

When prefers-reduced-motion: reduce is set:

  • The JavaScript skips setup entirely
  • Links show a plain 2px static underline via CSS
  • data-underline-color still applies to the static underline through --hu-color

Programmatic Control

The GSAP context is exposed for SPA teardown. Add this to your own JavaScript:

JavaScript:

// Revert all animations and remove listeners plus injected elements
window.gsapContext.revert();

Browser Support

Modern browsers (ES6+). Not compatible with IE11.

Dependencies

Required:

  • GSAP 3.12+ (core only, no plugins)

Your Cart

Your cart is empty

Browse Effects