f06

Hover Underline

Free

Four material link underlines (an exit-through line, marker sweep, hand-drawn wave, and an endlessly travelling wave) with coordinated type and active-index responses.

beginner
5 more details
hover-effectmicro-interactionunderlinesvg-animationinfinite-loop

This demo reads better at your own screen size than in the frame below:

Updated

About this effect

A tactile navigation underline effect that gives each link one of four genuinely different materials: an exit-through rule, a rough marker stroke, a hand-drawn SVG wave, or a wave that keeps travelling for as long as the link is held. The underline, link colour and horizontal offset respond together, while an optional stage index follows the active item. Everything runs on GSAP core with semantic anchors and no required wrapper per link.

Hover Underline - GSAP animation effect preview

What's included

9 items
  • Four preserved data variants: slide, fill, wave, and travel
  • Exit-through line travels into and out of a clipped track
  • Marker underline sweeps laterally with an irregular hand-cut edge
  • SVG wave draws through strokeDashoffset and lands with an elastic settle
  • Travelling wave loops seamlessly while hovered or focused, then draws itself out
  • Coordinated text colour and horizontal-offset response on every interaction
  • Optional active index and stage accent response for navigation groups
  • Pointer, keyboard-focus, and touch activation with complete listener cleanup
  • No-JavaScript and reduced-motion fallbacks keep clear static underlines

Perfect for

5 use cases
  • Cultural programme navigation with a distinct treatment for each event
  • Editorial menus that need expressive but readable link feedback
  • Creative studio and portfolio navigation with keyboard parity
  • Festival lineups and event indexes with a coordinated active counter
  • Mobile menus that need a visible tap response without replacing semantic links

How it works

3 sections

Every anchor marked with data-underline receives one decorative element at runtime. The slide variant moves a solid rule through a clipped shell, the fill variant scales an irregular marker stroke between opposing transform origins, and the wave variant draws an inline SVG path by animating strokeDashoffset. The travel variant draws a wave two wavelengths wider than the link, clips it to the link's width, and slides it sideways by exactly one wavelength on an endless linear repeat, so the loop has no seam; it stops only once the stroke has drawn itself back out.

The same activation updates link colour and x position. When links share a data-underline-stage container, entering or focusing a new item settles the previous treatment and updates the optional data-active-index display, creating a clean chase down the list.

All listeners are stored in a Map and removed by the gsap.matchMedia cleanup. Reduced-motion visitors skip injected decoration, while CSS supplies static coloured underlines; the same static treatment remains available when JavaScript is absent.

Difficulty Beginner
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this effect's demo, from the latest scan. A measurement of the demo as shipped, not a promise for your page.

Accessibility
100
Best practices
100

No performance score, on purpose. That figure depends on how you deploy: your server's compression and caching, your CDN, the connection and the device doing the test, none of which the code controls. The same page can score very differently on two consecutive runs, so measure it where it will live.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Hover Underline Demo | GSAP Vault</title>
    <link rel="stylesheet" href="assets/style.css">
</head>
<body>
    <main class="stage">
        <p class="cue">Hover a link, or tab through them</p>

        <nav class="links" data-underline-stage aria-label="Sections">
            <a href="#after-dark" data-underline="slide" data-underline-color="#2f5bd7">After Dark</a>
            <a href="#radio-body" data-underline="fill" data-underline-color="#b8532a">Radio Body</a>
            <a href="#soft-riot" data-underline="wave" data-underline-color="#37675a">Soft Riot</a>
            <a href="#long-wave" data-underline="travel" data-underline-color="#6d4a7e">Long Wave</a>
        </nav>
    </main>

    <script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
    <script src="assets/script.js"></script>
</body>
</html>
/**
 * Hover Underline
 *
 * Four material underline treatments for semantic links: an exit-through
 * line, a marker sweep, a hand-drawn SVG wave, and a travelling wave that
 * keeps moving for as long as the link is held. GSAP core only.
 */

(function onReady(init) {
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})(function initHoverUnderline() {
    if (typeof gsap === 'undefined') return;

    const SVG_NS = 'http://www.w3.org/2000/svg';
    const WAVE_PATH = 'M1 6 C 9 1, 16 8, 25 4 S 41 7, 51 3 S 68 8, 78 4 S 91 2, 99 5';

    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 links = Array.from(document.querySelectorAll('a[data-underline]'));
            const originalColors = new Map();

            links.forEach(function applyLinkColor(link) {
                originalColors.set(link, link.style.getPropertyValue('--hu-color'));
                if (link.dataset.underlineColor) {
                    link.style.setProperty('--hu-color', link.dataset.underlineColor);
                }
            });

            // CSS keeps every link visibly underlined when motion is reduced.
            if (!context.conditions.isMotion) {
                return function reducedCleanup() {
                    originalColors.forEach(function restoreColor(value, link) {
                        if (value) link.style.setProperty('--hu-color', value);
                        else link.style.removeProperty('--hu-color');
                    });
                };
            }

            const handlers = new Map();
            const pairs = new Map();
            const injected = [];
            const touchTimers = new Map();
            const stageCurrent = new Map();
            const stageIndexText = new Map();
            const stages = new Set();

            function registerNode(node) {
                node.setAttribute('aria-hidden', 'true');
                injected.push(node);
                return node;
            }

            function buildSlide(link) {
                const shell = registerNode(document.createElement('span'));
                shell.className = 'hu-line';

                const track = document.createElement('span');
                track.className = 'hu-line__track';
                shell.appendChild(track);
                link.appendChild(shell);

                gsap.set(track, { xPercent: -105 });

                return {
                    targets: [track],
                    enter: function slideEnter() {
                        gsap.killTweensOf(track);
                        gsap.fromTo(track, { xPercent: -105 }, {
                            xPercent: 0,
                            duration: 0.48,
                            ease: 'power4.out'
                        });
                    },
                    leave: function slideLeave() {
                        gsap.killTweensOf(track);
                        gsap.to(track, {
                            xPercent: 105,
                            duration: 0.34,
                            ease: 'power3.in'
                        });
                    }
                };
            }

            function buildFill(link) {
                const fill = registerNode(document.createElement('span'));
                fill.className = 'hu-fill';
                link.appendChild(fill);

                gsap.set(fill, { scaleX: 0, rotation: -1.5, transformOrigin: 'left center' });

                return {
                    targets: [fill],
                    enter: function fillEnter() {
                        gsap.killTweensOf(fill);
                        gsap.set(fill, { transformOrigin: 'left center' });
                        gsap.fromTo(fill, { scaleX: 0, rotation: -1.5 }, {
                            scaleX: 1,
                            rotation: 0.5,
                            duration: 0.42,
                            ease: 'power3.out'
                        });
                    },
                    leave: function fillLeave() {
                        gsap.killTweensOf(fill);
                        gsap.set(fill, { transformOrigin: 'right center' });
                        gsap.to(fill, {
                            scaleX: 0,
                            rotation: 1.5,
                            duration: 0.3,
                            ease: 'power2.inOut'
                        });
                    }
                };
            }

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

                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.4');
                path.setAttribute('stroke-linecap', 'round');
                path.setAttribute('stroke-linejoin', 'round');
                svg.appendChild(path);
                link.appendChild(svg);

                // Whole units, with two to spare. Browsers round the dash offset
                // they store, and an offset that rounds past the dash length
                // wraps the pattern round and leaves a stub of stroke showing
                // while the link is at rest.
                const length = Math.ceil(path.getTotalLength()) + 2;
                gsap.set(path, { strokeDasharray: length, strokeDashoffset: length });

                return {
                    targets: [path, svg],
                    enter: function waveEnter() {
                        gsap.killTweensOf([path, svg]);
                        gsap.fromTo(path, { strokeDashoffset: length }, {
                            strokeDashoffset: 0,
                            duration: 0.56,
                            ease: 'power2.out'
                        });
                        gsap.fromTo(svg, { y: 3, scaleY: 1.7 }, {
                            y: 0,
                            scaleY: 1,
                            duration: 0.72,
                            ease: 'elastic.out(1, 0.38)',
                            transformOrigin: 'center center'
                        });
                    },
                    leave: function waveLeave() {
                        gsap.killTweensOf([path, svg]);
                        gsap.to(path, {
                            strokeDashoffset: -length,
                            duration: 0.38,
                            ease: 'power2.in'
                        });
                        gsap.to(svg, { y: 0, scaleY: 1, duration: 0.2 });
                    }
                };
            }

            /* The travelling wave. Delete this block if you only want the
               three treatments that draw once and settle.

               The trick is that the path is drawn two wavelengths wider than
               the link and the rail clips it, so sliding the whole SVG by
               exactly one wavelength loops with no visible seam. */
            function buildTravel(link) {
                const rail = registerNode(document.createElement('span'));
                rail.className = 'hu-travel';

                const svg = document.createElementNS(SVG_NS, 'svg');
                const path = document.createElementNS(SVG_NS, 'path');
                path.setAttribute('fill', 'none');
                path.setAttribute('stroke', 'currentColor');
                path.setAttribute('stroke-linecap', 'round');
                svg.appendChild(path);
                rail.appendChild(svg);
                link.appendChild(rail);

                let length = 0;
                let waveLength = 0;
                let travelTween = null;

                // Measured rather than fixed, so the wave keeps its proportions
                // whatever size the link's type ends up at.
                function build() {
                    const size = parseFloat(getComputedStyle(link).fontSize) || 16;
                    const width = link.getBoundingClientRect().width;
                    const height = Math.max(9, size * 0.15);

                    // Long enough that the wave reads as a wave rather than a
                    // zigzag, and short enough that the slide is obvious.
                    waveLength = Math.max(14, size * 0.45);
                    // Above about 0.9 the crests clip against the rail.
                    const amplitude = height * 0.85;
                    const baseline = height / 2;
                    const total = width + waveLength * 2;
                    const waves = Math.ceil(total / waveLength);

                    let d = 'M 0 ' + baseline + ' q ' + (waveLength / 4) + ' ' + (-amplitude) + ' ' + (waveLength / 2) + ' 0';
                    for (let i = 1; i < waves * 2; i++) d += ' t ' + (waveLength / 2) + ' 0';

                    path.setAttribute('d', d);
                    path.setAttribute('stroke-width', String(Math.max(2, size * 0.042)));
                    svg.setAttribute('width', String(total));
                    svg.setAttribute('height', String(height));

                    // Whole units with two to spare: browsers round the stored
                    // offset, and one that rounds past the dash length wraps the
                    // pattern round and leaves a stub of stroke showing at rest.
                    length = Math.ceil(path.getTotalLength()) + 2;
                    gsap.set(path, { strokeDasharray: length, strokeDashoffset: length });
                }

                build();

                return {
                    targets: [path, svg],
                    enter: function travelEnter() {
                        gsap.killTweensOf([path, svg]);
                        if (travelTween) travelTween.kill();
                        // Re-measured here so a resize since the last hover cannot
                        // leave the wave short of the end of the word.
                        build();
                        gsap.fromTo(path, { strokeDashoffset: length }, {
                            strokeDashoffset: 0,
                            duration: 0.42,
                            ease: 'power2.out'
                        });
                        travelTween = gsap.fromTo(svg, { x: 0 }, {
                            x: -waveLength,
                            duration: 1.1,
                            ease: 'none',
                            repeat: -1
                        });
                    },
                    leave: function travelLeave() {
                        gsap.killTweensOf(path);
                        gsap.to(path, {
                            strokeDashoffset: -length,
                            duration: 0.34,
                            ease: 'power2.in',
                            // The loop is only stopped once the stroke has drawn
                            // itself out. Killing it while it is still visible
                            // freezes the wave mid-slide.
                            onComplete: function stopTravel() {
                                if (travelTween) travelTween.kill();
                                travelTween = null;
                                gsap.set(svg, { x: 0 });
                                gsap.set(path, { strokeDashoffset: length });
                            }
                        });
                    }
                };
            }

            function updateIndex(stage, link) {
                if (!stage || !stage.isConnected) return;
                const display = stage.querySelector('[data-active-index]');
                const stageLinks = Array.from(stage.querySelectorAll('a[data-underline]'));
                const number = link ? String(stageLinks.indexOf(link) + 1).padStart(2, '0') : '00';
                const color = link ? getComputedStyle(link).getPropertyValue('--hu-color').trim() : '';

                stage.classList.toggle('is-active', Boolean(link));
                if (color) stage.style.setProperty('--active-color', color);
                else stage.style.removeProperty('--active-color');

                if (display && display.textContent !== number) {
                    display.textContent = number;
                    gsap.killTweensOf(display);
                    gsap.fromTo(display, { yPercent: link ? 70 : -35, opacity: 0 }, {
                        yPercent: 0,
                        opacity: 1,
                        duration: 0.34,
                        ease: 'power3.out'
                    });
                }
            }

            function enterLink(link) {
                if (!link.isConnected) return;
                const pair = pairs.get(link);
                const stage = link.closest('[data-underline-stage]');
                const previous = stage && stageCurrent.get(stage);

                if (previous && previous !== link) {
                    const previousPair = pairs.get(previous);
                    if (previousPair) previousPair.leave();
                    gsap.to(previous, {
                        x: 0,
                        color: previous.dataset.huRestColor,
                        duration: 0.28,
                        ease: 'power2.out',
                        overwrite: true
                    });
                }

                if (stage) stageCurrent.set(stage, link);
                pair.enter();
                gsap.to(link, {
                    x: 10,
                    color: getComputedStyle(link).getPropertyValue('--hu-color').trim(),
                    duration: 0.36,
                    ease: 'power3.out',
                    overwrite: true
                });
                updateIndex(stage, link);
            }

            function leaveLink(link) {
                if (!link.isConnected) return;
                const pair = pairs.get(link);
                const stage = link.closest('[data-underline-stage]');

                pair.leave();
                gsap.to(link, {
                    x: 0,
                    color: link.dataset.huRestColor,
                    duration: 0.34,
                    ease: 'power3.out',
                    overwrite: true
                });

                if (stage && stageCurrent.get(stage) === link) {
                    stageCurrent.delete(stage);
                    updateIndex(stage, null);
                }
            }

            links.forEach(function initLink(link) {
                const variant = link.dataset.underline || 'slide';
                const stage = link.closest('[data-underline-stage]');
                const restColor = getComputedStyle(link).color;
                let pair;

                link.dataset.huRestColor = restColor;
                link.classList.add('hu-link', 'hu-ready');
                if (stage) {
                    stages.add(stage);
                    const display = stage.querySelector('[data-active-index]');
                    if (display && !stageIndexText.has(display)) {
                        stageIndexText.set(display, display.textContent);
                    }
                }

                if (variant === 'fill') pair = buildFill(link);
                else if (variant === 'wave') pair = buildWave(link);
                else if (variant === 'travel') pair = buildTravel(link);
                else pair = buildSlide(link);
                pairs.set(link, pair);

                const enter = function enter() { enterLink(link); };
                const leave = function leave() { leaveLink(link); };
                const pointerDown = function pointerDown(event) {
                    if (event.pointerType === 'mouse') return;
                    window.clearTimeout(touchTimers.get(link));
                    enterLink(link);
                    touchTimers.set(link, window.setTimeout(function touchSettle() {
                        if (document.activeElement !== link) leaveLink(link);
                    }, 850));
                };

                link.addEventListener('mouseenter', enter);
                link.addEventListener('mouseleave', leave);
                link.addEventListener('focus', enter);
                link.addEventListener('blur', leave);
                link.addEventListener('pointerdown', pointerDown);

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

            stages.forEach(function initialiseStage(stage) {
                updateIndex(stage, null);
            });

            return function cleanup() {
                handlers.forEach(function removeHandlers(handlerObject, link) {
                    Object.keys(handlerObject).forEach(function removeHandler(type) {
                        link.removeEventListener(type, handlerObject[type]);
                    });
                });
                touchTimers.forEach(window.clearTimeout);

                pairs.forEach(function killPair(pair) {
                    gsap.killTweensOf(pair.targets);
                });
                links.forEach(function restoreLink(link) {
                    gsap.killTweensOf(link);
                    link.classList.remove('hu-link', 'hu-ready');
                    delete link.dataset.huRestColor;
                    const value = originalColors.get(link);
                    if (value) link.style.setProperty('--hu-color', value);
                    else link.style.removeProperty('--hu-color');
                });
                injected.forEach(function removeNode(node) { node.remove(); });
                stages.forEach(function restoreStage(stage) {
                    stage.classList.remove('is-active');
                    stage.style.removeProperty('--active-color');
                });
                stageIndexText.forEach(function restoreIndex(value, display) {
                    display.textContent = value;
                });

                handlers.clear();
                pairs.clear();
                touchTimers.clear();
                stageCurrent.clear();
                stageIndexText.clear();
                injected.length = 0;
            };
        });
    });

    window.gsapContext = ctx;
});
!function(e){"loading"===document.readyState?document.addEventListener("DOMContentLoaded",e):e()}(function(){if("undefined"==typeof gsap)return;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 o=Array.from(document.querySelectorAll("a[data-underline]")),n=new Map;if(o.forEach(function(e){n.set(e,e.style.getPropertyValue("--hu-color")),e.dataset.underlineColor&&e.style.setProperty("--hu-color",e.dataset.underlineColor)}),!t.conditions.isMotion)return function(){n.forEach(function(e,t){e?t.style.setProperty("--hu-color",e):t.style.removeProperty("--hu-color")})};const r=new Map,s=new Map,a=[],i=new Map,c=new Map,l=new Map,u=new Set;function d(e){return e.setAttribute("aria-hidden","true"),a.push(e),e}function f(e,t){if(!e||!e.isConnected)return;const o=e.querySelector("[data-active-index]"),n=Array.from(e.querySelectorAll("a[data-underline]")),r=t?String(n.indexOf(t)+1).padStart(2,"0"):"00",s=t?getComputedStyle(t).getPropertyValue("--hu-color").trim():"";e.classList.toggle("is-active",Boolean(t)),s?e.style.setProperty("--active-color",s):e.style.removeProperty("--active-color"),o&&o.textContent!==r&&(o.textContent=r,gsap.killTweensOf(o),gsap.fromTo(o,{yPercent:t?70:-35,opacity:0},{yPercent:0,opacity:1,duration:.34,ease:"power3.out"}))}function p(e){if(!e.isConnected)return;const t=s.get(e),o=e.closest("[data-underline-stage]"),n=o&&c.get(o);if(n&&n!==e){const e=s.get(n);e&&e.leave(),gsap.to(n,{x:0,color:n.dataset.huRestColor,duration:.28,ease:"power2.out",overwrite:!0})}o&&c.set(o,e),t.enter(),gsap.to(e,{x:10,color:getComputedStyle(e).getPropertyValue("--hu-color").trim(),duration:.36,ease:"power3.out",overwrite:!0}),f(o,e)}function g(e){if(!e.isConnected)return;const t=s.get(e),o=e.closest("[data-underline-stage]");t.leave(),gsap.to(e,{x:0,color:e.dataset.huRestColor,duration:.34,ease:"power3.out",overwrite:!0}),o&&c.get(o)===e&&(c.delete(o),f(o,null))}return o.forEach(function(t){const o=t.dataset.underline||"slide",n=t.closest("[data-underline-stage]"),a=getComputedStyle(t).color;let c;if(t.dataset.huRestColor=a,t.classList.add("hu-link","hu-ready"),n){u.add(n);const e=n.querySelector("[data-active-index]");e&&!l.has(e)&&l.set(e,e.textContent)}c="fill"===o?function(e){const t=d(document.createElement("span"));return t.className="hu-fill",e.appendChild(t),gsap.set(t,{scaleX:0,rotation:-1.5,transformOrigin:"left center"}),{targets:[t],enter:function(){gsap.killTweensOf(t),gsap.set(t,{transformOrigin:"left center"}),gsap.fromTo(t,{scaleX:0,rotation:-1.5},{scaleX:1,rotation:.5,duration:.42,ease:"power3.out"})},leave:function(){gsap.killTweensOf(t),gsap.set(t,{transformOrigin:"right center"}),gsap.to(t,{scaleX:0,rotation:1.5,duration:.3,ease:"power2.inOut"})}}}(t):"wave"===o?function(t){const o=d(document.createElementNS(e,"svg"));o.setAttribute("class","hu-wave"),o.setAttribute("viewBox","0 0 100 10"),o.setAttribute("preserveAspectRatio","none");const n=document.createElementNS(e,"path");n.setAttribute("d","M1 6 C 9 1, 16 8, 25 4 S 41 7, 51 3 S 68 8, 78 4 S 91 2, 99 5"),n.setAttribute("fill","none"),n.setAttribute("stroke","currentColor"),n.setAttribute("stroke-width","2.4"),n.setAttribute("stroke-linecap","round"),n.setAttribute("stroke-linejoin","round"),o.appendChild(n),t.appendChild(o);const r=Math.ceil(n.getTotalLength())+2;return gsap.set(n,{strokeDasharray:r,strokeDashoffset:r}),{targets:[n,o],enter:function(){gsap.killTweensOf([n,o]),gsap.fromTo(n,{strokeDashoffset:r},{strokeDashoffset:0,duration:.56,ease:"power2.out"}),gsap.fromTo(o,{y:3,scaleY:1.7},{y:0,scaleY:1,duration:.72,ease:"elastic.out(1, 0.38)",transformOrigin:"center center"})},leave:function(){gsap.killTweensOf([n,o]),gsap.to(n,{strokeDashoffset:-r,duration:.38,ease:"power2.in"}),gsap.to(o,{y:0,scaleY:1,duration:.2})}}}(t):"travel"===o?function(t){const o=d(document.createElement("span"));o.className="hu-travel";const n=document.createElementNS(e,"svg"),r=document.createElementNS(e,"path");r.setAttribute("fill","none"),r.setAttribute("stroke","currentColor"),r.setAttribute("stroke-linecap","round"),n.appendChild(r),o.appendChild(n),t.appendChild(o);let s=0,a=0,i=null;function c(){const e=parseFloat(getComputedStyle(t).fontSize)||16,o=t.getBoundingClientRect().width,i=Math.max(9,.15*e);a=Math.max(14,.45*e);const c=.85*i,l=i/2,u=o+2*a,d=Math.ceil(u/a);let f="M 0 "+l+" q "+a/4+" "+-c+" "+a/2+" 0";for(let e=1;e<2*d;e++)f+=" t "+a/2+" 0";r.setAttribute("d",f),r.setAttribute("stroke-width",String(Math.max(2,.042*e))),n.setAttribute("width",String(u)),n.setAttribute("height",String(i)),s=Math.ceil(r.getTotalLength())+2,gsap.set(r,{strokeDasharray:s,strokeDashoffset:s})}return c(),{targets:[r,n],enter:function(){gsap.killTweensOf([r,n]),i&&i.kill(),c(),gsap.fromTo(r,{strokeDashoffset:s},{strokeDashoffset:0,duration:.42,ease:"power2.out"}),i=gsap.fromTo(n,{x:0},{x:-a,duration:1.1,ease:"none",repeat:-1})},leave:function(){gsap.killTweensOf(r),gsap.to(r,{strokeDashoffset:-s,duration:.34,ease:"power2.in",onComplete:function(){i&&i.kill(),i=null,gsap.set(n,{x:0}),gsap.set(r,{strokeDashoffset:s})}})}}}(t):function(e){const t=d(document.createElement("span"));t.className="hu-line";const o=document.createElement("span");return o.className="hu-line__track",t.appendChild(o),e.appendChild(t),gsap.set(o,{xPercent:-105}),{targets:[o],enter:function(){gsap.killTweensOf(o),gsap.fromTo(o,{xPercent:-105},{xPercent:0,duration:.48,ease:"power4.out"})},leave:function(){gsap.killTweensOf(o),gsap.to(o,{xPercent:105,duration:.34,ease:"power3.in"})}}}(t),s.set(t,c);const f=function(){p(t)},h=function(){g(t)},m=function(e){"mouse"!==e.pointerType&&(window.clearTimeout(i.get(t)),p(t),i.set(t,window.setTimeout(function(){document.activeElement!==t&&g(t)},850)))};t.addEventListener("mouseenter",f),t.addEventListener("mouseleave",h),t.addEventListener("focus",f),t.addEventListener("blur",h),t.addEventListener("pointerdown",m),r.set(t,{mouseenter:f,mouseleave:h,focus:f,blur:h,pointerdown:m})}),u.forEach(function(e){f(e,null)}),function(){r.forEach(function(e,t){Object.keys(e).forEach(function(o){t.removeEventListener(o,e[o])})}),i.forEach(window.clearTimeout),s.forEach(function(e){gsap.killTweensOf(e.targets)}),o.forEach(function(e){gsap.killTweensOf(e),e.classList.remove("hu-link","hu-ready"),delete e.dataset.huRestColor;const t=n.get(e);t?e.style.setProperty("--hu-color",t):e.style.removeProperty("--hu-color")}),a.forEach(function(e){e.remove()}),u.forEach(function(e){e.classList.remove("is-active"),e.style.removeProperty("--active-color")}),l.forEach(function(e,t){t.textContent=e}),r.clear(),s.clear(),i.clear(),c.clear(),l.clear(),a.length=0}})});window.gsapContext=t});
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

:root {
    /* Paper and ink, plus three printer's colours. The three exist only so the
       three underline materials can be told apart at a glance. */
    --page: #f4f2ec;
    --ink: #17171a;
    --muted: rgba(23, 23, 26, 0.63);
    --ink-blue: #2f5bd7;
    --terracotta: #b8532a;
    --moss: #37675a;
    --plum: #6d4a7e;
    --accent: var(--ink-blue);
    color-scheme: light;
}

html,
body {
    min-height: 100%;
}

html {
    background: var(--page);
    scroll-behavior: auto;
}

body {
    min-height: 100svh;
    overflow-x: hidden;
    background: var(--page);
    color: var(--ink);
    font-family: system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
    -webkit-font-smoothing: antialiased;
}

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

/* Full-bleed ground, three links, one cue line. */
.stage {
    display: grid;
    align-content: center;
    justify-items: start;
    min-height: 100svh;
    padding: clamp(1.5rem, 7vw, 5rem);
}

.cue {
    margin-bottom: clamp(1.4rem, 4vh, 2.6rem);
    color: var(--muted);
    font-size: clamp(0.78rem, 1vw, 0.9rem);
}

.links {
    display: grid;
    justify-items: start;
    gap: clamp(0.9rem, 3.2vh, 2rem);
}

.links > a {
    --hu-color: var(--ink-blue);
    position: relative;
    display: inline-block;
    max-width: 100%;
    color: var(--ink);
    font-size: clamp(2.6rem, 8.6vw, 6.5rem);
    font-weight: 600;
    line-height: 1;
    letter-spacing: -0.04em;
    /* The static underline is the no-JS state; the script removes it via
       .hu-ready once the animated decoration is in place. */
    text-decoration-color: var(--hu-color);
    text-decoration-thickness: 0.07em;
    text-underline-offset: 0.14em;
    white-space: nowrap;
    isolation: isolate;
}

.links > a:nth-child(2) {
    --hu-color: var(--terracotta);
}

.links > a:nth-child(3) {
    --hu-color: var(--moss);
}

.links > a:nth-child(4) {
    --hu-color: var(--plum);
}

.links > a.hu-ready {
    text-decoration: none;
}

.links > a:focus-visible {
    outline: 2px solid var(--hu-color);
    outline-offset: 0.2em;
}

/* Injected decoration. Each sits on the link's own box, so the three
   materials share one geometry and differ only in how they animate. */
.hu-line,
.hu-fill,
.hu-wave,
.hu-travel {
    position: absolute;
    left: 0;
    width: 100%;
    color: var(--hu-color);
    pointer-events: none;
}

.hu-line {
    bottom: -0.04em;
    height: 0.075em;
    overflow: hidden;
}

.hu-line__track {
    display: block;
    width: 100%;
    height: 100%;
    background: var(--hu-color);
}

.hu-fill {
    bottom: -0.07em;
    height: 0.19em;
    z-index: 0;
    background: var(--hu-color);
    clip-path: polygon(0 25%, 7% 12%, 19% 24%, 34% 8%, 49% 22%, 63% 10%, 79% 20%, 91% 5%, 100% 22%, 99% 88%, 84% 74%, 67% 93%, 52% 77%, 36% 91%, 18% 76%, 0 90%);
}

.hu-wave {
    bottom: -0.105em;
    height: 0.13em;
    min-height: 8px;
    overflow: visible;
}

/* The travelling wave is drawn wider than the link and slides sideways
   forever, so the rail has to clip it: that clip is what hides the seam. */
.hu-travel {
    bottom: -0.115em;
    height: 0.15em;
    min-height: 9px;
    overflow: hidden;
}

.hu-travel svg {
    display: block;
    overflow: visible;
}

@media (max-width: 600px) {
    .links > a {
        font-size: clamp(2.4rem, 13vw, 3.6rem);
    }
}

@media (prefers-reduced-motion: reduce) {
    *,
    *::before,
    *::after {
        scroll-behavior: auto !important;
        animation-duration: 0.01ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.01ms !important;
    }

    a[data-underline] {
        color: var(--ink);
        text-decoration-line: underline;
        text-decoration-color: var(--hu-color, var(--ink-blue));
        text-decoration-thickness: 0.07em;
        text-underline-offset: 0.14em;
        transform: none !important;
    }

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

Four GSAP-powered underline materials for semantic links: an exit-through line, a marker sweep, a hand-drawn wave, and a travelling wave that keeps rolling for as long as the link is held. Each activation also coordinates the link colour and horizontal offset; grouped links can update a small active index.

Quick Start

1. Add to your HTML <head>:

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

2. Add before the closing </body> tag:

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

3. Add data-underline to semantic links in your <body>:

<a href="/events/night" data-underline="slide">After Dark</a>
<a href="/events/radio" data-underline="fill">Radio Body</a>
<a href="/events/riot" data-underline="wave">Soft Riot</a>
<a href="/events/long" data-underline="travel">Long Wave</a>

The script injects all decorative spans and SVG markup automatically.

Options

Attribute Values Default Description
data-underline slide, fill, wave, travel slide Selects the exit-through line, marker sweep, hand-drawn wave, or travelling wave
data-underline-color Any CSS colour --hu-color / site accent Sets the underline and active text colour for one link
data-underline-stage Present or absent Absent Groups links so a new active item settles the previous one and can drive an index
data-active-index Present or absent Absent Marks an optional visual counter inside the nearest underline stage

slide, fill and wave draw once and settle. travel is the only one that keeps moving: it loops until the pointer leaves or the link loses focus.

Examples

Standalone Links

Add to your HTML <body>:

<nav aria-label="Sections">
  <a href="/work" data-underline>Work</a>
  <a href="/studio" data-underline="fill" data-underline-color="#b8532a">Studio</a>
  <a href="/contact" data-underline="wave" data-underline-color="#37675a">Contact</a>
  <a href="/notes" data-underline="travel" data-underline-color="#6d4a7e">Notes</a>
</nav>

Chasing Programme Index

The stage integration is optional. It lets focus or pointer movement settle the previously active link before playing the next treatment.

Add to your HTML <body>:

<section data-underline-stage>
  <nav aria-label="Programme">
    <a href="/after-dark" data-underline="slide" data-underline-color="#2f5bd7">After Dark</a>
    <a href="/radio-body" data-underline="fill" data-underline-color="#b8532a">Radio Body</a>
    <a href="/soft-riot" data-underline="wave" data-underline-color="#37675a">Soft Riot</a>
    <a href="/long-wave" data-underline="travel" data-underline-color="#6d4a7e">Long Wave</a>
  </nav>

  <p aria-hidden="true"><span data-active-index>01</span>/03</p>
</section>

Keep the index decorative (aria-hidden="true") when it only repeats the link position. The anchors remain the accessible navigation.

CSS Hooks

Class Description
.hu-ready Added while the animated enhancement is active
.hu-line Clipped shell for the exit-through line
.hu-line__track Solid line that travels through the shell
.hu-fill Irregular marker underline
.hu-wave Inline SVG wave
.hu-travel Clipping rail for the travelling wave; its overflow: hidden is what hides the loop's seam
.is-active Added to a data-underline-stage while one link is active

Each treatment reads --hu-color. A stage also receives --active-color, which can style borders, counters, or other small response elements.

Accessibility

  • Real <a> elements retain native link and keyboard behaviour.
  • focus and blur mirror mouseenter and mouseleave.
  • Touch or pen pointerdown plays the treatment; focused links remain active until blur.
  • :focus-visible should remain clearly styled in your CSS.
  • Injected decoration is marked aria-hidden="true".
  • The travel loop runs only while a link is hovered or focused, so nothing moves on an idle page.
  • With prefers-reduced-motion: reduce, JavaScript skips animation and CSS shows static coloured underlines.
  • Without JavaScript, ordinary CSS text decoration remains visible; only .hu-ready removes it during enhancement.

Cleanup

The GSAP context is exposed for SPA teardown.

Add to your JavaScript when unmounting the view:

window.gsapContext.revert();

Reverting removes every pointer and focus listener, clears touch timers, kills active tweens, removes injected decoration, and restores stage/index state.

Dependencies

  • GSAP 3.12+ core
  • No GSAP plugins
  • Modern browser with ES6 support