Back to Gallery

Hover Underline

FREE

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

beginner
4 more details
hover-effectmicro-interactionunderlinesvg-animation

About This Effect

A tactile navigation underline effect that gives each link one of three genuinely different materials: an exit-through rule, a rough marker stroke, or a hand-drawn SVG wave. 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.

What's Included

8 items
  • Three preserved data variants: slide, fill, and wave
  • 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
  • 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 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
<!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">
    <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@500;600;700;800&display=swap" rel="stylesheet">
</head>
<body>
    <main class="showcase-shell">
        <header class="showcase-toolbar">
            <p class="showcase-prompt">
                <span class="showcase-prompt__dot" aria-hidden="true"></span>
                <strong>Sweep or tab down</strong>
                <span>Three links, three materials</span>
            </p>
        </header>

        <section class="programme" data-underline-stage aria-label="Night Shift cultural programme navigation">
            <div class="programme__masthead">
                <p>Night Shift <span>/</span> Cultural programme</p>
                <p>Oct—Dec <span>/</span> Citywide</p>
            </div>

            <nav class="programme__nav" aria-label="Programme">
                <a id="programme-01" href="#programme-01" data-underline="slide" data-underline-color="#c8ff00">
                    After Dark
                </a>
                <a id="programme-02" href="#programme-02" data-underline="fill" data-underline-color="#ff6b2c">
                    Radio Body
                </a>
                <a id="programme-03" href="#programme-03" data-underline="wave" data-underline-color="#22d3ee">
                    Soft Riot
                </a>
            </nav>

            <div class="programme__footer" aria-hidden="true">
                <p>Live art · sound · moving image</p>
                <p class="programme-index"><span data-active-index>01</span><span>/03</span></p>
            </div>
        </section>
    </main>

    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
    <script src="assets/script.js"></script>
</body>
</html>
/**
 * Hover Underline
 *
 * Three material underline treatments for semantic links: an exit-through
 * line, a marker sweep, and a hand-drawn SVG wave. 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);

                const length = path.getTotalLength();
                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 });
                    }
                };
            }

            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 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,a=new Map,s=[],i=new Map,c=new Map,u=new Map,l=new Set;function d(e){return e.setAttribute("aria-hidden","true"),s.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",a=t?getComputedStyle(t).getPropertyValue("--hu-color").trim():"";e.classList.toggle("is-active",Boolean(t)),a?e.style.setProperty("--active-color",a):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=a.get(e),o=e.closest("[data-underline-stage]"),n=o&&c.get(o);if(n&&n!==e){const e=a.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=a.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]"),s=getComputedStyle(t).color;let c;if(t.dataset.huRestColor=s,t.classList.add("hu-link","hu-ready"),n){l.add(n);const e=n.querySelector("[data-active-index]");e&&!u.has(e)&&u.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=n.getTotalLength();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):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),a.set(t,c);const f=function(){p(t)},m=function(){g(t)},h=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",m),t.addEventListener("focus",f),t.addEventListener("blur",m),t.addEventListener("pointerdown",h),r.set(t,{mouseenter:f,mouseleave:m,focus:f,blur:m,pointerdown:h})}),l.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),a.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")}),s.forEach(function(e){e.remove()}),l.forEach(function(e){e.classList.remove("is-active"),e.style.removeProperty("--active-color")}),u.forEach(function(e,t){t.textContent=e}),r.clear(),a.clear(),i.clear(),c.clear(),u.clear(),s.length=0}})});window.gsapContext=t});
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

:root {
    --black: #0a0a0a;
    --paper: #f4f1e8;
    --muted: #aaa79f;
    --line: #353535;
    --lime: #c8ff00;
    --orange: #ff6b2c;
    --cyan: #22d3ee;
    --accent: var(--lime);
    color-scheme: dark;
}

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

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

body {
    min-height: 100svh;
    overflow-x: hidden;
    background: var(--black);
    color: var(--paper);
    font-family: 'Space Grotesk', system-ui, sans-serif;
    -webkit-font-smoothing: antialiased;
}

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

::-webkit-scrollbar {
    width: 10px;
}

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

::-webkit-scrollbar-thumb {
    border: 2px solid var(--black);
    border-radius: 999px;
    background: #454545;
}

.showcase-shell {
    width: 100%;
    min-height: 100svh;
    display: grid;
    grid-template-rows: auto 1fr;
    padding: clamp(0.75rem, 2vw, 1.5rem);
}

.showcase-toolbar {
    display: flex;
    align-items: center;
    min-height: 2rem;
    padding-bottom: clamp(0.65rem, 1.5vw, 1rem);
}

.showcase-prompt {
    display: flex;
    align-items: center;
    gap: 0.65rem;
    color: var(--muted);
    font-family: 'JetBrains Mono', monospace;
    font-size: clamp(0.58rem, 0.85vw, 0.7rem);
    letter-spacing: 0.08em;
    text-transform: uppercase;
}

.showcase-prompt strong {
    color: var(--paper);
    font-weight: 600;
}

.showcase-prompt__dot {
    width: 0.45rem;
    aspect-ratio: 1;
    border-radius: 50%;
    background: var(--lime);
    box-shadow: 0 0 0 4px rgb(200 255 0 / 12%);
}

.programme {
    --active-color: var(--line);
    min-height: 0;
    display: grid;
    grid-template-rows: auto 1fr auto;
    border: 1px solid var(--line);
    background:
        linear-gradient(90deg, transparent 49.9%, rgb(255 255 255 / 3%) 50%, transparent 50.1%),
        var(--black);
    transition: border-color 0.3s ease;
}

.programme.is-active {
    border-color: var(--active-color);
}

.programme__masthead,
.programme__footer {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 1rem;
    padding: clamp(0.7rem, 1.5vw, 1rem) clamp(0.85rem, 2.2vw, 1.75rem);
    color: var(--muted);
    font-family: 'JetBrains Mono', monospace;
    font-size: clamp(0.56rem, 0.85vw, 0.72rem);
    letter-spacing: 0.08em;
    line-height: 1.3;
    text-transform: uppercase;
}

.programme__masthead {
    border-bottom: 1px solid var(--line);
}

.programme__footer {
    border-top: 1px solid var(--line);
}

.programme__masthead span {
    margin-inline: 0.35rem;
    color: var(--paper);
}

.programme__nav {
    min-height: 0;
    display: grid;
    grid-template-rows: repeat(3, minmax(0, 1fr));
    background:
        linear-gradient(var(--line), var(--line)) 0 33.333% / 100% 1px no-repeat,
        linear-gradient(var(--line), var(--line)) 0 66.666% / 100% 1px no-repeat;
}

.programme__nav > a {
    --hu-color: var(--lime);
    position: relative;
    min-width: 0;
    align-self: center;
    display: inline-block;
    width: fit-content;
    max-width: calc(100% - clamp(1.7rem, 4.4vw, 3.5rem));
    margin-left: clamp(0.85rem, 2.2vw, 1.75rem);
    color: var(--paper);
    font-family: 'Syne', system-ui, sans-serif;
    font-size: clamp(2.8rem, 8.3vw, 6.6rem);
    font-weight: 700;
    line-height: 0.88;
    letter-spacing: -0.065em;
    text-decoration-color: var(--hu-color);
    text-decoration-thickness: 0.08em;
    text-underline-offset: 0.12em;
    white-space: nowrap;
    isolation: isolate;
}

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

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

.programme__nav > a::after {
    content: '0' counter(programme-link);
    counter-increment: programme-link;
    position: absolute;
    left: calc(100% + 0.7rem);
    top: 50%;
    color: var(--muted);
    font-family: 'JetBrains Mono', monospace;
    font-size: clamp(0.52rem, 0.8vw, 0.68rem);
    font-weight: 500;
    letter-spacing: 0;
    opacity: 0.65;
    transform: translateY(-50%);
}

.programme__nav {
    counter-reset: programme-link;
}

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

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

.hu-line,
.hu-fill,
.hu-wave {
    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;
}

.programme-index {
    display: flex;
    align-items: baseline;
    min-width: 4.3em;
    justify-content: flex-end;
    color: var(--muted);
    font-size: clamp(0.7rem, 1.1vw, 0.9rem);
    overflow: hidden;
}

.programme-index [data-active-index] {
    display: inline-block;
    color: var(--active-color);
    font-size: 1.55em;
    font-weight: 700;
    transition: color 0.25s ease;
}

@media (max-width: 600px) {
    .showcase-shell {
        padding: 0.65rem;
    }

    .showcase-prompt span:last-child {
        display: none;
    }

    .programme {
        background: var(--black);
    }

    .programme__masthead p:last-child {
        display: none;
    }

    .programme__nav > a {
        max-width: calc(100% - 1.5rem);
        margin-left: 0.75rem;
        font-size: clamp(2.35rem, 12vw, 3.35rem);
        white-space: normal;
    }

    .programme__nav > a::after {
        left: auto;
        right: 0;
        top: 50%;
        transform: translateY(-50%);
    }

    .programme__footer {
        padding-inline: 0.75rem;
    }

    .programme__footer > p:first-child {
        max-width: 12rem;
    }
}

@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(--paper);
        text-decoration-line: underline;
        text-decoration-color: var(--hu-color, var(--lime));
        text-decoration-thickness: 0.08em;
        text-underline-offset: 0.12em;
        transform: none !important;
    }

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

Three GSAP-powered underline materials for semantic links: an exit-through line, a marker sweep, and a hand-drawn wave. 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.14.2/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>

The script injects all decorative spans and SVG markup automatically.

Options

Attribute Values Default Description
data-underline slide, fill, wave slide Selects the exit-through line, marker sweep, or hand-drawn 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

All three original data-underline values are preserved.

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="#ff6b2c">Studio</a>
  <a href="/contact" data-underline="wave" data-underline-color="#22d3ee">Contact</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="#c8ff00">After Dark</a>
    <a href="/radio-body" data-underline="fill" data-underline-color="#ff6b2c">Radio Body</a>
    <a href="/soft-riot" data-underline="wave" data-underline-color="#22d3ee">Soft Riot</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
.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".
  • 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

Your Cart

Your cart is empty

Browse Effects