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/alert#standalone alert
Copies the alert folder of the
standalone branch — where every demo lives
on its own — into ./alert.
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();
import { defineElement } from '@lordicon/element';
// Registers <lord-icon> with its built-in triggers. The markup says what each icon follows.
defineElement();
One alert card The icon watches the card it sits in, through target. The image inside shows until the icon is ready.
<!-- A kind is data-kind on the card. intro plays the icon once the card's
own entrance has finished; hover plays it again. The image shows until
the icon is ready. -->
<li class="alert" data-kind="info">
<lord-icon
class="alert__icon"
current-color
src="icons/info-circle.json"
intro="hover-pinch, after=.alert"
trigger="hover"
target=".alert"
aria-hidden="true"
>
<img alt="" src="icons/info-circle.svg" />
</lord-icon>
<!-- … -->
</li>
Fill a card in Sets data-kind and the icon's src. The deck is made of these.
/** One alert from the template, for the deck. The gallery's cards are in the markup. */
function build(kind: Kind): HTMLElement {
const { icon, title, message } = CONTENT[kind];
const alert = template.content.cloneNode(true) as DocumentFragment;
const card = alert.querySelector<HTMLElement>('.alert')!;
card.dataset.kind = kind;
card.querySelector('.alert__icon')!.setAttribute('src', `icons/${icon}.json`);
card.querySelector('.alert__title')!.textContent = title;
card.querySelector('.alert__message')!.textContent = message;
return card;
}
/** One alert from the template, for the deck. The gallery's cards are in the markup. */
function build(kind) {
const { icon, title, message } = CONTENT[kind];
const alert = template.content.cloneNode(true);
const card = alert.querySelector('.alert');
card.dataset.kind = kind;
card.querySelector('.alert__icon').setAttribute('src', `icons/${icon}.json`);
card.querySelector('.alert__title').textContent = title;
card.querySelector('.alert__message').textContent = message;
return card;
}
Queue the next alert Four kinds, then the button rests until one is dismissed.
/** The first kind not already in the deck, so dismissing one brings that kind back. */
function next(): Kind | undefined {
const up = new Set(standing().map((card) => card.dataset.kind));
return QUEUE.find((kind) => !up.has(kind));
}
function enqueue(): void {
const kind = next();
if (!kind) return;
const card = build(kind);
card.querySelector('.alert__close')!.addEventListener('click', () => void dismiss(card));
queue.append(card);
settle();
}
/** After any change: who sits where, and whether there is room for one more. */
function settle(): void {
restack();
add.disabled = !next();
}
/** The first kind not already in the deck, so dismissing one brings that kind back. */
function next() {
const up = new Set(standing().map((card) => card.dataset.kind));
return QUEUE.find((kind) => !up.has(kind));
}
function enqueue() {
const kind = next();
if (!kind) return;
const card = build(kind);
card.querySelector('.alert__close').addEventListener('click', () => void dismiss(card));
queue.append(card);
settle();
}
/** After any change: who sits where, and whether there is room for one more. */
function settle() {
restack();
add.disabled = !next();
}
Stack the deck and dismiss a card --depth per card drives the stylesheet. Dismissing waits for the transitions.
/** Writes each card's depth: the newest is 0 and in front, the rest recede behind it. */
function restack(): void {
const cards = standing();
cards.forEach((card, at) => {
card.style.setProperty('--depth', String(cards.length - 1 - at));
card.style.zIndex = String(at);
});
}
/** Marks the card as leaving, lets the stylesheet animate it out, then removes it. */
async function dismiss(card: HTMLElement): Promise<void> {
card.dataset.leaving = '';
// Restack now, so the deck closes up while the card is still on its way out.
settle();
// The leaving transitions only exist once a frame has passed with the attribute set.
await new Promise(requestAnimationFrame);
await Promise.allSettled(card.getAnimations().map((leaving) => leaving.finished));
card.remove();
settle();
}
/** Writes each card's depth: the newest is 0 and in front, the rest recede behind it. */
function restack() {
const cards = standing();
cards.forEach((card, at) => {
card.style.setProperty('--depth', String(cards.length - 1 - at));
card.style.zIndex = String(at);
});
}
/** Marks the card as leaving, lets the stylesheet animate it out, then removes it. */
async function dismiss(card) {
card.dataset.leaving = '';
// Restack now, so the deck closes up while the card is still on its way out.
settle();
// The leaving transitions only exist once a frame has passed with the attribute set.
await new Promise(requestAnimationFrame);
await Promise.allSettled(card.getAnimations().map((leaving) => leaving.finished));
card.remove();
settle();
}
The deck: position, size and fade from --depth translate and scale are separate properties, so they can take different times.
/* Position, size and fade are all functions of --depth. `translate` and `scale` are
separate properties, so an arriving card climbs for 220ms while popping to full size
in 60ms. Two cards show behind the front one; the third fades out. */
.stack .alert {
--layer: min(var(--depth, 0), 2);
translate: 0 calc(var(--layer) * 10px);
scale: calc(1 - var(--layer) * 0.2);
opacity: calc(1 - max(var(--depth, 0) - 1, 0));
transition:
translate 220ms cubic-bezier(0.2, 0.8, 0.3, 1),
scale 60ms cubic-bezier(0, 0, 0.2, 1),
opacity 60ms cubic-bezier(0, 0, 0.2, 1);
}
/* The cards behind settle more slowly than the new one arrives. */
.stack .alert:not(:last-child) {
transition:
translate 340ms cubic-bezier(0.3, 0, 0.2, 1),
scale 340ms cubic-bezier(0.3, 0, 0.2, 1),
opacity 340ms cubic-bezier(0.3, 0, 0.2, 1);
}
The way out A drop and a fade, over a deck that is already closing up.
/* A dismissed card drops on an accelerating curve and fades slowly enough to be seen
going. The deck closes up behind it at the same time. */
.stack .alert[data-leaving] {
z-index: 10;
opacity: 0;
scale: 0.96;
translate: 0 16px;
transition:
translate 240ms cubic-bezier(0.4, 0, 1, 1),
scale 240ms cubic-bezier(0.4, 0, 1, 1),
opacity 200ms linear;
}
Every file
The demo's own files, whole, at the paths the export uses.
alert.css 236 lines
@import '@shared/styles/index.css';
@import '@shared/ui/pager.css';
.demo {
display: grid;
grid-template-rows: 1fr auto;
justify-items: center;
align-items: stretch;
gap: var(--space-48);
min-height: 100dvh;
padding: var(--space-64) var(--gutter) var(--space-32);
}
.page {
display: grid;
align-content: start;
gap: var(--space-24);
justify-items: center;
width: 100%;
}
/* The deck sits mid-stage but behaves as if pinned to the bottom of a screen: cards
arrive from below and recede downwards. */
.page--deck {
align-content: center;
}
.add {
justify-self: center;
padding: var(--space-8) var(--space-16);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
font-size: var(--text-sm);
font-weight: 500;
cursor: pointer;
}
.add:hover:not(:disabled) {
border-color: var(--border-strong);
}
.add:disabled {
color: var(--ink-subtle);
cursor: not-allowed;
}
.alerts {
display: grid;
gap: var(--space-16);
width: 100%;
max-width: 600px;
margin: 0;
padding: 0;
list-style: none;
}
/* The card. Grey by default; a kind recolours it below. */
.alert {
display: flex;
align-items: flex-start;
gap: var(--space-8);
padding: var(--space-12);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--surface-muted);
}
.alert__icon {
flex: none;
width: var(--icon-size-24);
height: var(--icon-size-24);
}
/* A kind is two colours on the card and one on the icon, which inherits `color`. */
.alert__icon {
color: var(--ink-soft);
--icon-filter: var(--filter-ink-soft);
}
.alert[data-kind='success'] {
border-color: var(--success-border);
background: var(--success-surface);
}
.alert[data-kind='success'] .alert__icon {
color: var(--success);
--icon-filter: var(--filter-success);
}
.alert[data-kind='warning'] {
border-color: var(--warning-border);
background: var(--warning-surface);
}
.alert[data-kind='warning'] .alert__icon {
color: var(--warning);
--icon-filter: var(--filter-warning);
}
.alert[data-kind='error'] {
border-color: var(--danger-border);
background: var(--danger-surface);
}
.alert[data-kind='error'] .alert__icon {
color: var(--danger);
--icon-filter: var(--filter-danger);
}
.alert__body {
flex: 1;
min-width: 0;
}
.alert__title {
margin: 0;
font-size: var(--text-sm);
font-weight: 500;
line-height: var(--leading-base);
}
.alert__message {
margin: 0;
color: var(--ink-soft);
font-size: var(--text-sm);
line-height: var(--leading-base);
}
.alert__close {
flex: none;
display: grid;
padding: var(--space-4);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: none;
color: var(--ink-soft);
--icon-filter: var(--filter-ink-soft);
cursor: pointer;
place-items: center;
}
.alert__close:hover {
border-color: var(--border-strong);
color: var(--ink);
}
.alert__close-icon {
width: var(--icon-size-16);
height: var(--icon-size-16);
}
/* The queue is a deck: the newest card is in front, the rest recede behind it. main.ts
writes --depth on each card; everything below follows from it. */
.stack {
position: relative;
/* Room for the tallest card plus the slivers under it. */
min-height: 132px;
}
/* Anchored at the bottom, so cards of different heights show the same sliver behind. */
.stack .alert {
position: absolute;
inset-inline: 0;
bottom: 20px;
transform-origin: bottom center;
}
/* Position, size and fade are all functions of --depth. `translate` and `scale` are
separate properties, so an arriving card climbs for 220ms while popping to full size
in 60ms. Two cards show behind the front one; the third fades out. */
.stack .alert {
--layer: min(var(--depth, 0), 2);
translate: 0 calc(var(--layer) * 10px);
scale: calc(1 - var(--layer) * 0.2);
opacity: calc(1 - max(var(--depth, 0) - 1, 0));
transition:
translate 220ms cubic-bezier(0.2, 0.8, 0.3, 1),
scale 60ms cubic-bezier(0, 0, 0.2, 1),
opacity 60ms cubic-bezier(0, 0, 0.2, 1);
}
/* The cards behind settle more slowly than the new one arrives. */
.stack .alert:not(:last-child) {
transition:
translate 340ms cubic-bezier(0.3, 0, 0.2, 1),
scale 340ms cubic-bezier(0.3, 0, 0.2, 1),
opacity 340ms cubic-bezier(0.3, 0, 0.2, 1);
}
/* Where a new card starts from. */
@starting-style {
.stack .alert {
opacity: 0;
scale: 0.9;
translate: 0 24px;
}
}
/* A dismissed card drops on an accelerating curve and fades slowly enough to be seen
going. The deck closes up behind it at the same time. */
.stack .alert[data-leaving] {
z-index: 10;
opacity: 0;
scale: 0.96;
translate: 0 16px;
transition:
translate 240ms cubic-bezier(0.4, 0, 1, 1),
scale 240ms cubic-bezier(0.4, 0, 1, 1),
opacity 200ms linear;
}
/* Translucent cards in the deck, so the ones behind show through. */
.stack .alert {
background: color-mix(in srgb, var(--surface-muted) 70%, transparent);
-webkit-backdrop-filter: blur(5px) saturate(140%);
backdrop-filter: blur(5px) saturate(140%);
}
.stack .alert[data-kind='success'] {
background: color-mix(in srgb, var(--success-surface) 70%, transparent);
}
.stack .alert[data-kind='warning'] {
background: color-mix(in srgb, var(--warning-surface) 70%, transparent);
}
.stack .alert[data-kind='error'] {
background: color-mix(in srgb, var(--danger-surface) 70%, transparent);
}
@media (prefers-reduced-motion: reduce) {
.stack .alert {
transition: none;
}
}
index.html 233 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Alerts</title>
<link rel="stylesheet" href="./alert.css" />
<script type="module" src="./main.ts"></script>
</head>
<body>
<main class="demo">
<section class="page" data-page aria-label="Every kind of alert">
<!-- Every kind at once. The close buttons are not focusable: dismissing
belongs to the second page. -->
<ul class="alerts">
<!-- A kind is data-kind on the card. intro plays the icon once the card's
own entrance has finished; hover plays it again. The image shows until
the icon is ready. -->
<li class="alert" data-kind="info">
<lord-icon
class="alert__icon"
current-color
src="icons/info-circle.json"
intro="hover-pinch, after=.alert"
trigger="hover"
target=".alert"
aria-hidden="true"
>
<img alt="" src="icons/info-circle.svg" />
</lord-icon>
<div class="alert__body">
<p class="alert__title">Scheduled maintenance</p>
<p class="alert__message">
Online banking will be unavailable Sunday 2–4 AM ET for scheduled
maintenance.
</p>
</div>
<button
class="alert__close"
type="button"
aria-label="Dismiss"
tabindex="-1"
>
<lord-icon
class="alert__close-icon"
current-color
src="icons/cross.json"
loading="interaction"
trigger="hover"
target=".alert__close"
aria-hidden="true"
>
<img alt="" src="icons/cross.svg" />
</lord-icon>
</button>
</li>
<li class="alert" data-kind="success">
<lord-icon
class="alert__icon"
current-color
src="icons/check-circle.json"
intro="hover-pinch, after=.alert"
trigger="hover"
target=".alert"
aria-hidden="true"
>
<img alt="" src="icons/check-circle.svg" />
</lord-icon>
<div class="alert__body">
<p class="alert__title">Transfer complete</p>
<p class="alert__message">
$1,250.00 was successfully sent to Alex Morgan.
</p>
</div>
<button
class="alert__close"
type="button"
aria-label="Dismiss"
tabindex="-1"
>
<lord-icon
class="alert__close-icon"
current-color
src="icons/cross.json"
loading="interaction"
trigger="hover"
target=".alert__close"
aria-hidden="true"
>
<img alt="" src="icons/cross.svg" />
</lord-icon>
</button>
</li>
<li class="alert" data-kind="warning">
<lord-icon
class="alert__icon"
current-color
src="icons/warning-triangle.json"
intro="hover-pinch, after=.alert"
trigger="hover"
target=".alert"
aria-hidden="true"
>
<img alt="" src="icons/warning-triangle.svg" />
</lord-icon>
<div class="alert__body">
<p class="alert__title">Low balance</p>
<p class="alert__message">
Your checking account is below $100. Consider transferring funds to
avoid overdraft.
</p>
</div>
<button
class="alert__close"
type="button"
aria-label="Dismiss"
tabindex="-1"
>
<lord-icon
class="alert__close-icon"
current-color
src="icons/cross.json"
loading="interaction"
trigger="hover"
target=".alert__close"
aria-hidden="true"
>
<img alt="" src="icons/cross.svg" />
</lord-icon>
</button>
</li>
<li class="alert" data-kind="error">
<lord-icon
class="alert__icon"
current-color
src="icons/cross-circle.json"
intro="hover-pinch, after=.alert"
trigger="hover"
target=".alert"
aria-hidden="true"
>
<img alt="" src="icons/cross-circle.svg" />
</lord-icon>
<div class="alert__body">
<p class="alert__title">Payment declined</p>
<p class="alert__message">
Your card ending in 4242 was declined. Check your details and try
again.
</p>
</div>
<button
class="alert__close"
type="button"
aria-label="Dismiss"
tabindex="-1"
>
<lord-icon
class="alert__close-icon"
current-color
src="icons/cross.json"
loading="interaction"
trigger="hover"
target=".alert__close"
aria-hidden="true"
>
<img alt="" src="icons/cross.svg" />
</lord-icon>
</button>
</li>
</ul>
</section>
<section class="page page--deck" data-page aria-label="Alerts as they arrive" hidden>
<ul class="alerts stack" data-list="queue" role="status" aria-live="polite"></ul>
<button class="add" type="button" data-add>Show an alert</button>
</section>
<div class="pager">
<button
class="pager__dot"
type="button"
aria-pressed="true"
aria-label="Every kind of alert"
></button>
<button
class="pager__dot"
type="button"
aria-pressed="false"
aria-label="Alerts as they arrive"
></button>
</div>
<!-- The deck's cards are cloned from this: the same card as above, filled in by
build(). -->
<template id="alert-template">
<li class="alert">
<lord-icon
class="alert__icon"
current-color
intro="hover-pinch, after=.alert"
trigger="hover"
target=".alert"
aria-hidden="true"
></lord-icon>
<div class="alert__body">
<p class="alert__title"></p>
<p class="alert__message"></p>
</div>
<button class="alert__close" type="button" aria-label="Dismiss">
<lord-icon
class="alert__close-icon"
current-color
src="icons/cross.json"
trigger="hover"
target=".alert__close"
aria-hidden="true"
></lord-icon>
</button>
</li>
</template>
</main>
</body>
</html>
main.ts 113 lines
import { defineElement } from '@lordicon/element';
// Registers <lord-icon> with its built-in triggers. The markup says what each icon follows.
defineElement();
import { pager } from '@shared/ui/pager.ts';
type Kind = 'info' | 'success' | 'warning' | 'error';
const CONTENT: Record<Kind, { icon: string; title: string; message: string }> = {
info: {
icon: 'info-circle',
title: 'Scheduled maintenance',
message: 'Online banking will be unavailable Sunday 2–4 AM ET for scheduled maintenance.',
},
success: {
icon: 'check-circle',
title: 'Transfer complete',
message: '$1,250.00 was successfully sent to Alex Morgan.',
},
warning: {
icon: 'warning-triangle',
title: 'Low balance',
message:
'Your checking account is below $100. Consider transferring funds to avoid overdraft.',
},
error: {
icon: 'cross-circle',
title: 'Payment declined',
message: 'Your card ending in 4242 was declined. Check your details and try again.',
},
};
/** What the button queues, in order. */
const QUEUE: Kind[] = ['error', 'warning', 'success', 'info'];
const template = document.querySelector<HTMLTemplateElement>('#alert-template')!;
const queue = document.querySelector<HTMLElement>('[data-list="queue"]')!;
const add = document.querySelector<HTMLButtonElement>('[data-add]')!;
/** One alert from the template, for the deck. The gallery's cards are in the markup. */
function build(kind: Kind): HTMLElement {
const { icon, title, message } = CONTENT[kind];
const alert = template.content.cloneNode(true) as DocumentFragment;
const card = alert.querySelector<HTMLElement>('.alert')!;
card.dataset.kind = kind;
card.querySelector('.alert__icon')!.setAttribute('src', `icons/${icon}.json`);
card.querySelector('.alert__title')!.textContent = title;
card.querySelector('.alert__message')!.textContent = message;
return card;
}
/** The cards in the deck, not counting one on its way out. */
function standing(): HTMLElement[] {
return ([...queue.children] as HTMLElement[]).filter(
(card) => !card.hasAttribute('data-leaving'),
);
}
/** Writes each card's depth: the newest is 0 and in front, the rest recede behind it. */
function restack(): void {
const cards = standing();
cards.forEach((card, at) => {
card.style.setProperty('--depth', String(cards.length - 1 - at));
card.style.zIndex = String(at);
});
}
/** Marks the card as leaving, lets the stylesheet animate it out, then removes it. */
async function dismiss(card: HTMLElement): Promise<void> {
card.dataset.leaving = '';
// Restack now, so the deck closes up while the card is still on its way out.
settle();
// The leaving transitions only exist once a frame has passed with the attribute set.
await new Promise(requestAnimationFrame);
await Promise.allSettled(card.getAnimations().map((leaving) => leaving.finished));
card.remove();
settle();
}
/** The first kind not already in the deck, so dismissing one brings that kind back. */
function next(): Kind | undefined {
const up = new Set(standing().map((card) => card.dataset.kind));
return QUEUE.find((kind) => !up.has(kind));
}
function enqueue(): void {
const kind = next();
if (!kind) return;
const card = build(kind);
card.querySelector('.alert__close')!.addEventListener('click', () => void dismiss(card));
queue.append(card);
settle();
}
/** After any change: who sits where, and whether there is room for one more. */
function settle(): void {
restack();
add.disabled = !next();
}
add.addEventListener('click', enqueue);
pager();
settle();
import { defineElement } from '@lordicon/element';
// Registers <lord-icon> with its built-in triggers. The markup says what each icon follows.
defineElement();
import { pager } from '@shared/ui/pager.js';
const CONTENT = {
info: {
icon: 'info-circle',
title: 'Scheduled maintenance',
message: 'Online banking will be unavailable Sunday 2–4 AM ET for scheduled maintenance.',
},
success: {
icon: 'check-circle',
title: 'Transfer complete',
message: '$1,250.00 was successfully sent to Alex Morgan.',
},
warning: {
icon: 'warning-triangle',
title: 'Low balance',
message:
'Your checking account is below $100. Consider transferring funds to avoid overdraft.',
},
error: {
icon: 'cross-circle',
title: 'Payment declined',
message: 'Your card ending in 4242 was declined. Check your details and try again.',
},
};
/** What the button queues, in order. */
const QUEUE = ['error', 'warning', 'success', 'info'];
const template = document.querySelector('#alert-template');
const queue = document.querySelector('[data-list="queue"]');
const add = document.querySelector('[data-add]');
/** One alert from the template, for the deck. The gallery's cards are in the markup. */
function build(kind) {
const { icon, title, message } = CONTENT[kind];
const alert = template.content.cloneNode(true);
const card = alert.querySelector('.alert');
card.dataset.kind = kind;
card.querySelector('.alert__icon').setAttribute('src', `icons/${icon}.json`);
card.querySelector('.alert__title').textContent = title;
card.querySelector('.alert__message').textContent = message;
return card;
}
/** The cards in the deck, not counting one on its way out. */
function standing() {
return [...queue.children].filter((card) => !card.hasAttribute('data-leaving'));
}
/** Writes each card's depth: the newest is 0 and in front, the rest recede behind it. */
function restack() {
const cards = standing();
cards.forEach((card, at) => {
card.style.setProperty('--depth', String(cards.length - 1 - at));
card.style.zIndex = String(at);
});
}
/** Marks the card as leaving, lets the stylesheet animate it out, then removes it. */
async function dismiss(card) {
card.dataset.leaving = '';
// Restack now, so the deck closes up while the card is still on its way out.
settle();
// The leaving transitions only exist once a frame has passed with the attribute set.
await new Promise(requestAnimationFrame);
await Promise.allSettled(card.getAnimations().map((leaving) => leaving.finished));
card.remove();
settle();
}
/** The first kind not already in the deck, so dismissing one brings that kind back. */
function next() {
const up = new Set(standing().map((card) => card.dataset.kind));
return QUEUE.find((kind) => !up.has(kind));
}
function enqueue() {
const kind = next();
if (!kind) return;
const card = build(kind);
card.querySelector('.alert__close').addEventListener('click', () => void dismiss(card));
queue.append(card);
settle();
}
/** After any change: who sits where, and whether there is room for one more. */
function settle() {
restack();
add.disabled = !next();
}
add.addEventListener('click', enqueue);
pager();
settle();
What it imports 6 shared files
shared/styles/base.css 115 lines
/*
* 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
/* The one stylesheet a demo imports. */
@import './palette.css';
@import './tokens.css';
@import './base.css';
shared/styles/palette.css 87 lines
/*
* 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
/*
* 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/pager.css 40 lines
/* The dots that page between the parts of a demo. Where they sit is the demo's business. */
.pager {
display: flex;
gap: var(--space-4);
}
/* A small dot inside a button big enough to hit. */
.pager__dot {
display: grid;
place-items: center;
width: var(--space-24);
height: var(--space-24);
padding: 0;
border: 0;
background: none;
cursor: pointer;
}
.pager__dot::before {
content: '';
width: var(--space-8);
height: var(--space-8);
border-radius: var(--radius-pill);
background: var(--border-strong);
transition: background 150ms ease;
}
.pager__dot:hover::before {
background: var(--ink-subtle);
}
.pager__dot[aria-pressed='true']::before {
background: var(--accent);
}
@media (prefers-reduced-motion: reduce) {
.pager__dot::before {
transition: none;
}
}
shared/ui/pager.ts 21 lines
/**
* Dots that switch between the parts of one demo. Demo scaffolding, not a control: it
* exists so a demo can show three variants of a component without stacking them.
*
* Parts carry `data-page`, dots carry `.pager__dot`, both in source order. A hidden page is
* out of the tab order and the accessibility tree.
*/
export function pager(root: ParentNode = document): (shown: number) => void {
const pages = [...root.querySelectorAll<HTMLElement>('[data-page]')];
const dots = [...root.querySelectorAll<HTMLButtonElement>('.pager__dot')];
const show = (shown: number) => {
pages.forEach((page, at) => (page.hidden = at !== shown));
dots.forEach((dot, at) => dot.setAttribute('aria-pressed', String(at === shown)));
};
dots.forEach((dot, at) => dot.addEventListener('click', () => show(at)));
show(0);
return show;
}
/**
* Dots that switch between the parts of one demo. Demo scaffolding, not a control: it
* exists so a demo can show three variants of a component without stacking them.
*
* Parts carry `data-page`, dots carry `.pager__dot`, both in source order. A hidden page is
* out of the tab order and the accessibility tree.
*/
export function pager(root = document) {
const pages = [...root.querySelectorAll('[data-page]')];
const dots = [...root.querySelectorAll('.pager__dot')];
const show = (shown) => {
pages.forEach((page, at) => (page.hidden = at !== shown));
dots.forEach((dot, at) => dot.setAttribute('aria-pressed', String(at === shown)));
};
dots.forEach((dot, at) => dot.addEventListener('click', () => show(at)));
show(0);
return show;
}
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 |
|---|---|---|
info-circle |
system-outline-367-info-circle | |
check-circle |
system-outline-24-check-circle (edited by hand) | |
warning-triangle |
system-outline-1140-warning-triangle | |
cross-circle |
system-outline-25-cross-circle (edited by hand) | |
cross |
system-outline-38-cross |