# Hover Underline

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

Canonical: https://gsapvault.com/effects/hover-underline
Live demo: https://gsapvault.com/demos/hover-underline/index.html

| Property | Value |
|----------|-------|
| Type | effect |
| Tier | free |
| Price | Free |
| Difficulty | beginner |
| Plugins | Core GSAP only |
| Techniques | hover-effect, micro-interaction, underline, svg-animation |
| Uses Lenis | No |

## Overview

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.

## Features

- 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

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

## Vibe-Code Ready Setup

This effect includes `START-HERE-AI.md`, a product-specific copy-paste setup prompt for Cursor, Claude Code, ChatGPT, GitHub Copilot, Windsurf, and other coding assistants. It tells the assistant to inspect the existing stack, integrate the supplied files, preserve the design, scope selectors, retain accessibility and responsive behaviour, add framework-appropriate GSAP cleanup, and report what it tested.

[How AI-assisted setup works](https://gsapvault.com/vibe-coding)

## How It Works

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.

## Documentation

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>`:**

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

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

```html
<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>`:**

```html
<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>`:**

```html
<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>`:**

```html
<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:**

```javascript
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

## Source Code

This effect is free. The complete source is included below.

### index.html

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

### assets/style.css

```css
* {
    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;
    }
}
```

### assets/script.js

```js
/**
 * 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;
});
```

---

From [GSAP Vault](https://gsapvault.com): production-ready GSAP animation effects. Full catalog for agents: https://gsapvault.com/llms-full.txt
