Sidebar navigation

Seven destinations whose icons answer the pointer and the keyboard, and one attribute that says where you are.

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/sidebar-navigation#standalone sidebar-navigation

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

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 destination target points the icon at the link, so the whole row is the hover area.
html
<!-- One destination. aria-current is the state; the stylesheet
     tints the row that has it. target=".nav__item" makes the
     whole row the hover area. -->
<li>
    <a class="nav__item" href="#overview" aria-current="page">
        <lord-icon
            class="nav__mark"
            current-color
            src="icons/grid-widget.json"
            loading="interaction"
            trigger="hover"
            target=".nav__item"
            aria-hidden="true"
        >
            <img alt="" src="icons/grid-widget.svg" />
        </lord-icon>

        <span class="nav__label">Overview</span>
    </a>
</li>
Move aria-current on click Added to one link, removed from the rest. No icon in sight.
const nav = document.querySelector<HTMLElement>('.nav')!;
const items = [...nav.querySelectorAll<HTMLAnchorElement>('.nav__item')];

// One listener for the list. The clicked link is in the event.
nav.addEventListener('click', (event) => {
    const chosen = (event.target as Element).closest<HTMLAnchorElement>('.nav__item');
    if (!chosen) return;

    // Real links with no pages behind them.
    event.preventDefault();

    // aria-current is not a boolean: the other links do not carry it at all.
    for (const item of items) {
        if (item === chosen) item.setAttribute('aria-current', 'page');
        else item.removeAttribute('aria-current');
    }
});
Hovered and current rows Two custom properties: the fill and the icon colour.
css
/* The fill and the icon colour, at rest and on a hovered or current row. The icon takes
   `color` through current-color. The label does not change. No transition. */
.nav__item {
    --nav-fill: transparent;
    --nav-mark: var(--ink-muted);
    --icon-filter: var(--filter-ink-muted);
}

.nav__item:hover,
.nav__item[aria-current='page'] {
    --nav-fill: var(--surface-muted);
    --nav-mark: var(--ink);
    --icon-filter: var(--filter-ink);
}
The sidebar's height Drawn at 814px, never shorter than its contents, never taller than the screen.
css
/* A sidebar has no natural height. The design draws it at 814px; it is never shorter than
   its contents and never taller than the screen. min-height beats max-height in CSS. */
.sidebar {
    display: flex;
    flex-direction: column;
    justify-content: space-between;
    gap: var(--space-24);
    width: 300px;
    height: 814px;
    min-height: min-content;
    max-height: calc(100dvh - var(--space-48)); /* the demo's padding, top and bottom */
    padding: var(--space-12);
    border: 1px solid var(--border-soft);
    border-radius: var(--radius-lg);
    background: var(--surface);
    box-shadow: var(--shadow-sm);
}

Every file

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

index.html 228 lines
html
<!doctype html>
<html lang="en">
    <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <title>Sidebar navigation</title>
        <link rel="stylesheet" href="./sidebar-navigation.css" />
        <script type="module" src="./main.ts"></script>
    </head>
    <body>
        <main class="demo">
            <div class="sidebar">
                <div class="sidebar__top">
                    <p class="brand">
                        <!-- The logo is an inline SVG, not a Lordicon icon. -->
                        <span class="brand__mark" aria-hidden="true">
                            <svg viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
                                <rect width="40" height="40" rx="4" fill="#edf9f1" />
                                <path
                                    fill="#08c18a"
                                    fill-rule="evenodd"
                                    clip-rule="evenodd"
                                    d="M23.2981 16.7L26.2974 13.7022C26.9288 13.0782 28 13.5128 28 14.4043V25.9718C28 27.0973 27.0867 28 25.9704 28H14.0296C12.9133 28 12 27.0973 12 25.9718V14.4155C12 13.5239 13.0712 13.0782 13.7026 13.7134L16.7019 16.7223L19.1261 12.5098C19.5208 11.8301 20.4905 11.8301 20.8739 12.5098L23.2981 16.7ZM17.6378 22.7846C18.3369 22.7846 18.9119 22.2163 18.9119 21.5253C18.9119 20.8344 18.3369 20.2661 17.6378 20.2661C16.9387 20.2661 16.3636 20.8344 16.3636 21.5253C16.3636 22.2274 16.9274 22.7846 17.6378 22.7846ZM22.723 22.7846C23.4221 22.7846 23.9972 22.2163 23.9972 21.5253C23.9972 20.8344 23.4221 20.2661 22.723 20.2661C22.024 20.2661 21.4489 20.8344 21.4489 21.5253C21.4489 22.2274 22.024 22.7846 22.723 22.7846Z"
                                />
                            </svg>
                        </span>

                        Lordicon
                    </p>

                    <!-- The search bar demo's field: same attributes, same triggers. -->
                    <form class="search" role="search">
                        <div class="field" data-focused="false" data-clearable="false">
                            <label class="visually-hidden" for="q">Search</label>

                            <lord-icon
                                class="field__mark"
                                current-color
                                src="icons/magnifier.json"
                                loading="interaction"
                                trigger="follow(data-focused)"
                                target=".field"
                                aria-hidden="true"
                            >
                                <img alt="" src="icons/magnifier.svg" />
                            </lord-icon>

                            <input class="field__input" id="q" type="search" placeholder="Search" />

                            <button class="field__clear" type="button" aria-label="Clear search">
                                <lord-icon
                                    class="field__mark"
                                    current-color
                                    src="icons/cross.json"
                                    loading="interaction"
                                    trigger="follow(data-clearable)"
                                    target=".field"
                                    state="in-reveal"
                                    aria-hidden="true"
                                ></lord-icon>
                            </button>
                        </div>
                    </form>

                    <nav class="nav" aria-label="Main">
                        <ul class="nav__group">
                            <!-- One destination. aria-current is the state; the stylesheet
                                 tints the row that has it. target=".nav__item" makes the
                                 whole row the hover area. -->
                            <li>
                                <a class="nav__item" href="#overview" aria-current="page">
                                    <lord-icon
                                        class="nav__mark"
                                        current-color
                                        src="icons/grid-widget.json"
                                        loading="interaction"
                                        trigger="hover"
                                        target=".nav__item"
                                        aria-hidden="true"
                                    >
                                        <img alt="" src="icons/grid-widget.svg" />
                                    </lord-icon>

                                    <span class="nav__label">Overview</span>
                                </a>
                            </li>

                            <li>
                                <a class="nav__item" href="#orders">
                                    <lord-icon
                                        class="nav__mark"
                                        current-color
                                        src="icons/cart.json"
                                        loading="interaction"
                                        trigger="hover"
                                        target=".nav__item"
                                        aria-hidden="true"
                                    >
                                        <img alt="" src="icons/cart.svg" />
                                    </lord-icon>

                                    <span class="nav__label">Orders</span>
                                </a>
                            </li>

                            <li>
                                <a class="nav__item" href="#products">
                                    <lord-icon
                                        class="nav__mark"
                                        current-color
                                        src="icons/box-front.json"
                                        loading="interaction"
                                        trigger="hover"
                                        target=".nav__item"
                                        aria-hidden="true"
                                    >
                                        <img alt="" src="icons/box-front.svg" />
                                    </lord-icon>

                                    <span class="nav__label">Products</span>
                                </a>
                            </li>
                        </ul>

                        <hr class="nav__rule" />

                        <ul class="nav__group">
                            <li>
                                <a class="nav__item" href="#customers">
                                    <lord-icon
                                        class="nav__mark"
                                        current-color
                                        src="icons/avatars-two.json"
                                        loading="interaction"
                                        trigger="hover"
                                        target=".nav__item"
                                        aria-hidden="true"
                                    >
                                        <img alt="" src="icons/avatars-two.svg" />
                                    </lord-icon>

                                    <span class="nav__label">Customers</span>
                                </a>
                            </li>

                            <li>
                                <a class="nav__item" href="#discounts">
                                    <lord-icon
                                        class="nav__mark"
                                        current-color
                                        src="icons/price-tag.json"
                                        loading="interaction"
                                        trigger="hover"
                                        target=".nav__item"
                                        aria-hidden="true"
                                    >
                                        <img alt="" src="icons/price-tag.svg" />
                                    </lord-icon>

                                    <span class="nav__label">Discounts</span>
                                </a>
                            </li>

                            <li>
                                <a class="nav__item" href="#analytics">
                                    <lord-icon
                                        class="nav__mark"
                                        current-color
                                        src="icons/bar-chart-vertical.json"
                                        loading="interaction"
                                        trigger="hover"
                                        target=".nav__item"
                                        aria-hidden="true"
                                    >
                                        <img alt="" src="icons/bar-chart-vertical.svg" />
                                    </lord-icon>

                                    <span class="nav__label">Analytics</span>
                                </a>
                            </li>
                        </ul>

                        <hr class="nav__rule" />

                        <ul class="nav__group">
                            <li>
                                <!-- hover-mechanic turns the cog; its default state pinches. -->
                                <a class="nav__item" href="#settings">
                                    <lord-icon
                                        class="nav__mark"
                                        current-color
                                        src="icons/cog.json"
                                        loading="interaction"
                                        trigger="hover"
                                        target=".nav__item"
                                        state="hover-mechanic"
                                        aria-hidden="true"
                                    >
                                        <img alt="" src="icons/cog.svg" />
                                    </lord-icon>

                                    <span class="nav__label">Settings</span>
                                </a>
                            </li>
                        </ul>
                    </nav>
                </div>

                <button class="account" type="button">
                    <span class="account__avatar" aria-hidden="true">D</span>
                    <span class="account__name">Dave Lee</span>

                    <lord-icon
                        class="account__mark"
                        current-color
                        src="icons/three-dots-vertical.json"
                        loading="interaction"
                        trigger="hover"
                        target=".account"
                        aria-hidden="true"
                    >
                        <img alt="" src="icons/three-dots-vertical.svg" />
                    </lord-icon>
                </button>
            </div>
        </main>
    </body>
</html>
main.ts 27 lines
import { defineElement } from '@lordicon/element';

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

import { fields } from '@shared/ui/field.ts';

const nav = document.querySelector<HTMLElement>('.nav')!;
const items = [...nav.querySelectorAll<HTMLAnchorElement>('.nav__item')];

// One listener for the list. The clicked link is in the event.
nav.addEventListener('click', (event) => {
    const chosen = (event.target as Element).closest<HTMLAnchorElement>('.nav__item');
    if (!chosen) return;

    // Real links with no pages behind them.
    event.preventDefault();

    // aria-current is not a boolean: the other links do not carry it at all.
    for (const item of items) {
        if (item === chosen) item.setAttribute('aria-current', 'page');
        else item.removeAttribute('aria-current');
    }
});

// The search field is the search bar demo's; fields() writes its two attributes.
fields();
sidebar-navigation.css 157 lines
css
@import '@shared/styles/index.css';
@import '@shared/ui/field.css';

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

/* A sidebar has no natural height. The design draws it at 814px; it is never shorter than
   its contents and never taller than the screen. min-height beats max-height in CSS. */
.sidebar {
    display: flex;
    flex-direction: column;
    justify-content: space-between;
    gap: var(--space-24);
    width: 300px;
    height: 814px;
    min-height: min-content;
    max-height: calc(100dvh - var(--space-48)); /* the demo's padding, top and bottom */
    padding: var(--space-12);
    border: 1px solid var(--border-soft);
    border-radius: var(--radius-lg);
    background: var(--surface);
    box-shadow: var(--shadow-sm);
}

.sidebar__top {
    display: flex;
    flex: none;
    flex-direction: column;
    gap: var(--space-12);
}

.brand {
    display: flex;
    align-items: center;
    gap: var(--space-8);
    margin: 0;
    padding: var(--space-8) 0;
    font-weight: 700;
}

.brand__mark {
    display: block;
    flex: none;
    width: var(--icon-size-40);
    height: var(--icon-size-40);
}

.brand__mark svg {
    display: block;
    width: 100%;
    height: 100%;
}

/* The search bar's field, with no resting border: the card already has an edge. The
   border stays, transparent, so the words do not move when focus lights it. */
.field {
    border-color: transparent;
}

.nav__group {
    margin: 0;
    padding: 0;
    list-style: none;
}

/* A rule between two lists. */
.nav__rule {
    height: 0;
    margin: var(--space-12) 0;
    border: 0;
    border-top: 1px solid var(--border);
}

/* The fill and the icon colour, at rest and on a hovered or current row. The icon takes
   `color` through current-color. The label does not change. No transition. */
.nav__item {
    --nav-fill: transparent;
    --nav-mark: var(--ink-muted);
    --icon-filter: var(--filter-ink-muted);
}

.nav__item:hover,
.nav__item[aria-current='page'] {
    --nav-fill: var(--surface-muted);
    --nav-mark: var(--ink);
    --icon-filter: var(--filter-ink);
}

.nav__item {
    display: flex;
    align-items: center;
    gap: var(--space-8);
    padding: var(--space-8);
    border-radius: var(--radius-md);
    background: var(--nav-fill);
    color: var(--ink);
    font-weight: 500;
    text-decoration: none;
}

.nav__mark {
    flex: none;
    width: var(--icon-size-24);
    height: var(--icon-size-24);
    color: var(--nav-mark);
}

/* The focus ring goes on the row, inside its edge. */
.nav__item:focus-visible {
    outline-offset: -2px;
}

.account {
    display: flex;
    flex: none;
    align-items: center;
    gap: var(--space-8);
    padding: var(--space-8);
    border: 1px solid var(--border-soft);
    border-radius: var(--radius-md);
    background: none;
    font-weight: 500;
    text-align: start;
    cursor: pointer;
}

/* A letter in a circle stands in for a photo. */
.account__avatar {
    display: grid;
    flex: none;
    place-items: center;
    width: var(--icon-size-40);
    height: var(--icon-size-40);
    border-radius: var(--radius-pill);
    background: var(--surface-muted);
    color: var(--ink-subtle);
    font-weight: 500;
}

/* The only thing in the row that may shrink. */
.account__name {
    flex: 1;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.account__mark {
    flex: none;
    width: var(--icon-size-24);
    height: var(--icon-size-24);
    color: var(--ink);
}
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/field.css 72 lines
css
/*
 * The look of a text field wired by field.ts. A demo overrides the resting border or the
 * focused look with a rule of its own after this import.
 */
.field {
    display: flex;
    align-items: center;
    gap: var(--space-8);
    padding: var(--space-8);
    border: 1px solid var(--border-soft);
    border-radius: var(--radius-md);
    background: var(--surface-muted);
}

.field[data-focused='true'] {
    border-color: var(--accent-border);
    box-shadow: var(--shadow-accent);
}

.field__input {
    flex: 1;
    min-width: 0;
    padding: 0;
    border: 0;
    background: none;
    color: var(--ink);
    font-weight: 500;
}

.field__input::placeholder {
    color: var(--ink-muted);
}

/* The box draws the focused state; no second ring inside it. */
.field__input:focus-visible {
    outline: none;
}

/* A search input has a clear button of its own; this field brings its own. */
.field__input::-webkit-search-cancel-button {
    appearance: none;
}

/* `visibility` rather than `hidden`: the button keeps its box, so the field does not
   change width when it appears. It is out of the tab order either way. */
.field__clear {
    display: grid;
    place-items: center;
    visibility: hidden;
    padding: 0;
    border: 0;
    background: none;
    color: var(--ink-muted);
    cursor: pointer;
}

.field[data-clearable='true'] .field__clear {
    visibility: visible;
}

.field__clear:hover {
    color: var(--ink);
}

/* Both icons take the placeholder's grey; the typed words are the only full-strength ink. */
.field__mark {
    flex: none;
    width: var(--icon-size-24);
    height: var(--icon-size-24);
    color: var(--ink-muted);
    --icon-filter: var(--filter-ink-muted);
}
shared/ui/field.ts 75 lines
/**
 * A text field that reports its state in two attributes:
 *
 *     <div class="field" data-focused="false" data-clearable="false">
 *         <lord-icon … trigger="follow(data-focused)" target=".field"></lord-icon>
 *         <input class="field__input" />
 *         <button class="field__clear">…</button>
 *     </div>
 *
 * `data-focused` is whether focus is anywhere inside the field. `data-clearable` turns true
 * once the typing has paused with something in the input, and is what shows the clear
 * button. Icons and the stylesheet read the attributes; this module only writes them.
 *
 * The clear button is optional. Without one, `data-clearable` is never set.
 */

/** How long the typing has to pause before the field offers to clear. */
const SETTLED = 500;

export type FieldOptions = { settled?: number };

/** Wires every `.field` under `root`. Called once; nothing here needs taking apart. */
export function fields(root: ParentNode = document, options: FieldOptions = {}): void {
    for (const field of root.querySelectorAll<HTMLElement>('.field')) {
        wire(field, options.settled ?? SETTLED);
    }
}

function wire(field: HTMLElement, settled: number): void {
    const input = field.querySelector<HTMLInputElement>('.field__input');
    if (!input) return;

    const clear = field.querySelector<HTMLButtonElement>('.field__clear');
    let settling: ReturnType<typeof setTimeout> | undefined;

    const offer = (clearable: boolean) => {
        clearTimeout(settling);
        field.dataset.clearable = String(clearable);
    };

    // focusin/focusout bubble, so the field counts as focused with focus on the input or
    // on the clear button.
    field.addEventListener('focusin', () => (field.dataset.focused = 'true'));

    field.addEventListener('focusout', (event) => {
        // Moving focus to the clear button is not leaving the field.
        if (field.contains(event.relatedTarget as Node | null)) return;

        field.dataset.focused = 'false';
        if (clear && input.value) offer(true);
    });

    if (!clear) return;

    // The clear button appears once the typing pauses, not on every keystroke.
    input.addEventListener('input', () => {
        clearTimeout(settling);

        if (!input.value) return offer(false);

        // Once shown it stays, so it is there when reached for.
        if (field.dataset.clearable === 'true') return;

        settling = setTimeout(() => offer(true), settled);
    });

    clear.addEventListener('click', () => {
        input.value = '';
        offer(false);
        // Setting `value` from script raises no event; anything filtering on this field
        // listens for `input`.
        input.dispatchEvent(new Event('input', { bubbles: true }));
        input.focus();
    });
}

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
grid-widget system-outline-366-grid-widget (edited by hand)
cart system-outline-146-cart (edited by hand)
box-front system-outline-3570-box-front (edited by hand)
avatars-two system-outline-313-avatars-two (edited by hand)
price-tag system-outline-99-price-tag (edited by hand)
bar-chart-vertical system-outline-3089-bar-chart-vertical (edited by hand)
cog system-outline-39-cog (edited by hand)
three-dots-vertical system-outline-4101-three-dots-vertical (edited by hand)
magnifier system-outline-19-magnifier (edited by hand)
cross system-outline-38-cross