A bell that rings when the count goes up, over a panel that unrolls and brings its rows in behind it.
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/notification-menu#standalone notification-menu
Copies the notification-menu folder of the
standalone branch — where every demo lives
on its own — into ./notification-menu.
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();
The bell button popovertarget opens the panel. data-count is what the icon watches.
<!-- popovertarget opens the panel; the browser closes it on Escape or a
click away. data-count is the count: the icon rings when it goes up and
the badge is drawn from it. aria-expanded is written by popovers(). -->
<button
class="bell"
type="button"
popovertarget="feed"
data-count="0"
aria-label="Notifications"
>
<lord-icon
class="bell__mark"
current-color
src="icons/bell.json"
trigger="follow(data-count)"
target=".bell"
aria-hidden="true"
>
<img alt="" src="icons/bell.svg" />
</lord-icon>
<span class="bell__badge" aria-hidden="true" hidden></span>
</button>
Set the count Writes the attribute and draws the badge. Nothing here reaches for the icon.
/**
* Sets the notification count. Writes data-count, which the bell's icon watches, and
* draws the badge. The page's initial count plays nothing: only changes do.
*/
function setCount(count: number): void {
const was = Number(bell.dataset.count) || 0;
if (count === was) return;
bell.dataset.count = String(count); // the icon watches this
for (const animation of badge.getAnimations()) animation.cancel();
if (count === 0) {
drop();
return;
}
badge.textContent = String(count);
badge.hidden = false;
if (prefersReducedMotion()) return;
badge.animate({ scale: [0, 1] }, { duration: POP_MS, easing: POP_EASE });
}
/**
* Sets the notification count. Writes data-count, which the bell's icon watches, and
* draws the badge. The page's initial count plays nothing: only changes do.
*/
function setCount(count) {
const was = Number(bell.dataset.count) || 0;
if (count === was) return;
bell.dataset.count = String(count); // the icon watches this
for (const animation of badge.getAnimations()) animation.cancel();
if (count === 0) {
drop();
return;
}
badge.textContent = String(count);
badge.hidden = false;
if (prefersReducedMotion()) return;
badge.animate({ scale: [0, 1] }, { duration: POP_MS, easing: POP_EASE });
}
Opening the menu clears the count The count goes down, so the bell stays quiet.
// Opening the menu reads the notifications: the count goes to zero. The bell stays quiet,
// because follow only plays when the count goes up.
menu.addEventListener('toggle', (event) => {
if ((event as ToggleEvent).newState === 'open') setCount(0);
});
// Opening the menu reads the notifications: the count goes to zero. The bell stays quiet,
// because follow only plays when the count goes up.
menu.addEventListener('toggle', (event) => {
if (event.newState === 'open') setCount(0);
});
One notification data-unread is the dot and the tint. data-rise marks a row the panel brings in.
<!-- One notification. data-unread is the dot and the tint. data-rise
marks a row the panel brings in; data-reveal marks the words that
arrive one after another. -->
<li class="feed__row" data-unread data-rise>
<button class="row" type="button">
<span class="row__mark">
<lord-icon
class="row__icon"
current-color
src="icons/heart.json"
trigger="hover"
target=".row"
aria-hidden="true"
></lord-icon>
</span>
<span class="row__body">
<span class="row__title" data-reveal>
Anna liked your Bell component
</span>
<span class="row__time">2 min ago</span>
</span>
<span class="row__dot" aria-hidden="true"></span>
<span class="visually-hidden">Unread</span>
</button>
</li>
Start the tint with the row popovers() hands back each row's delay; the tint starts from it.
// popovers() brings each data-rise row in and calls onRise with the delay it gave the
// row. Starting the tint from that delay keeps it in the same cascade as the row and its
// words. `reveal` names the titles whose words arrive one by one.
const opening: PopoverOptions = {
reveal: '[data-reveal]',
onRise(row, delay) {
if (row.hasAttribute('data-unread')) unroll(row, delay + UNROLL_AFTER);
},
};
popovers(document, opening);
// popovers() brings each data-rise row in and calls onRise with the delay it gave the
// row. Starting the tint from that delay keeps it in the same cascade as the row and its
// words. `reveal` names the titles whose words arrive one by one.
const opening = {
reveal: '[data-reveal]',
onRise(row, delay) {
if (row.hasAttribute('data-unread')) unroll(row, delay + UNROLL_AFTER);
},
};
popovers(document, opening);
The colour of a row The fill is a background image, so main.ts can animate its width.
/* What a row is filled with, at rest and hovered. The fill itself is the background
image below, so main.ts can animate its width. No transition. */
.feed__row {
--row-fill: transparent;
--row-hover: var(--surface-muted);
}
.feed__row[data-unread] {
--row-fill: var(--accent-surface);
--row-hover: color-mix(in srgb, var(--accent) 10%, var(--accent-surface));
}
.feed__row:has(.row:hover) {
--row-fill: var(--row-hover);
}
Every file
The demo's own files, whole, at the paths the export uses.
index.html 160 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Notification menu</title>
<link rel="stylesheet" href="./notification-menu.css" />
<script type="module" src="./main.ts"></script>
</head>
<body>
<main class="demo">
<div class="stage">
<!-- popovertarget opens the panel; the browser closes it on Escape or a
click away. data-count is the count: the icon rings when it goes up and
the badge is drawn from it. aria-expanded is written by popovers(). -->
<button
class="bell"
type="button"
popovertarget="feed"
data-count="0"
aria-label="Notifications"
>
<lord-icon
class="bell__mark"
current-color
src="icons/bell.json"
trigger="follow(data-count)"
target=".bell"
aria-hidden="true"
>
<img alt="" src="icons/bell.svg" />
</lord-icon>
<span class="bell__badge" aria-hidden="true" hidden></span>
</button>
<div class="menu popover" id="feed" popover>
<div class="menu__head" data-rise>
<h2 class="menu__title">Notification</h2>
<button class="menu__read" type="button">Mark as read</button>
</div>
<ul class="feed">
<!-- One notification. data-unread is the dot and the tint. data-rise
marks a row the panel brings in; data-reveal marks the words that
arrive one after another. -->
<li class="feed__row" data-unread data-rise>
<button class="row" type="button">
<span class="row__mark">
<lord-icon
class="row__icon"
current-color
src="icons/heart.json"
trigger="hover"
target=".row"
aria-hidden="true"
></lord-icon>
</span>
<span class="row__body">
<span class="row__title" data-reveal>
Anna liked your Bell component
</span>
<span class="row__time">2 min ago</span>
</span>
<span class="row__dot" aria-hidden="true"></span>
<span class="visually-hidden">Unread</span>
</button>
</li>
<li class="feed__row" data-unread data-rise>
<button class="row" type="button">
<span class="row__mark">
<lord-icon
class="row__icon"
current-color
src="icons/download.json"
trigger="hover"
target=".row"
aria-hidden="true"
></lord-icon>
</span>
<span class="row__body">
<span class="row__title" data-reveal>
Version 1.4.0 is now available
</span>
<span class="row__time">1 hour ago</span>
</span>
<span class="row__dot" aria-hidden="true"></span>
<span class="visually-hidden">Unread</span>
</button>
</li>
<li class="feed__row" data-rise>
<button class="row" type="button">
<span class="row__mark">
<lord-icon
class="row__icon"
current-color
src="icons/message.json"
trigger="hover"
target=".row"
aria-hidden="true"
></lord-icon>
</span>
<span class="row__body">
<span class="row__title" data-reveal>
Marek commented: “amazing job!”
</span>
<span class="row__time">yesterday</span>
</span>
</button>
</li>
<li class="feed__row" data-rise>
<button class="row" type="button">
<span class="row__mark">
<lord-icon
class="row__icon"
current-color
src="icons/star.json"
trigger="hover"
target=".row"
aria-hidden="true"
></lord-icon>
</span>
<span class="row__body">
<span class="row__title" data-reveal>
Your plugin just passed 500 stars
</span>
<span class="row__time">2 days ago</span>
</span>
</button>
</li>
</ul>
</div>
</div>
<!-- The demo's own controls, not part of the component. -->
<form class="controls">
<label class="controls__label" for="count">Notifications</label>
<input
class="controls__input"
id="count"
type="number"
min="0"
max="99"
value="5"
inputmode="numeric"
/>
<button class="controls__go" type="submit">Set</button>
</form>
</main>
</body>
</html>
main.ts 105 lines
import { defineElement } from '@lordicon/element';
// Registers <lord-icon> with its built-in triggers. The markup says what each icon follows.
defineElement();
import { prefersReducedMotion } from '@shared/motion/reduced-motion.ts';
import { popovers, type PopoverOptions } from '@shared/ui/popover.ts';
const bell = document.querySelector<HTMLElement>('.bell')!;
const badge = document.querySelector<HTMLElement>('.bell__badge')!;
const menu = document.querySelector<HTMLElement>('.menu')!;
/** The badge arriving: an overshoot that settles. From the reference recording. */
const POP_MS = 350;
const POP_EASE = 'cubic-bezier(0.34, 1.8, 0.64, 1)';
/** The badge leaving. */
const DROP_MS = 100;
/**
* Sets the notification count. Writes data-count, which the bell's icon watches, and
* draws the badge. The page's initial count plays nothing: only changes do.
*/
function setCount(count: number): void {
const was = Number(bell.dataset.count) || 0;
if (count === was) return;
bell.dataset.count = String(count); // the icon watches this
for (const animation of badge.getAnimations()) animation.cancel();
if (count === 0) {
drop();
return;
}
badge.textContent = String(count);
badge.hidden = false;
if (prefersReducedMotion()) return;
badge.animate({ scale: [0, 1] }, { duration: POP_MS, easing: POP_EASE });
}
/** Shrinks the badge away, then takes it out of the layout. */
function drop(): void {
if (prefersReducedMotion()) {
badge.hidden = true;
return;
}
const leaving = badge.animate({ scale: [1, 0] }, { duration: DROP_MS, easing: 'ease-in' });
// A rejection means a newer count cancelled this animation and owns the badge now.
void leaving.finished.then(() => (badge.hidden = true)).catch(() => null);
}
// Opening the menu reads the notifications: the count goes to zero. The bell stays quiet,
// because follow only plays when the count goes up.
menu.addEventListener('toggle', (event) => {
if ((event as ToggleEvent).newState === 'open') setCount(0);
});
/** The tint behind a new row, unrolled left to right. */
const UNROLL_MS = 370;
const UNROLL_EASE = 'cubic-bezier(0.25, 1, 0.5, 1)';
/** After the row's own delay, so the words are already on their way. */
const UNROLL_AFTER = 90;
// popovers() brings each data-rise row in and calls onRise with the delay it gave the
// row. Starting the tint from that delay keeps it in the same cascade as the row and its
// words. `reveal` names the titles whose words arrive one by one.
const opening: PopoverOptions = {
reveal: '[data-reveal]',
onRise(row, delay) {
if (row.hasAttribute('data-unread')) unroll(row, delay + UNROLL_AFTER);
},
};
popovers(document, opening);
/**
* The tint is a background image, not a colour, so it has a width to animate. The
* stylesheet says what colour it is. Only called from onRise, which popovers() skips
* under reduced motion.
*/
function unroll(row: HTMLElement, delay: number): void {
row.animate(
{ backgroundSize: ['0% 100%', '100% 100%'] },
{ duration: UNROLL_MS, delay, easing: UNROLL_EASE, fill: 'backwards' },
);
}
// The demo's own controls: a way to set the count on a page with no server behind it.
const form = document.querySelector<HTMLFormElement>('.controls')!;
const input = document.querySelector<HTMLInputElement>('.controls__input')!;
form.addEventListener('submit', (event) => {
event.preventDefault();
setCount(Math.max(0, Math.trunc(Number(input.value) || 0)));
});
// One arrival after the page has settled, so the bell is seen at rest first.
setTimeout(() => setCount(2), 1000);
import { defineElement } from '@lordicon/element';
// Registers <lord-icon> with its built-in triggers. The markup says what each icon follows.
defineElement();
import { prefersReducedMotion } from '@shared/motion/reduced-motion.js';
import { popovers } from '@shared/ui/popover.js';
const bell = document.querySelector('.bell');
const badge = document.querySelector('.bell__badge');
const menu = document.querySelector('.menu');
/** The badge arriving: an overshoot that settles. From the reference recording. */
const POP_MS = 350;
const POP_EASE = 'cubic-bezier(0.34, 1.8, 0.64, 1)';
/** The badge leaving. */
const DROP_MS = 100;
/**
* Sets the notification count. Writes data-count, which the bell's icon watches, and
* draws the badge. The page's initial count plays nothing: only changes do.
*/
function setCount(count) {
const was = Number(bell.dataset.count) || 0;
if (count === was) return;
bell.dataset.count = String(count); // the icon watches this
for (const animation of badge.getAnimations()) animation.cancel();
if (count === 0) {
drop();
return;
}
badge.textContent = String(count);
badge.hidden = false;
if (prefersReducedMotion()) return;
badge.animate({ scale: [0, 1] }, { duration: POP_MS, easing: POP_EASE });
}
/** Shrinks the badge away, then takes it out of the layout. */
function drop() {
if (prefersReducedMotion()) {
badge.hidden = true;
return;
}
const leaving = badge.animate({ scale: [1, 0] }, { duration: DROP_MS, easing: 'ease-in' });
// A rejection means a newer count cancelled this animation and owns the badge now.
void leaving.finished.then(() => (badge.hidden = true)).catch(() => null);
}
// Opening the menu reads the notifications: the count goes to zero. The bell stays quiet,
// because follow only plays when the count goes up.
menu.addEventListener('toggle', (event) => {
if (event.newState === 'open') setCount(0);
});
/** The tint behind a new row, unrolled left to right. */
const UNROLL_MS = 370;
const UNROLL_EASE = 'cubic-bezier(0.25, 1, 0.5, 1)';
/** After the row's own delay, so the words are already on their way. */
const UNROLL_AFTER = 90;
// popovers() brings each data-rise row in and calls onRise with the delay it gave the
// row. Starting the tint from that delay keeps it in the same cascade as the row and its
// words. `reveal` names the titles whose words arrive one by one.
const opening = {
reveal: '[data-reveal]',
onRise(row, delay) {
if (row.hasAttribute('data-unread')) unroll(row, delay + UNROLL_AFTER);
},
};
popovers(document, opening);
/**
* The tint is a background image, not a colour, so it has a width to animate. The
* stylesheet says what colour it is. Only called from onRise, which popovers() skips
* under reduced motion.
*/
function unroll(row, delay) {
row.animate(
{ backgroundSize: ['0% 100%', '100% 100%'] },
{ duration: UNROLL_MS, delay, easing: UNROLL_EASE, fill: 'backwards' },
);
}
// The demo's own controls: a way to set the count on a page with no server behind it.
const form = document.querySelector('.controls');
const input = document.querySelector('.controls__input');
form.addEventListener('submit', (event) => {
event.preventDefault();
setCount(Math.max(0, Math.trunc(Number(input.value) || 0)));
});
// One arrival after the page has settled, so the bell is seen at rest first.
setTimeout(() => setCount(2), 1000);
notification-menu.css 230 lines
@import '@shared/styles/index.css';
@import '@shared/ui/popover.css';
@import '@shared/ui/menu.css';
/* The bell near the top, so the panel has room to hang under it. */
.demo {
display: grid;
grid-template-rows: 1fr auto;
justify-items: center;
gap: var(--space-48);
min-height: 100dvh;
padding: var(--space-48) var(--gutter) var(--space-32);
}
/* A box for the badge to hang off. */
.stage {
display: grid;
align-self: start;
justify-items: center;
}
/* Resting, hovered, and open. aria-expanded is written by popovers(). */
.bell {
position: relative;
display: grid;
place-items: center;
padding: var(--space-8);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
color: var(--ink);
cursor: pointer;
--icon-filter: var(--filter-ink);
}
.bell:hover {
background: var(--surface-muted);
}
.bell[aria-expanded='true'] {
background: var(--border-soft);
}
.bell__mark {
display: block;
width: var(--icon-size-24);
height: var(--icon-size-24);
}
/* Hung off the button's corner, so the button keeps its size. A circle at one digit,
a pill at two. */
.bell__badge {
position: absolute;
top: calc(var(--space-8) * -1);
right: calc(var(--space-12) * -1);
min-width: var(--icon-size-24);
padding: 0 var(--space-4);
border-radius: var(--radius-pill);
background: var(--accent);
color: var(--ink-inverse);
font-size: var(--text-xs);
line-height: var(--leading-base);
font-weight: 700;
text-align: center;
transform-origin: center;
}
/* The panel is menu.css's. Only the width is this demo's. */
.menu {
--menu-width: 350px;
max-width: calc(100vw - var(--space-24));
}
.menu__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-12);
}
.menu__title {
margin: 0;
font-size: var(--text-sm);
line-height: var(--leading-base);
font-weight: 700;
}
.menu__read {
padding: var(--space-4) var(--space-12);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
font-size: var(--text-sm);
line-height: var(--leading-base);
font-weight: 500;
cursor: pointer;
}
.menu__read:hover {
background: var(--surface-muted);
}
.feed {
margin: 0;
padding: 0;
list-style: none;
}
/* What a row is filled with, at rest and hovered. The fill itself is the background
image below, so main.ts can animate its width. No transition. */
.feed__row {
--row-fill: transparent;
--row-hover: var(--surface-muted);
}
.feed__row[data-unread] {
--row-fill: var(--accent-surface);
--row-hover: color-mix(in srgb, var(--accent) 10%, var(--accent-surface));
}
.feed__row:has(.row:hover) {
--row-fill: var(--row-hover);
}
/* Adjacent unread rows form one rounded block: the edges where they meet are square. */
.feed__row {
border-radius: var(--radius-md);
background-image: linear-gradient(var(--row-fill), var(--row-fill));
background-repeat: no-repeat;
background-size: 100% 100%;
}
.feed__row[data-unread]:has(+ [data-unread]) {
border-end-start-radius: 0;
border-end-end-radius: 0;
}
[data-unread] + .feed__row[data-unread] {
border-start-start-radius: 0;
border-start-end-radius: 0;
}
/* The row is menu.css's, with two lines of text. The <li> paints the fill and owns the
rounding, so the row's own hover fill is off. */
.row {
--row-align: flex-start;
--row-gap: var(--space-12);
--row-pad: var(--space-12);
--row-hover: transparent;
border-radius: inherit;
}
/* The disc the icon sits in. */
.row__mark {
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);
}
.row__body {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
font-size: var(--text-sm);
font-weight: 500;
}
.row__title {
line-height: var(--leading-base);
}
.row__time {
color: var(--ink-subtle);
line-height: var(--leading-sm);
}
/* Not animated: the tint already says the row is new. */
.row__dot {
flex: none;
width: 6px;
height: 6px;
margin-top: var(--space-4);
border-radius: var(--radius-pill);
background: var(--accent);
}
.controls {
display: flex;
align-items: center;
gap: var(--space-8);
padding: var(--space-12);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--surface);
}
.controls__label {
font-size: var(--text-sm);
font-weight: 500;
}
.controls__input {
width: 72px;
padding: var(--space-4) var(--space-8);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
font-size: var(--text-sm);
font-weight: 500;
}
.controls__go {
padding: var(--space-4) var(--space-12);
border: 0;
border-radius: var(--radius-md);
background: var(--accent);
color: var(--ink-inverse);
font-size: var(--text-sm);
font-weight: 500;
cursor: pointer;
}
.controls__go:hover {
background: var(--accent-strong);
}
What it imports 9 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;
}
/**
* 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() {
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' },
);
}
/**
* 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.js';
/** 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)';
/**
* 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) {
const already = element.querySelectorAll('[data-word]');
if (already.length) return [...already];
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
const texts = [];
while (walker.nextNode()) texts.push(walker.currentNode);
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('[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) {
for (const animation of element.getAnimations()) animation.cancel();
for (const word of element.querySelectorAll('[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, options = {}) {
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, options = {}) {
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
/*
* 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/menu.css 94 lines
/*
* The look of a menu: the panel, its group headings and its rows. Placement and the
* opening animation are in popover.ts and popover.css; the keyboard cursor is in menu.ts.
*
* Import after popover.css, which resets the UA popover rules at the same specificity.
* Most demos only need to set `--menu-width`.
*/
/* `display: flex` matters: popover.css hides a closed panel with `display: none`, and an
author `display` is what keeps the browser's own hiding rule from applying. */
.menu {
display: flex;
flex-direction: column;
gap: var(--menu-gap, var(--space-12));
width: var(--menu-width);
padding: var(--menu-pad, var(--space-12));
border: 1px solid var(--border-soft);
border-radius: var(--radius-lg);
background: var(--surface);
box-shadow: var(--shadow-sm);
/* The UA sheet gives a popover `color: CanvasText`; nothing inherits past it. */
color: var(--ink);
}
/* The panel takes focus when it opens so arrow keys have somewhere to land, but the cursor
drawn by menu.ts is what the eye should follow, not a ring around the whole box. */
.menu[data-keys]:focus {
outline: none;
}
.menu__group {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.menu__heading {
margin: 0;
color: var(--ink-muted);
font-size: var(--text-sm);
line-height: var(--leading-sm);
font-weight: 600;
}
/* `flex: none`: a column animating from `height: 0` squashes its children unless they
refuse to shrink. */
.menu__list {
display: flex;
flex-direction: column;
flex: none;
}
/* Two variables colour a row: `--row-fill` at rest and `--row-hover` under the cursor.
A row that is already tinted, such as an unread notification, sets both. */
.row {
--row-fill: transparent;
--row-hover: var(--surface-muted);
display: flex;
align-items: var(--row-align, center);
gap: var(--row-gap, var(--space-8));
width: 100%;
padding: var(--row-pad, var(--space-8));
border: 0;
border-radius: var(--radius-md);
background: var(--row-fill);
color: var(--ink);
font: inherit;
text-align: start;
cursor: pointer;
}
/* `data-active` is written by the keyboard cursor and by the pointer alike, so hovered and
arrowed-to are one state with one look. No transition: this is a state, not a movement. */
.row:hover,
.row[data-active='true'] {
background: var(--row-hover);
}
/* The rows touch, so the focus ring goes inside the row rather than over its neighbours. */
.row:focus-visible {
outline-offset: -2px;
}
.row__icon {
display: block;
flex: none;
width: var(--icon-size-24);
height: var(--icon-size-24);
}
.row__label {
flex: 1;
font-weight: 500;
}
shared/ui/popover.css 63 lines
/*
* The look of a popover panel, which way it opened, and how it leaves. Placement and the
* opening animation are in popover.ts, which writes `data-direction` on the panel before
* it is painted.
*/
/* The UA sheet pins a popover to all four edges and centres it with auto margins.
popover.ts sets two of the edges, so the rest is undone here. */
.popover {
position: fixed;
inset: auto;
margin: 0;
padding: 0;
border: 0;
/* The UA `overflow: auto` would clip a submenu reaching across the gap. The opening
animation clips from an inline style and hands back to this afterwards. */
overflow: visible;
}
/* The browser hides a closed popover from its own stylesheet, and any author `display`
overrides that. Without this rule, a panel given `display: flex` is never hidden. */
.popover:not(:popover-open) {
display: none;
}
/* Leaving is a CSS transition. A closing popover leaves the top layer at once; the
`allow-discrete` transitions hold `display` and `overlay` until the fade is over. */
.popover {
--popover-leave: var(--space-12);
opacity: 0;
translate: 0 var(--popover-leave);
transition:
opacity 160ms ease-in,
translate 160ms ease-in,
overlay 160ms allow-discrete,
display 160ms allow-discrete;
}
.popover:popover-open {
opacity: 1;
translate: 0 0;
transition: none;
}
/* Opening upward: the panel leaves upward too, and while it is still short its rows
overflow at the top, so the ones nearest the control show first. Children must not
shrink, or the flex column squashes them instead of overflowing. */
.popover[data-direction='up'] {
--popover-leave: calc(var(--space-12) * -1);
justify-content: flex-end;
}
.popover[data-direction='up'] > * {
flex-shrink: 0;
}
/* popover.ts skips its animations under reduced motion too. */
@media (prefers-reduced-motion: reduce) {
.popover {
translate: 0 0;
transition: none;
}
}
shared/ui/popover.ts 249 lines
/**
* A panel that opens beside the control that owns it, built on the browser's popover:
*
* <button type="button" popovertarget="feed">…</button>
* <div id="feed" popover>…</div>
*
* The browser draws the panel in the top layer, closes it on Escape and on a click away,
* and links button and panel for assistive tech. This module adds what it does not do:
*
* - places the panel against the button, opening up or down depending on the room
* - writes `aria-expanded` on the button and `data-direction` on the panel
* - animates the opening: the panel's height unrolls and its `[data-rise]` rows fade in
* one after another, starting from the end nearest the button
* - closes the whole chain of panels when a `[data-choose]` row is clicked, and raises a
* `popover-choose` event carrying the row
*
* Closing is not animated here. A closing popover leaves the top layer, and the only way to
* hold it there is `transition-behavior: allow-discrete` in popover.css.
*/
import { prefersReducedMotion } from '../motion/reduced-motion.ts';
import { revealText } from '../motion/text-reveal.ts';
/** Between the control and the panel's near edge. */
const GAP = 8;
/** The height unrolling. */
const OPEN_MS = 220;
const OPEN_EASE = 'cubic-bezier(0.3, 0, 0.2, 1)';
/** Before the first row starts, and between one row and the next. */
const RISE_LEAD = 60;
const RISE_STAGGER = 40;
/** How far beyond its place a row starts. */
const RISE_DISTANCE = 12;
/** A row settling into place. Its fade is shorter, so it is readable before it stops. */
const RISE_MS = 360;
const RISE_EASE = 'cubic-bezier(0.25, 1, 0.5, 1)';
const FADE_MS = 180;
/** A label arriving word by word, when a `reveal` selector is given. */
const REVEAL_MS = 260;
const REVEAL_FADE = 0.45;
export type PopoverDirection = 'up' | 'down';
/** What a `popover-choose` event carries. */
export type PopoverChoice = { item: HTMLElement };
export type PopoverOptions = {
gap?: number;
/** Which way the panel opens. `auto` picks the side with more room. */
direction?: PopoverDirection | 'auto';
duration?: number;
lead?: number;
stagger?: number;
/** Called for each rising row with the delay it was given, to start more in step. */
onRise?: (row: HTMLElement, delay: number) => void;
/** A selector inside each rising row whose text arrives word by word. */
reveal?: string;
};
const wired = new WeakSet<HTMLElement>();
/**
* Which way there is room to open. Decided from the control's rectangle alone, because the
* panel is still hidden and has no height yet. A tie opens downwards. A panel opened from
* inside another panel follows that one.
*/
function roomFor(anchor: HTMLElement, gap: number): PopoverDirection {
const outer = anchor.closest<HTMLElement>('[data-direction]');
if (outer) return outer.dataset.direction as PopoverDirection;
const box = anchor.getBoundingClientRect();
const below = document.documentElement.clientHeight - box.bottom - gap;
return below >= box.top - gap ? 'down' : 'up';
}
/**
* Puts the panel against the control. A popover is positioned like a fixed element, so
* the control's viewport rectangle is already in the right coordinates. The panel is
* pinned by the edge it grows away from, so the height animation moves the far edge only.
*
* `data-side="inline"` on the panel puts it beside the control instead, for a submenu.
* It clears the panel the control sits in, not the control itself.
*
* Runs before the panel shows, so the browser's own position is never painted, and again
* after, when the panel has a width to keep on screen.
*/
function place(anchor: HTMLElement, panel: HTMLElement, gap: number, to: PopoverDirection): void {
const box = anchor.getBoundingClientRect();
const wide = document.documentElement.clientWidth;
const tall = document.documentElement.clientHeight;
const width = panel.offsetWidth; // 0 while hidden
const beside = panel.dataset.side === 'inline';
// Both edges are written every time; an inline `top` from an earlier opening would
// otherwise fight a `bottom` set now.
panel.style.top = to === 'down' ? `${beside ? box.top : box.bottom + gap}px` : 'auto';
panel.style.bottom = to === 'up' ? `${tall - (beside ? box.bottom : box.top - gap)}px` : 'auto';
if (!beside) {
panel.style.left = `${Math.max(gap, Math.min(box.left, wide - width - gap))}px`;
return;
}
const from = anchor.closest<HTMLElement>('[popover]')?.getBoundingClientRect() ?? box;
const after = from.right + gap;
panel.style.left = `${after + width <= wide - gap ? after : Math.max(gap, from.left - gap - width)}px`;
}
/**
* Collapses the panel before it is painted. `toggle` fires after the panel is already
* showing at full height, so a frame of it would flash; `beforetoggle` runs while it is
* still hidden.
*/
function collapse(panel: HTMLElement): void {
panel.style.overflow = 'clip';
panel.style.height = '0px';
}
/**
* Animates the height from collapsed to full. Both ends are measured, not assumed: with
* `box-sizing: border-box`, `height: 0` still renders as padding plus border, and animating
* from 0 would spend the first part of the curve below that floor with nothing moving.
* `offsetHeight` rather than `scrollHeight` at the far end, because it includes the border.
*/
function unroll(panel: HTMLElement, duration: number): void {
const from = panel.offsetHeight;
panel.style.height = '';
const to = panel.offsetHeight;
const opening = panel.animate(
{ height: [`${from}px`, `${to}px`] },
{ duration, easing: OPEN_EASE, fill: 'backwards' },
);
void opening.finished
.catch(() => null)
.then(() => {
panel.style.overflow = '';
});
}
/** The outermost panel in a chain of nested ones. */
function outermost(panel: HTMLElement): HTMLElement {
let found = panel;
let above = found.parentElement?.closest<HTMLElement>('[popover]');
while (above) {
found = above;
above = found.parentElement?.closest<HTMLElement>('[popover]');
}
return found;
}
/**
* A `[data-choose]` row was clicked: raise `popover-choose` and close the whole chain.
* Rows without the attribute, such as one that opens a submenu, do not close anything.
*/
function choose(panel: HTMLElement, item: HTMLElement): void {
const detail: PopoverChoice = { item };
panel.dispatchEvent(new CustomEvent('popover-choose', { bubbles: true, detail }));
outermost(panel).hidePopover();
}
/** The panel's own `[data-rise]` rows, not those of a submenu nested inside it. */
function own(panel: HTMLElement): HTMLElement[] {
return [...panel.querySelectorAll<HTMLElement>('[data-rise]')].filter((row) => {
const inner = row.closest('[popover]');
return inner === panel || !panel.contains(inner);
});
}
/**
* Fades the rows in from a little beyond their place, one after another. An upward panel
* takes its rows in reverse, so the row nearest the control always arrives first.
*/
function raise(panel: HTMLElement, to: PopoverDirection, options: PopoverOptions): void {
const { lead = RISE_LEAD, stagger = RISE_STAGGER } = options;
const rows = own(panel);
const cascade = to === 'up' ? rows.reverse() : rows;
const from = to === 'up' ? RISE_DISTANCE : -RISE_DISTANCE;
cascade.forEach((row, index) => {
const delay = lead + index * stagger;
row.animate(
{ opacity: [0, 1] },
{ duration: FADE_MS, delay, easing: 'ease-out', fill: 'backwards' },
);
row.animate(
{ transform: [`translateY(${from}px)`, 'translateY(0)'] },
{ duration: RISE_MS, delay, easing: RISE_EASE, fill: 'backwards' },
);
const label = options.reveal && row.querySelector<HTMLElement>(options.reveal);
if (label) revealText(label, { duration: REVEAL_MS, fadeShare: REVEAL_FADE, delay });
options.onRise?.(row, delay);
});
}
/** Wires every `[popovertarget]` under `root` to the panel it names. Called once. */
export function popovers(root: ParentNode = document, options: PopoverOptions = {}): void {
const { gap = GAP, duration = OPEN_MS, direction = 'auto' } = options;
for (const anchor of root.querySelectorAll<HTMLElement>('[popovertarget]')) {
const panel = document.getElementById(anchor.getAttribute('popovertarget') ?? '');
if (!panel || wired.has(panel)) continue;
wired.add(panel);
// Placing and collapsing happen in `beforetoggle`, before the panel is painted.
panel.addEventListener('beforetoggle', (event) => {
if ((event as ToggleEvent).newState !== 'open') return;
const to = direction === 'auto' ? roomFor(anchor, gap) : direction;
panel.dataset.direction = to; // read again in `toggle`, and by the stylesheet
place(anchor, panel, gap, to);
if (!prefersReducedMotion()) collapse(panel);
});
// Only this panel's rows: a click inside a nested panel bubbles through here too.
panel.addEventListener('click', (event) => {
const item = (event.target as Element).closest<HTMLElement>('[data-choose]');
if (item && item.closest('[popover]') === panel) choose(panel, item);
});
panel.addEventListener('toggle', (event) => {
const open = (event as ToggleEvent).newState === 'open';
const to = (panel.dataset.direction ?? 'down') as PopoverDirection;
if (open) place(anchor, panel, gap, to); // again, now that it has a width
// The one attribute the stylesheet and an icon trigger can read the state from.
anchor.setAttribute('aria-expanded', String(open));
if (!open || prefersReducedMotion()) return;
unroll(panel, duration);
raise(panel, to, options);
});
}
}
/**
* A panel that opens beside the control that owns it, built on the browser's popover:
*
* <button type="button" popovertarget="feed">…</button>
* <div id="feed" popover>…</div>
*
* The browser draws the panel in the top layer, closes it on Escape and on a click away,
* and links button and panel for assistive tech. This module adds what it does not do:
*
* - places the panel against the button, opening up or down depending on the room
* - writes `aria-expanded` on the button and `data-direction` on the panel
* - animates the opening: the panel's height unrolls and its `[data-rise]` rows fade in
* one after another, starting from the end nearest the button
* - closes the whole chain of panels when a `[data-choose]` row is clicked, and raises a
* `popover-choose` event carrying the row
*
* Closing is not animated here. A closing popover leaves the top layer, and the only way to
* hold it there is `transition-behavior: allow-discrete` in popover.css.
*/
import { prefersReducedMotion } from '../motion/reduced-motion.js';
import { revealText } from '../motion/text-reveal.js';
/** Between the control and the panel's near edge. */
const GAP = 8;
/** The height unrolling. */
const OPEN_MS = 220;
const OPEN_EASE = 'cubic-bezier(0.3, 0, 0.2, 1)';
/** Before the first row starts, and between one row and the next. */
const RISE_LEAD = 60;
const RISE_STAGGER = 40;
/** How far beyond its place a row starts. */
const RISE_DISTANCE = 12;
/** A row settling into place. Its fade is shorter, so it is readable before it stops. */
const RISE_MS = 360;
const RISE_EASE = 'cubic-bezier(0.25, 1, 0.5, 1)';
const FADE_MS = 180;
/** A label arriving word by word, when a `reveal` selector is given. */
const REVEAL_MS = 260;
const REVEAL_FADE = 0.45;
/** What a `popover-choose` event carries. */
const wired = new WeakSet();
/**
* Which way there is room to open. Decided from the control's rectangle alone, because the
* panel is still hidden and has no height yet. A tie opens downwards. A panel opened from
* inside another panel follows that one.
*/
function roomFor(anchor, gap) {
const outer = anchor.closest('[data-direction]');
if (outer) return outer.dataset.direction;
const box = anchor.getBoundingClientRect();
const below = document.documentElement.clientHeight - box.bottom - gap;
return below >= box.top - gap ? 'down' : 'up';
}
/**
* Puts the panel against the control. A popover is positioned like a fixed element, so
* the control's viewport rectangle is already in the right coordinates. The panel is
* pinned by the edge it grows away from, so the height animation moves the far edge only.
*
* `data-side="inline"` on the panel puts it beside the control instead, for a submenu.
* It clears the panel the control sits in, not the control itself.
*
* Runs before the panel shows, so the browser's own position is never painted, and again
* after, when the panel has a width to keep on screen.
*/
function place(anchor, panel, gap, to) {
const box = anchor.getBoundingClientRect();
const wide = document.documentElement.clientWidth;
const tall = document.documentElement.clientHeight;
const width = panel.offsetWidth; // 0 while hidden
const beside = panel.dataset.side === 'inline';
// Both edges are written every time; an inline `top` from an earlier opening would
// otherwise fight a `bottom` set now.
panel.style.top = to === 'down' ? `${beside ? box.top : box.bottom + gap}px` : 'auto';
panel.style.bottom = to === 'up' ? `${tall - (beside ? box.bottom : box.top - gap)}px` : 'auto';
if (!beside) {
panel.style.left = `${Math.max(gap, Math.min(box.left, wide - width - gap))}px`;
return;
}
const from = anchor.closest('[popover]')?.getBoundingClientRect() ?? box;
const after = from.right + gap;
panel.style.left = `${after + width <= wide - gap ? after : Math.max(gap, from.left - gap - width)}px`;
}
/**
* Collapses the panel before it is painted. `toggle` fires after the panel is already
* showing at full height, so a frame of it would flash; `beforetoggle` runs while it is
* still hidden.
*/
function collapse(panel) {
panel.style.overflow = 'clip';
panel.style.height = '0px';
}
/**
* Animates the height from collapsed to full. Both ends are measured, not assumed: with
* `box-sizing: border-box`, `height: 0` still renders as padding plus border, and animating
* from 0 would spend the first part of the curve below that floor with nothing moving.
* `offsetHeight` rather than `scrollHeight` at the far end, because it includes the border.
*/
function unroll(panel, duration) {
const from = panel.offsetHeight;
panel.style.height = '';
const to = panel.offsetHeight;
const opening = panel.animate(
{ height: [`${from}px`, `${to}px`] },
{ duration, easing: OPEN_EASE, fill: 'backwards' },
);
void opening.finished
.catch(() => null)
.then(() => {
panel.style.overflow = '';
});
}
/** The outermost panel in a chain of nested ones. */
function outermost(panel) {
let found = panel;
let above = found.parentElement?.closest('[popover]');
while (above) {
found = above;
above = found.parentElement?.closest('[popover]');
}
return found;
}
/**
* A `[data-choose]` row was clicked: raise `popover-choose` and close the whole chain.
* Rows without the attribute, such as one that opens a submenu, do not close anything.
*/
function choose(panel, item) {
const detail = { item };
panel.dispatchEvent(new CustomEvent('popover-choose', { bubbles: true, detail }));
outermost(panel).hidePopover();
}
/** The panel's own `[data-rise]` rows, not those of a submenu nested inside it. */
function own(panel) {
return [...panel.querySelectorAll('[data-rise]')].filter((row) => {
const inner = row.closest('[popover]');
return inner === panel || !panel.contains(inner);
});
}
/**
* Fades the rows in from a little beyond their place, one after another. An upward panel
* takes its rows in reverse, so the row nearest the control always arrives first.
*/
function raise(panel, to, options) {
const { lead = RISE_LEAD, stagger = RISE_STAGGER } = options;
const rows = own(panel);
const cascade = to === 'up' ? rows.reverse() : rows;
const from = to === 'up' ? RISE_DISTANCE : -RISE_DISTANCE;
cascade.forEach((row, index) => {
const delay = lead + index * stagger;
row.animate(
{ opacity: [0, 1] },
{ duration: FADE_MS, delay, easing: 'ease-out', fill: 'backwards' },
);
row.animate(
{ transform: [`translateY(${from}px)`, 'translateY(0)'] },
{ duration: RISE_MS, delay, easing: RISE_EASE, fill: 'backwards' },
);
const label = options.reveal && row.querySelector(options.reveal);
if (label) revealText(label, { duration: REVEAL_MS, fadeShare: REVEAL_FADE, delay });
options.onRise?.(row, delay);
});
}
/** Wires every `[popovertarget]` under `root` to the panel it names. Called once. */
export function popovers(root = document, options = {}) {
const { gap = GAP, duration = OPEN_MS, direction = 'auto' } = options;
for (const anchor of root.querySelectorAll('[popovertarget]')) {
const panel = document.getElementById(anchor.getAttribute('popovertarget') ?? '');
if (!panel || wired.has(panel)) continue;
wired.add(panel);
// Placing and collapsing happen in `beforetoggle`, before the panel is painted.
panel.addEventListener('beforetoggle', (event) => {
if (event.newState !== 'open') return;
const to = direction === 'auto' ? roomFor(anchor, gap) : direction;
panel.dataset.direction = to; // read again in `toggle`, and by the stylesheet
place(anchor, panel, gap, to);
if (!prefersReducedMotion()) collapse(panel);
});
// Only this panel's rows: a click inside a nested panel bubbles through here too.
panel.addEventListener('click', (event) => {
const item = event.target.closest('[data-choose]');
if (item && item.closest('[popover]') === panel) choose(panel, item);
});
panel.addEventListener('toggle', (event) => {
const open = event.newState === 'open';
const to = panel.dataset.direction ?? 'down';
if (open) place(anchor, panel, gap, to); // again, now that it has a width
// The one attribute the stylesheet and an icon trigger can read the state from.
anchor.setAttribute('aria-expanded', String(open));
if (!open || prefersReducedMotion()) return;
unroll(panel, duration);
raise(panel, to, options);
});
}
}
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 |
|---|---|---|
bell |
system-outline-193-bell (edited by hand) | |
heart |
system-outline-20-heart (edited by hand) | |
download |
system-outline-199-download (edited by hand) | |
message |
system-outline-4198-message (edited by hand) | |
star |
system-outline-237-star (edited by hand) |