Menu bar

A toolbar where the picked tool and the list style morph into place, the action plays on press, and every button shows its name.

Take it with you

This demo also exists as a standalone project — npm install && npm run dev, no changes needed.

npx giget@latest gh:lordicondev/ui-components/menu-bar#standalone menu-bar

Copies the menu-bar folder of the standalone branch — where every demo lives on its own — into ./menu-bar.

How it works

Define the element, put the icon in the markup, set the control's state. Then the parts that make this demo different.

Define the element Built-in triggers only. What each icon follows is written in its markup.
import { defineElement } from '@lordicon/element';

// Registers <lord-icon> with its built-in triggers. The markup says what each icon follows.
defineElement();
One tool A real radio underneath. data-selected is what the icon and the stylesheet read.
html
<!-- One tool. The radio is the real control, only invisible. data-selected
     mirrors its checked state for the icon and the stylesheet. The
     tooltip span is the radio's name; tooltip.css draws it. -->
<label class="tool" data-selected="true">
    <input
        class="tool__input visually-hidden"
        type="radio"
        name="tool"
        value="brush"
        checked
    />

    <lord-icon
        class="tool__mark"
        current-color
        src="icons/brush-alt.json"
        trigger="follow(data-selected)"
        target=".tool"
        state="morph-change"
        aria-hidden="true"
    >
        <img alt="" src="icons/brush-alt.morph-change.svg" />
    </lord-icon>

    <span class="tooltip">Brush</span>
</label>
One list button A toggle. aria-pressed is an attribute already, so the icon follows it with nothing in between.
html
<!-- A toggle: aria-pressed is its state, and the icon follows it. -->
<button class="list" type="button" aria-pressed="false">
    <lord-icon
        class="list__mark"
        current-color
        src="icons/bullet-list.json"
        trigger="follow(aria-pressed)"
        target=".list"
        state="morph-select"
        aria-hidden="true"
    >
        <img alt="" src="icons/bullet-list.svg" />
    </lord-icon>

    <span class="tooltip">Bullet list</span>
</button>
One action button No attribute to follow: nothing about it changes after a press.
html
<!-- An action, not a state: no attribute to follow, the icon plays once. -->
<button class="action" type="button">
    <lord-icon
        class="action__mark"
        current-color
        src="icons/three-dots-vertical.json"
        loading="interaction"
        trigger="click"
        target=".action"
        aria-hidden="true"
    >
        <img alt="" src="icons/three-dots-vertical.svg" />
    </lord-icon>

    <span class="tooltip">More options</span>
</button>
Copy the radio's choice into an attribute checked is a property, and nothing can watch a property.
const tools = [...document.querySelectorAll<HTMLElement>('.tool')];

// A radio's `checked` is a property, and a MutationObserver cannot watch a property. So
// the group's choice is copied onto each label as data-selected, which the icon and the
// stylesheet read. `change` fires for a click and for an arrow key alike.
function follow(): void {
    for (const tool of tools) {
        const input = tool.querySelector<HTMLInputElement>('.tool__input')!;
        tool.dataset.selected = String(input.checked);
    }
}

document.querySelector('.menu-bar__tools')!.addEventListener('change', follow);
One kind of list or none Pressing one list button releases the other; pressing it again releases it.
const lists = [...document.querySelectorAll<HTMLButtonElement>('.list')];

// A paragraph is one kind of list or none: pressing one releases the other, and pressing
// it again releases it. aria-pressed is an attribute, so the icons follow it as it is.
for (const list of lists) {
    list.addEventListener('click', () => {
        const on = list.getAttribute('aria-pressed') !== 'true';
        for (const other of lists) other.setAttribute('aria-pressed', String(other === list && on));
    });
}
The selected tool Three custom properties; the icon takes its colour through current-color.
css
/* Border, fill and icon colour, at rest, hovered and selected. The icon takes `color`
   through current-color. No transition: only the morph animates. A pressed list button
   looks like a selected tool. */
.tool,
.list {
    --tool-line: var(--border);
    --tool-fill: var(--surface);
    --tool-mark: var(--ink);
    --icon-filter: var(--filter-ink);
}

.tool:hover,
.list:hover {
    --tool-line: var(--border-strong);
}

/* The selected tool is tinted, not outlined. */
.tool[data-selected='true'],
.list[aria-pressed='true'] {
    --tool-line: var(--accent-surface);
    --tool-fill: var(--accent-surface);
    --tool-mark: var(--accent);
    --icon-filter: var(--filter-accent);
}

Every file

The demo's own files, whole, at the paths the export uses.

index.html 171 lines
html
<!doctype html>
<html lang="en">
    <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <title>Menu bar</title>
        <link rel="stylesheet" href="./menu-bar.css" />
        <script type="module" src="./main.ts"></script>
    </head>
    <body>
        <main class="demo">
            <div class="menu-bar">
                <fieldset class="menu-bar__tools">
                    <legend class="visually-hidden">Tool</legend>

                    <!-- One tool. The radio is the real control, only invisible. data-selected
                         mirrors its checked state for the icon and the stylesheet. The
                         tooltip span is the radio's name; tooltip.css draws it. -->
                    <label class="tool" data-selected="true">
                        <input
                            class="tool__input visually-hidden"
                            type="radio"
                            name="tool"
                            value="brush"
                            checked
                        />

                        <lord-icon
                            class="tool__mark"
                            current-color
                            src="icons/brush-alt.json"
                            trigger="follow(data-selected)"
                            target=".tool"
                            state="morph-change"
                            aria-hidden="true"
                        >
                            <img alt="" src="icons/brush-alt.morph-change.svg" />
                        </lord-icon>

                        <span class="tooltip">Brush</span>
                    </label>

                    <label class="tool" data-selected="false">
                        <input
                            class="tool__input visually-hidden"
                            type="radio"
                            name="tool"
                            value="fill"
                        />

                        <lord-icon
                            class="tool__mark"
                            current-color
                            src="icons/paint-bucket.json"
                            trigger="follow(data-selected)"
                            target=".tool"
                            state="morph-select"
                            aria-hidden="true"
                        >
                            <img alt="" src="icons/paint-bucket.svg" />
                        </lord-icon>

                        <span class="tooltip">Fill</span>
                    </label>

                    <label class="tool" data-selected="false">
                        <input
                            class="tool__input visually-hidden"
                            type="radio"
                            name="tool"
                            value="pencil"
                        />

                        <lord-icon
                            class="tool__mark"
                            current-color
                            src="icons/pencil.json"
                            trigger="follow(data-selected)"
                            target=".tool"
                            state="morph-select"
                            aria-hidden="true"
                        >
                            <img alt="" src="icons/pencil.svg" />
                        </lord-icon>

                        <span class="tooltip">Pencil</span>
                    </label>

                    <label class="tool" data-selected="false">
                        <input
                            class="tool__input visually-hidden"
                            type="radio"
                            name="tool"
                            value="pen"
                        />

                        <lord-icon
                            class="tool__mark"
                            current-color
                            src="icons/pen-nib.json"
                            trigger="follow(data-selected)"
                            target=".tool"
                            state="morph-select"
                            aria-hidden="true"
                        >
                            <img alt="" src="icons/pen-nib.svg" />
                        </lord-icon>

                        <span class="tooltip">Pen</span>
                    </label>
                </fieldset>

                <span class="menu-bar__rule"></span>

                <div class="menu-bar__lists" role="group" aria-label="List">
                    <!-- A toggle: aria-pressed is its state, and the icon follows it. -->
                    <button class="list" type="button" aria-pressed="false">
                        <lord-icon
                            class="list__mark"
                            current-color
                            src="icons/bullet-list.json"
                            trigger="follow(aria-pressed)"
                            target=".list"
                            state="morph-select"
                            aria-hidden="true"
                        >
                            <img alt="" src="icons/bullet-list.svg" />
                        </lord-icon>

                        <span class="tooltip">Bullet list</span>
                    </button>

                    <button class="list" type="button" aria-pressed="false">
                        <lord-icon
                            class="list__mark"
                            current-color
                            src="icons/check-list.json"
                            trigger="follow(aria-pressed)"
                            target=".list"
                            state="morph-change"
                            aria-hidden="true"
                        >
                            <img alt="" src="icons/check-list.svg" />
                        </lord-icon>

                        <span class="tooltip">Checklist</span>
                    </button>
                </div>

                <span class="menu-bar__rule"></span>

                <!-- An action, not a state: no attribute to follow, the icon plays once. -->
                <button class="action" type="button">
                    <lord-icon
                        class="action__mark"
                        current-color
                        src="icons/three-dots-vertical.json"
                        loading="interaction"
                        trigger="click"
                        target=".action"
                        aria-hidden="true"
                    >
                        <img alt="" src="icons/three-dots-vertical.svg" />
                    </lord-icon>

                    <span class="tooltip">More options</span>
                </button>
            </div>
        </main>
    </body>
</html>
main.ts 34 lines
import { defineElement } from '@lordicon/element';

// Registers <lord-icon> with its built-in triggers. The markup says what each icon follows.
defineElement();

import { tooltips } from '@shared/ui/tooltip.ts';

const tools = [...document.querySelectorAll<HTMLElement>('.tool')];

// A radio's `checked` is a property, and a MutationObserver cannot watch a property. So
// the group's choice is copied onto each label as data-selected, which the icon and the
// stylesheet read. `change` fires for a click and for an arrow key alike.
function follow(): void {
    for (const tool of tools) {
        const input = tool.querySelector<HTMLInputElement>('.tool__input')!;
        tool.dataset.selected = String(input.checked);
    }
}

document.querySelector('.menu-bar__tools')!.addEventListener('change', follow);

const lists = [...document.querySelectorAll<HTMLButtonElement>('.list')];

// A paragraph is one kind of list or none: pressing one releases the other, and pressing
// it again releases it. aria-pressed is an attribute, so the icons follow it as it is.
for (const list of lists) {
    list.addEventListener('click', () => {
        const on = list.getAttribute('aria-pressed') !== 'true';
        for (const other of lists) other.setAttribute('aria-pressed', String(other === list && on));
    });
}

// Every button's name is the span inside it; tooltips() shows it on hover and focus.
tooltips();
menu-bar.css 110 lines
css
@import '@shared/styles/index.css';
@import '@shared/ui/tooltip.css';

.demo {
    display: grid;
    place-items: center;
    min-height: 100dvh;
    padding: var(--space-64) var(--gutter);
}

.menu-bar {
    display: flex;
    align-items: center;
    gap: var(--space-16);
    padding: var(--space-12);
    border-radius: var(--radius-lg);
    background: var(--surface);
    box-shadow: var(--shadow-sm);
}

/* The tools are a fieldset, without the browser's frame. */
.menu-bar__tools,
.menu-bar__lists {
    display: flex;
    align-items: center;
    gap: var(--space-8);
    margin: 0;
    padding: 0;
    border: 0;
}

/* As tall as the buttons beside it. */
.menu-bar__rule {
    align-self: stretch;
    width: 1px;
    background: var(--border);
}

/* Border, fill and icon colour, at rest, hovered and selected. The icon takes `color`
   through current-color. No transition: only the morph animates. A pressed list button
   looks like a selected tool. */
.tool,
.list {
    --tool-line: var(--border);
    --tool-fill: var(--surface);
    --tool-mark: var(--ink);
    --icon-filter: var(--filter-ink);
}

.tool:hover,
.list:hover {
    --tool-line: var(--border-strong);
}

/* The selected tool is tinted, not outlined. */
.tool[data-selected='true'],
.list[aria-pressed='true'] {
    --tool-line: var(--accent-surface);
    --tool-fill: var(--accent-surface);
    --tool-mark: var(--accent);
    --icon-filter: var(--filter-accent);
}

.tool,
.list,
.action {
    display: grid;
    place-items: center;
    padding: var(--space-12);
    border: 1px solid var(--border);
    border-radius: var(--radius-lg);
    background: var(--surface);
    cursor: pointer;
}

/* No text selection on a double click. A label does not get this from base.css. */
.tool,
.list {
    border-color: var(--tool-line);
    background: var(--tool-fill);
    -webkit-user-select: none;
    user-select: none;
}

.action:hover {
    border-color: var(--border-strong);
}

.tool__mark,
.list__mark,
.action__mark {
    width: var(--icon-size-24);
    height: var(--icon-size-24);
}

.tool__mark,
.list__mark {
    color: var(--tool-mark);
}

.action__mark {
    color: var(--ink);
    --icon-filter: var(--filter-ink);
}

/* The radio is invisible, so its focus ring goes on the label. */
.tool:has(.tool__input:focus-visible) {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
}
What it imports 6 shared files
shared/styles/base.css 115 lines
css
/*
 * Fonts, reset and default typography. Nothing control-specific: every demo styles its
 * own control. Only the UI face is here; the mono face is the portal's.
 */
@font-face {
    font-family: 'Figtree';
    font-style: normal;
    font-weight: 300 900;
    font-display: swap;
    src: url('../fonts/figtree-latin.woff2') format('woff2');
    unicode-range:
        U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308,
        U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

@font-face {
    font-family: 'Figtree';
    font-style: normal;
    font-weight: 300 900;
    font-display: swap;
    src: url('../fonts/figtree-latin-ext.woff2') format('woff2');
    unicode-range:
        U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329,
        U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113,
        U+2C60-2C7F, U+A720-A7FF;
}

/* Arial with Figtree's measurements, for the moment before Figtree arrives, so the text does
   not move when it does. Measured on Figtree at 400 and 500. */
@font-face {
    font-family: 'Figtree fallback';
    src: local('Arial'), local('Liberation Sans');
    size-adjust: 100.3%;
    ascent-override: 94.7%;
    descent-override: 24.9%;
    line-gap-override: 0%;
}

*,
*::before,
*::after {
    box-sizing: border-box;
}

/* An author `display` on a component would beat the UA rule for [hidden], and then
   `el.hidden = true` would stop working. Settled once here. */
[hidden] {
    display: none !important;
}

body {
    margin: 0;
    background: var(--surface);
    color: var(--ink);
    --icon-filter: var(--filter-ink);
    font-family: var(--font-ui);
    font-size: var(--text-base);
    line-height: var(--leading-base);
    -webkit-font-smoothing: antialiased;
}

button,
input,
textarea,
select {
    font: inherit;
    color: inherit;
}

/* A second click on a button would otherwise select its label. */
button {
    -webkit-user-select: none;
    user-select: none;
}

:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
}

/*
 * In the accessibility tree but not on screen: labels and legends that the visual design
 * has no room for. Clipped rather than `display: none`, which would remove it from the
 * tree too, and it also hides live radio inputs that must stay reachable by keyboard.
 */
.visually-hidden {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
}

/*
 * Until the script defines <lord-icon> it is an unknown inline element with no size. These
 * rules give it the box it will have, so nothing moves when it is defined.
 */
lord-icon {
    display: inline-block;
    width: var(--icon-size-32);
    height: var(--icon-size-32);
}

/* A placeholder inside the icon fills it until the icon is ready. */
lord-icon:not(:defined) > img {
    display: block;
    width: 100%;
    height: 100%;
}

/* Placeholders are black. Set --icon-filter wherever an icon's colour is set. */
lord-icon > img {
    filter: var(--icon-filter);
}
shared/styles/index.css 4 lines
css
/* The one stylesheet a demo imports. */
@import './palette.css';
@import './tokens.css';
@import './base.css';
shared/styles/palette.css 87 lines
css
/*
 * Generated from the Figma token export.
 * Raw values only — roles live in tokens.css. Edit the Figma variables, not this file.
 */
:root {
    /* color/ink */
    --gray-0: #FFFFFF;
    --gray-50: #F7F8FA;
    --gray-100: #EEF0F3;
    --gray-200: #DEE1E6;
    --gray-300: #C3C7D0;
    --gray-400: #9BA1AD;
    --gray-500: #767C89;
    --gray-600: #565C68;
    --gray-700: #3D414B;
    --gray-800: #282B33;
    --gray-900: #16181D;
    --gray-950: #0A0B0E;

    /* color/signal */
    --brand-100: #FFEEE9;
    --brand-200: #FFD7CA;
    --brand-300: #FFB199;
    --brand-400: #FF7D5C;
    --brand-500: #FF5A36;
    --brand-600: #E8431F;
    --brand-700: #C2340F;

    /* color/success */
    --green-100: #EDF9F1;
    --green-300: #A7D9BC;
    --green-700: #15803D;

    /* color/error */
    --red-100: #FDECEC;
    --red-300: #F5B5B5;
    --red-700: #DC2626;

    /* typography/font */
    --font-ui-name: 'Figtree';
    --font-mono-name: 'JetBrains Mono';

    /* typography/size */
    --text-xs: 12px;
    --text-sm: 14px;
    --text-base: 16px;
    --text-lg: 20px;
    --text-xl: 24px;
    --text-2xl: 32px;
    --text-3xl: 40px;

    /* typography/lineHeight */
    --leading-xs: 16px;
    --leading-sm: 20px;
    --leading-base: 24px;
    --leading-lg: 28px;
    --leading-xl: 32px;
    --leading-2xl: 40px;
    --leading-3xl: 48px;

    /* spacing (named after the value, the Figma scale is non-linear) */
    --space-4: 4px;
    --space-8: 8px;
    --space-12: 12px;
    --space-16: 16px;
    --space-24: 24px;
    --space-32: 32px;
    --space-48: 48px;
    --space-64: 64px;
    --space-96: 96px;

    /* size/icon */
    --icon-size-16: 16px;
    --icon-size-20: 20px;
    --icon-size-24: 24px;
    --icon-size-32: 32px;
    --icon-size-40: 40px;
    --icon-size-48: 48px;
    --icon-size-64: 64px;
    --icon-size-96: 96px;

    /* radius */
    --radius-sm: 4px;
    --radius-md: 8px;
    --radius-lg: 12px;
    --radius-pill: 9999px;
}
shared/styles/tokens.css 93 lines
css
/*
 * Design tokens by role. Raw values come from palette.css (generated); which grey is a
 * border and which is muted text is decided here, and so would a dark mode be.
 */
:root {
    /* Text. `--ink*` is always a colour; `--text-*` is always a size. */
    --ink: var(--gray-950);
    /* Text that reads as done or in progress, a step lighter than a heading. */
    --ink-strong: var(--gray-800);
    /* Body copy: a step down from --ink. */
    --ink-soft: var(--gray-600);
    --ink-muted: var(--gray-500);
    --ink-subtle: var(--gray-400);
    /* What has not started yet. */
    --ink-faint: var(--gray-300);
    --ink-inverse: var(--gray-0);

    /* Backgrounds */
    --surface: var(--gray-0);
    --surface-muted: var(--gray-50);
    /* A well inside a surface: the filled shape a single icon sits in. */
    --surface-sunken: var(--gray-100);

    /* Outlines */
    --border: var(--gray-200);
    /* The edge of a box that already has a fill, such as an input. */
    --border-soft: var(--gray-100);
    --border-strong: var(--gray-300);
    --border-accent: var(--brand-400);

    /* Brand accent */
    --accent: var(--brand-500);
    --accent-strong: var(--brand-600);
    --accent-surface: var(--brand-100);
    /* The outline of a tinted box, or a small shape on one. */
    --accent-border: var(--brand-300);
    /* Text or a mark on either of the above. */
    --accent-ink: var(--brand-700);

    /* Status. Warning rides the brand ramp — that is what the designs use. */
    --success: var(--green-700);
    --success-surface: var(--green-100);
    --success-border: var(--green-300);

    --warning: var(--brand-500);
    --warning-surface: var(--brand-100);
    --warning-border: var(--brand-300);

    --danger: var(--red-700);
    --danger-surface: var(--red-100);
    --danger-border: var(--red-300);

    /* Icons. Fed straight into --lord-icon-* by the demos. */
    --icon-color: var(--gray-950);
    --icon-accent: var(--brand-500);

    /* Icon placeholders are black SVGs. Each filter turns black into the colour of the same
       name. Made with a CSS filter generator: make a new one when its colour changes. */
    --filter-ink: invert(2%) sepia(4%) saturate(4817%) hue-rotate(193deg) brightness(114%)
        contrast(96%);
    --filter-ink-soft: invert(33%) sepia(18%) saturate(323%) hue-rotate(182deg) brightness(100%)
        contrast(90%);
    --filter-ink-muted: invert(55%) sepia(23%) saturate(191%) hue-rotate(181deg) brightness(84%)
        contrast(89%);
    --filter-ink-subtle: invert(70%) sepia(11%) saturate(284%) hue-rotate(181deg) brightness(91%)
        contrast(86%);
    --filter-ink-faint: invert(76%) sepia(17%) saturate(99%) hue-rotate(184deg) brightness(99%)
        contrast(100%);
    --filter-ink-inverse: invert(100%);
    --filter-accent: invert(49%) sepia(65%) saturate(3508%) hue-rotate(337deg) brightness(106%)
        contrast(113%);
    --filter-accent-strong: invert(29%) sepia(85%) saturate(1790%) hue-rotate(350deg)
        brightness(94%) contrast(93%);
    --filter-success: invert(38%) sepia(22%) saturate(2045%) hue-rotate(94deg) brightness(92%)
        contrast(84%);
    --filter-warning: var(--filter-accent);
    --filter-danger: invert(48%) sepia(74%) saturate(7139%) hue-rotate(343deg) brightness(84%)
        contrast(105%);

    /* Type stacks. The families themselves are in palette.css. */
    --font-ui:
        var(--font-ui-name), 'Figtree fallback', -apple-system, BlinkMacSystemFont, 'Segoe UI',
        Roboto, sans-serif;
    --font-mono: var(--font-mono-name), ui-monospace, SFMono-Regular, Menlo, monospace;

    /* Side padding of a page. The portal uses the same value, so demo and text line up. */
    --gutter: clamp(var(--space-16), 5vw, var(--space-48));

    /* A card's halo: no offset, so it reads as lift rather than as a light source. */
    --shadow-sm: 0 0 7.5px color-mix(in srgb, var(--gray-400) 20%, transparent);
    /* The same halo under a brand-coloured element. */
    --shadow-accent: 0 0 7.5px color-mix(in srgb, var(--accent-strong) 40%, transparent);
}
shared/ui/tooltip.css 44 lines
css
/*
 * The look of a tooltip. tooltip.ts sets `data-tip` on the control while its name should
 * be visible. The control underneath is the demo's own.
 */

/* The control the tooltip sits in is what it is positioned against. */
:has(> .tooltip) {
    position: relative;
}

/* Hidden with `opacity`, so the span keeps naming the control for a screen reader. It
   rises into place from just below and sinks back out the same way. */
.tooltip {
    position: absolute;
    bottom: calc(100% + var(--space-8));
    left: 50%;
    z-index: 1;
    padding: var(--space-4) var(--space-8);
    border-radius: var(--radius-md);
    background: var(--ink);
    color: var(--ink-inverse);
    font-size: var(--text-xs);
    line-height: var(--leading-xs);
    font-weight: 500;
    white-space: nowrap;
    pointer-events: none;
    opacity: 0;
    translate: -50% var(--space-8);
    transition:
        opacity 120ms ease,
        translate 160ms cubic-bezier(0.3, 0, 0.2, 1);
}

[data-tip] > .tooltip {
    opacity: 1;
    translate: -50% 0;
}

@media (prefers-reduced-motion: reduce) {
    .tooltip {
        translate: -50% 0;
        transition: none;
    }
}
shared/ui/tooltip.ts 91 lines
/**
 * Tooltips for icon buttons. The tooltip is the button's accessible name, made visible:
 *
 *     <button type="button">
 *         <lord-icon … aria-hidden="true"></lord-icon>
 *         <span class="tooltip">Bullet list</span>
 *     </button>
 *
 * tooltip.css keeps the span at `opacity: 0`, so it still names the button for a screen
 * reader. This module sets `data-tip` on the button while the name should be visible.
 */

/** How long the pointer rests on a control before its name shows. */
const OPEN_DELAY = 400;

/** After a tooltip closes, the next one in the row opens at once for this long. */
const WARM = 300;

/** Wires every `.tooltip` under `root` to the element it sits in. Called once. */
export function tooltips(root: ParentNode = document): void {
    const anchors = [...root.querySelectorAll<HTMLElement>('.tooltip')]
        .map((tooltip) => tooltip.parentElement)
        .filter((anchor) => anchor !== null);

    /** The anchor whose name is shown or about to be, and the timer showing it. */
    let wanted: HTMLElement | null = null;
    let opening: ReturnType<typeof setTimeout> | undefined;

    /** True just after a tooltip closed. Moving along a toolbar should not wait again. */
    let warm = false;
    let cooling: ReturnType<typeof setTimeout> | undefined;

    /** The anchor whose tooltip Escape closed; left alone until the pointer moves on. */
    let dismissed: HTMLElement | null = null;

    const focused = (anchor: HTMLElement) => anchor.matches(':focus-visible, :has(:focus-visible)');

    /** Closes `anchor`'s tooltip, if it is the one showing. Other anchors are left alone. */
    function close(anchor: HTMLElement): void {
        if (wanted !== anchor) return;

        clearTimeout(opening);
        wanted = null;
        if (!anchor.hasAttribute('data-tip')) return;

        anchor.removeAttribute('data-tip');
        warm = true;
        clearTimeout(cooling);
        cooling = setTimeout(() => (warm = false), WARM);
    }

    function open(anchor: HTMLElement, immediately: boolean): void {
        if (anchor === wanted || anchor === dismissed) return;

        if (wanted) close(wanted);
        wanted = anchor;

        const wait = immediately || warm ? 0 : OPEN_DELAY;
        opening = setTimeout(() => anchor.setAttribute('data-tip', ''), wait);
    }

    for (const anchor of anchors) {
        // A touch is not a hover: nothing would take the tooltip away again.
        anchor.addEventListener('pointerenter', (event) => {
            if (event.pointerType !== 'touch') open(anchor, false);
        });

        anchor.addEventListener('pointerleave', () => {
            if (dismissed === anchor) dismissed = null;
            if (!focused(anchor)) close(anchor);
        });

        // Keyboard focus shows the name at once. Focus from a click does not count.
        anchor.addEventListener('focusin', () => {
            if (focused(anchor)) open(anchor, true);
        });

        anchor.addEventListener('focusout', () => {
            if (dismissed === anchor) dismissed = null;
            close(anchor);
        });
    }

    // Escape closes the tooltip without moving the pointer off it.
    document.addEventListener('keydown', (event) => {
        if (event.key !== 'Escape' || !wanted) return;

        dismissed = wanted;
        close(wanted);
    });
}

Icons

Hover a preview to play it. The icons are under the Lordicon License Terms, not the MIT licence of the code.

Preview Used as Lordicon icon
brush-alt system-outline-762-brush-alt (edited by hand)
paint-bucket system-outline-1428-paint-bucket (edited by hand)
pencil system-outline-35-pencil (edited by hand)
pen-nib system-outline-4228-pen-nib (edited by hand)
bullet-list system-outline-1443-bullet-list (edited by hand)
check-list system-outline-4381-check-list (edited by hand)
three-dots-vertical system-outline-4101-three-dots-vertical (edited by hand)