Add to cart

A button that opens to say what it did, and a heart that stays filled. One attribute each.

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/add-to-cart#standalone add-to-cart

Copies the add-to-cart folder of the standalone branch — where every demo lives on its own — into ./add-to-cart.

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();
The cart button aria-pressed is the state. The words inside are what it looks like.
html
<!-- aria-pressed is the state: the basket morphs on it and the button opens
     on it. The words are aria-hidden; aria-pressed already says the same.
     The tooltip span is the button's name. -->
<button class="cart" type="button" aria-pressed="false">
    <lord-icon
        class="cart__mark"
        current-color
        src="icons/shopping-basket.json"
        loading="interaction"
        trigger="follow(aria-pressed)"
        target=".cart"
        state="morph-add"
        aria-hidden="true"
    >
        <img alt="" src="icons/shopping-basket.svg" />
    </lord-icon>

    <span class="cart__said" aria-hidden="true">
        <span class="cart__label">Added to cart</span>
    </span>

    <span class="tooltip">Add to cart</span>
</button>
The favourite button Same trigger, same attribute, a different morph.
html
<!-- The same trigger and attribute. morph-select fills the heart; the
     stylesheet colours it from the same aria-pressed. -->
<button class="favorite" type="button" aria-pressed="false">
    <lord-icon
        class="favorite__mark"
        current-color
        src="icons/heart.json"
        loading="interaction"
        trigger="follow(aria-pressed)"
        target=".favorite"
        state="morph-select"
        aria-hidden="true"
    >
        <img alt="" src="icons/heart.svg" />
    </lord-icon>

    <span class="tooltip">Add to favorites</span>
</button>
Flip the attribute on click The cart also renames itself and starts its words.
cart.addEventListener('click', () => {
    const added = cart.getAttribute('aria-pressed') !== 'true';

    cart.setAttribute('aria-pressed', String(added)); // the basket follows on its own
    rename(cart, added ? 'Remove from cart' : 'Add to cart');

    // Closing has no text animation: the box narrows and clips the words.
    if (added) revealText(label, { delay: WORDS_WAIT, duration: WORDS_RUN });
    else settleText(label);
});

favorite.addEventListener('click', () => {
    const kept = favorite.getAttribute('aria-pressed') !== 'true';

    favorite.setAttribute('aria-pressed', String(kept)); // and so does the heart
    rename(favorite, kept ? 'Remove from favorites' : 'Add to favorites');
});
Measure the label's width Width cannot transition to auto, so the stylesheet gets a number.
/**
 * Writes the label's width for the stylesheet, because width cannot transition to `auto`.
 * The label has `width: max-content`, so it can be measured while the box around it is
 * closed. Measured after the font has loaded, which changes the width.
 */
async function measure(): Promise<void> {
    await document.fonts.ready;
    cart.style.setProperty('--said-width', `${label.getBoundingClientRect().width}px`);
}

void measure();
Open and close the button Three transitions. Closing is the same three in reverse.
css
/* Three transitions: the width of the box holding the words, the gap before it, and the
   padding. The box clips the words, so closing needs no text animation. --said-width is
   written by main.ts. 400ms and the curve are from the reference recording. */
.cart {
    --opening: 400ms cubic-bezier(0.4, 0, 0.2, 1);
    display: flex;
    align-items: center;
    gap: 0;
    padding: var(--space-8);
    transition:
        gap var(--opening),
        padding var(--opening);
}

.cart[aria-pressed='true'] {
    gap: var(--space-8);
    padding: var(--space-8) var(--space-16);
}

.cart__said {
    width: 0;
    overflow: hidden;
    transition: width var(--opening);
}

.cart[aria-pressed='true'] .cart__said {
    width: var(--said-width, max-content);
}
Colour the heart from the same attribute Shape and colour read aria-pressed, so they cannot disagree.
css
/* Outline or filled, from the same attribute the morph reads. No transition. */
.favorite__mark {
    color: var(--ink);
    --icon-filter: var(--filter-ink);
}

.favorite[aria-pressed='true'] .favorite__mark {
    color: var(--accent);
    --icon-filter: var(--filter-accent);
}

Every file

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

add-to-cart.css 120 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);
}

/* Anchored on the right and as wide as the shut pair: the cart opens leftward and the
   heart never moves. */
.shelf {
    display: flex;
    justify-content: flex-end;
    align-items: stretch;
    gap: var(--space-8);
    width: 88px;
}

/* Three transitions: the width of the box holding the words, the gap before it, and the
   padding. The box clips the words, so closing needs no text animation. --said-width is
   written by main.ts. 400ms and the curve are from the reference recording. */
.cart {
    --opening: 400ms cubic-bezier(0.4, 0, 0.2, 1);
    display: flex;
    align-items: center;
    gap: 0;
    padding: var(--space-8);
    transition:
        gap var(--opening),
        padding var(--opening);
}

.cart[aria-pressed='true'] {
    gap: var(--space-8);
    padding: var(--space-8) var(--space-16);
}

.cart__said {
    width: 0;
    overflow: hidden;
    transition: width var(--opening);
}

.cart[aria-pressed='true'] .cart__said {
    width: var(--said-width, max-content);
}

.cart {
    border: 0;
    border-radius: var(--radius-md);
    background: var(--accent);
    color: var(--ink-inverse);
    --icon-filter: var(--filter-ink-inverse);
    box-shadow: var(--shadow-accent);
    cursor: pointer;
}

.cart:hover {
    background: var(--accent-strong);
}

/* Pressed. */
.cart:active {
    background: var(--accent-ink);
}

/* max-content keeps the words at their own width inside the animating box, and lets
   main.ts measure them while the box is shut. */
.cart__label {
    display: block;
    width: max-content;
    font-weight: 500;
}

/* Square, and as tall as the cart button, which has no border: hence the 1px. */
.favorite {
    display: grid;
    place-items: center;
    aspect-ratio: 1;
    padding: calc(var(--space-8) - 1px);
    border: 1px solid var(--border);
    border-radius: var(--radius-md);
    background: var(--surface);
    cursor: pointer;
}

.favorite:hover {
    background: var(--surface-muted);
}

.favorite:active {
    background: var(--surface-sunken);
}

.cart__mark,
.favorite__mark {
    flex: none;
    width: var(--icon-size-24);
    height: var(--icon-size-24);
}

/* Outline or filled, from the same attribute the morph reads. No transition. */
.favorite__mark {
    color: var(--ink);
    --icon-filter: var(--filter-ink);
}

.favorite[aria-pressed='true'] .favorite__mark {
    color: var(--accent);
    --icon-filter: var(--filter-accent);
}

@media (prefers-reduced-motion: reduce) {
    .cart,
    .cart__said {
        transition: none;
    }
}
index.html 58 lines
html
<!doctype html>
<html lang="en">
    <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <title>Add to cart</title>
        <link rel="stylesheet" href="./add-to-cart.css" />
        <script type="module" src="./main.ts"></script>
    </head>
    <body>
        <main class="demo">
            <div class="shelf">
                <!-- aria-pressed is the state: the basket morphs on it and the button opens
                     on it. The words are aria-hidden; aria-pressed already says the same.
                     The tooltip span is the button's name. -->
                <button class="cart" type="button" aria-pressed="false">
                    <lord-icon
                        class="cart__mark"
                        current-color
                        src="icons/shopping-basket.json"
                        loading="interaction"
                        trigger="follow(aria-pressed)"
                        target=".cart"
                        state="morph-add"
                        aria-hidden="true"
                    >
                        <img alt="" src="icons/shopping-basket.svg" />
                    </lord-icon>

                    <span class="cart__said" aria-hidden="true">
                        <span class="cart__label">Added to cart</span>
                    </span>

                    <span class="tooltip">Add to cart</span>
                </button>

                <!-- The same trigger and attribute. morph-select fills the heart; the
                     stylesheet colours it from the same aria-pressed. -->
                <button class="favorite" type="button" aria-pressed="false">
                    <lord-icon
                        class="favorite__mark"
                        current-color
                        src="icons/heart.json"
                        loading="interaction"
                        trigger="follow(aria-pressed)"
                        target=".favorite"
                        state="morph-select"
                        aria-hidden="true"
                    >
                        <img alt="" src="icons/heart.svg" />
                    </lord-icon>

                    <span class="tooltip">Add to favorites</span>
                </button>
            </div>
        </main>
    </body>
</html>
main.ts 52 lines
import { defineElement } from '@lordicon/element';

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

import { revealText, settleText } from '@shared/motion/text-reveal.ts';
import { tooltips } from '@shared/ui/tooltip.ts';

const cart = document.querySelector<HTMLButtonElement>('.cart')!;
const label = document.querySelector<HTMLElement>('.cart__label')!;
const favorite = document.querySelector<HTMLButtonElement>('.favorite')!;

tooltips();

/**
 * Writes the label's width for the stylesheet, because width cannot transition to `auto`.
 * The label has `width: max-content`, so it can be measured while the box around it is
 * closed. Measured after the font has loaded, which changes the width.
 */
async function measure(): Promise<void> {
    await document.fonts.ready;
    cart.style.setProperty('--said-width', `${label.getBoundingClientRect().width}px`);
}

void measure();

/** A toggle button is named after what pressing it would do next. The name is the tooltip. */
function rename(button: HTMLButtonElement, to: string): void {
    button.querySelector<HTMLElement>('.tooltip')!.textContent = to;
}

/** The words start 100ms into the 400ms opening and land as the button stops. */
const WORDS_WAIT = 100;
const WORDS_RUN = 300;

cart.addEventListener('click', () => {
    const added = cart.getAttribute('aria-pressed') !== 'true';

    cart.setAttribute('aria-pressed', String(added)); // the basket follows on its own
    rename(cart, added ? 'Remove from cart' : 'Add to cart');

    // Closing has no text animation: the box narrows and clips the words.
    if (added) revealText(label, { delay: WORDS_WAIT, duration: WORDS_RUN });
    else settleText(label);
});

favorite.addEventListener('click', () => {
    const kept = favorite.getAttribute('aria-pressed') !== 'true';

    favorite.setAttribute('aria-pressed', String(kept)); // and so does the heart
    rename(favorite, kept ? 'Remove from favorites' : 'Add to favorites');
});
What it imports 8 shared files
shared/motion/reduced-motion.ts 7 lines
/**
 * Whether the viewer asked for less motion. Script-driven animations have to check this
 * themselves; a media query only switches off CSS ones.
 */
export function prefersReducedMotion(): boolean {
    return globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
}
shared/motion/text-reveal.ts 132 lines
/**
 * Text that arrives word by word and leaves in one piece.
 *
 * `revealText()` fades the words in as a wave: several are in flight at once. `concealText()`
 * slides the whole block down without fading; the box around it is expected to clip it.
 * This module only moves the text, the caller owns the box.
 */
import { prefersReducedMotion } from './reduced-motion.ts';

/** First word starting to last word finished, whatever the word count. */
const REVEAL_MS = 500;

/**
 * How much of the run one word spends fading. Higher means a softer, more overlapping wave;
 * lower means words arrive more one at a time.
 */
const FADE_SHARE = 0.3;

const FADE_EASE = 'ease-out';

const CONCEAL_MS = 300;

/** Pixels the block travels down. Enough to read as leaving, short enough to stay clipped. */
const CONCEAL_DISTANCE = 32;

const MOVE_EASING = 'cubic-bezier(0.3, 0, 0.2, 1)';

export type RevealOptions = {
    duration?: number;
    fadeShare?: number;
    easing?: string;
    /** Wait before the first word, for text arriving into a box that is still opening. */
    delay?: number;
};

export type ConcealOptions = {
    duration?: number;
    /** Pixels, downwards. */
    distance?: number;
    easing?: string;
};

/**
 * Wraps every word of `element` in its own span. Walks text nodes rather than rewriting
 * innerHTML, so inline markup inside the paragraph survives. Runs once; later calls return
 * the spans already there.
 */
export function splitWords(element: HTMLElement): HTMLElement[] {
    const already = element.querySelectorAll<HTMLElement>('[data-word]');
    if (already.length) return [...already];

    const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
    const texts: Text[] = [];
    while (walker.nextNode()) texts.push(walker.currentNode as Text);

    for (const text of texts) {
        if (!text.data.trim()) continue;

        const pieces = document.createDocumentFragment();
        // The capturing split keeps the whitespace, so copied text reads as before.
        for (const part of text.data.split(/(\s+)/)) {
            if (!part) continue;

            if (!part.trim()) {
                pieces.append(part);
                continue;
            }

            const word = document.createElement('span');
            word.dataset.word = '';
            word.textContent = part;
            pieces.append(word);
        }

        text.replaceWith(pieces);
    }

    return [...element.querySelectorAll<HTMLElement>('[data-word]')];
}

/**
 * Cancels whatever this block and its words have running. Scoped on purpose: a subtree
 * search from an ancestor would also cancel the box's own animation.
 */
export function settleText(element: HTMLElement): void {
    for (const animation of element.getAnimations()) animation.cancel();

    for (const word of element.querySelectorAll<HTMLElement>('[data-word]')) {
        for (const animation of word.getAnimations()) animation.cancel();
    }
}

/**
 * Fades the words in one after another. The run always takes `duration`; more words mean
 * a tighter stagger.
 */
export function revealText(element: HTMLElement, options: RevealOptions = {}): Animation[] {
    const { duration = REVEAL_MS, fadeShare = FADE_SHARE, easing = FADE_EASE, delay = 0 } = options;

    const words = splitWords(element);
    settleText(element);
    if (prefersReducedMotion()) return [];

    const solo = words.length < 2;
    const fade = solo ? duration : duration * fadeShare;
    const step = solo ? 0 : (duration - fade) / (words.length - 1);

    return words.map((word, index) =>
        // `backwards` keeps a word invisible through its delay. No forwards fill, so a
        // finished wave leaves nothing to undo.
        word.animate(
            { opacity: [0, 1] },
            { duration: fade, delay: delay + index * step, easing, fill: 'backwards' },
        ),
    );
}

/**
 * Slides the block down as one, without fading. Fills forwards so the text stays down
 * until the box has closed; the next reveal cancels it.
 */
export function concealText(element: HTMLElement, options: ConcealOptions = {}): Animation | null {
    const { duration = CONCEAL_MS, distance = CONCEAL_DISTANCE, easing = MOVE_EASING } = options;

    settleText(element);
    if (prefersReducedMotion()) return null;

    return element.animate(
        { transform: ['translateY(0)', `translateY(${distance}px)`] },
        { duration, easing, fill: 'forwards' },
    );
}
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
shopping-basket system-outline-139-shopping-basket (edited by hand)
heart system-outline-20-heart (edited by hand)