A lock that answers focus, an eye that follows the toggle, and a hint that arrives word by word.
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/password-field#standalone password-field
Copies the password-field folder of the
standalone branch — where every demo lives
on its own — into ./password-field.
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 field and the lock The lock watches data-focused on the field it sits in, through target.
<!-- target names the ancestor whose state the icon follows; the element
resolves it with closest(). Here it is .field. -->
<div class="field" aria-invalid="false" data-focused="false">
<label class="field__label" for="password">Password</label>
<div class="field__control">
<lord-icon
class="field__icon field__lock"
current-color
src="icons/lock.json"
loading="interaction"
trigger="follow(data-focused)"
target=".field"
aria-hidden="true"
>
<img alt="" src="icons/lock.svg" />
</lord-icon>
<input
class="field__input"
id="password"
type="password"
value="hunter2"
minlength="8"
required
aria-describedby="password-error"
/>
<!-- … -->
</div>
<!-- … -->
</div>
The reveal toggle and the eye aria-pressed on the button. state picks the morph.
<button
class="field__toggle"
type="button"
aria-label="Show password"
aria-pressed="false"
>
<!-- state picks the morph: first half to reveal, second to hide. -->
<lord-icon
class="field__icon"
current-color
src="icons/eye.json"
loading="interaction"
trigger="follow(aria-pressed)"
target=".field__toggle"
state="morph-close"
aria-hidden="true"
>
<img alt="" src="icons/eye.svg" />
</lord-icon>
</button>
The error message and its icon data-raised counts how many times the message has been shown.
<!-- data-raised counts how many times the message has been shown:
the icon draws itself in on the first, and nudges on a repeat. -->
<lord-icon
class="field__error-icon"
current-color
src="icons/warning-circle.json"
trigger="follow(data-raised)"
target=".field__error"
state="in-reveal"
aria-hidden="true"
></lord-icon>
Set the control state Nothing here mentions an icon.
toggle.addEventListener('click', () => {
const reveal = input.type === 'password';
input.type = reveal ? 'text' : 'password';
toggle.setAttribute('aria-pressed', String(reveal));
toggle.setAttribute('aria-label', reveal ? 'Hide password' : 'Show password');
});
// The lock plays when focus arrives, not on every keystroke.
input.addEventListener('focus', () => (field.dataset.focused = 'true'));
input.addEventListener('blur', () => (field.dataset.focused = 'false'));
form.addEventListener('submit', (event) => {
event.preventDefault();
const wrong = problem();
field.setAttribute('aria-invalid', String(wrong !== null));
if (wrong) showHint(wrong);
else void hideHint();
});
// A changed value invalidates the last answer; the next Sign in asks again.
input.addEventListener('input', () => {
field.setAttribute('aria-invalid', 'false');
void hideHint();
});
toggle.addEventListener('click', () => {
const reveal = input.type === 'password';
input.type = reveal ? 'text' : 'password';
toggle.setAttribute('aria-pressed', String(reveal));
toggle.setAttribute('aria-label', reveal ? 'Hide password' : 'Show password');
});
// The lock plays when focus arrives, not on every keystroke.
input.addEventListener('focus', () => (field.dataset.focused = 'true'));
input.addEventListener('blur', () => (field.dataset.focused = 'false'));
form.addEventListener('submit', (event) => {
event.preventDefault();
const wrong = problem();
field.setAttribute('aria-invalid', String(wrong !== null));
if (wrong) showHint(wrong);
else void hideHint();
});
// A changed value invalidates the last answer; the next Sign in asks again.
input.addEventListener('input', () => {
field.setAttribute('aria-invalid', 'false');
void hideHint();
});
Show the message Slides in; the words use the same wave the accordion uses.
/** Shows the message. It slides in from the right and its words arrive one by one. */
function showHint(message: string): void {
const raised = Number(hint.dataset.raised) || 0;
if (raised > 0) {
// Already up with the same message (a keystroke would have hidden it). Raise the
// count so the icon nudges, unless the arrival is still playing.
if (!arriving()) hint.dataset.raised = String(raised + 1);
return;
}
hint.dataset.raised = '1'; // the icon plays its entrance on this
stopHint();
hintText.textContent = message; // new words; the old spans go with the old text
hint.hidden = false;
if (prefersReducedMotion()) return;
hint.animate(
{ transform: [`translateX(${SLIDE_IN}px)`, 'translateX(0)'] },
{ duration: 300, easing: HINT_EASING },
);
revealText(hintText);
}
/** Shows the message. It slides in from the right and its words arrive one by one. */
function showHint(message) {
const raised = Number(hint.dataset.raised) || 0;
if (raised > 0) {
// Already up with the same message (a keystroke would have hidden it). Raise the
// count so the icon nudges, unless the arrival is still playing.
if (!arriving()) hint.dataset.raised = String(raised + 1);
return;
}
hint.dataset.raised = '1'; // the icon plays its entrance on this
stopHint();
hintText.textContent = message; // new words; the old spans go with the old text
hint.hidden = false;
if (prefersReducedMotion()) return;
hint.animate(
{ transform: [`translateX(${SLIDE_IN}px)`, 'translateX(0)'] },
{ duration: 300, easing: HINT_EASING },
);
revealText(hintText);
}
Hide the message A drop and a fade, then hidden. Safe to call on every keystroke.
/** Hides the message: a drop and a fade, then `hidden`. */
async function hideHint(): Promise<void> {
// Already hidden, or already leaving. Every keystroke calls this.
if (hint.dataset.raised === '0') return;
hint.dataset.raised = '0';
stopHint();
settleText(hintText); // a half-finished wave would fade out at half opacity
if (prefersReducedMotion()) {
hint.hidden = true;
return;
}
const out = hint.animate(
{ transform: ['translateY(0)', `translateY(${DROP_OUT}px)`], opacity: [1, 0] },
{ duration: 200, easing: HINT_EASING, fill: 'forwards' },
);
// A rejection means a new message cancelled this exit.
if (!(await out.finished.catch(() => null))) return;
hint.hidden = true;
out.cancel();
}
/** Hides the message: a drop and a fade, then `hidden`. */
async function hideHint() {
// Already hidden, or already leaving. Every keystroke calls this.
if (hint.dataset.raised === '0') return;
hint.dataset.raised = '0';
stopHint();
settleText(hintText); // a half-finished wave would fade out at half opacity
if (prefersReducedMotion()) {
hint.hidden = true;
return;
}
const out = hint.animate(
{ transform: ['translateY(0)', `translateY(${DROP_OUT}px)`], opacity: [1, 0] },
{ duration: 200, easing: HINT_EASING, fill: 'forwards' },
);
// A rejection means a new message cancelled this exit.
if (!(await out.finished.catch(() => null))) return;
hint.hidden = true;
out.cancel();
}
Every file
The demo's own files, whole, at the paths the export uses.
index.html 83 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Password field</title>
<link rel="stylesheet" href="./password-field.css" />
<script type="module" src="./main.ts"></script>
</head>
<body>
<main class="demo">
<form class="demo__form" novalidate>
<!-- target names the ancestor whose state the icon follows; the element
resolves it with closest(). Here it is .field. -->
<div class="field" aria-invalid="false" data-focused="false">
<label class="field__label" for="password">Password</label>
<div class="field__control">
<lord-icon
class="field__icon field__lock"
current-color
src="icons/lock.json"
loading="interaction"
trigger="follow(data-focused)"
target=".field"
aria-hidden="true"
>
<img alt="" src="icons/lock.svg" />
</lord-icon>
<input
class="field__input"
id="password"
type="password"
value="hunter2"
minlength="8"
required
aria-describedby="password-error"
/>
<button
class="field__toggle"
type="button"
aria-label="Show password"
aria-pressed="false"
>
<!-- state picks the morph: first half to reveal, second to hide. -->
<lord-icon
class="field__icon"
current-color
src="icons/eye.json"
loading="interaction"
trigger="follow(aria-pressed)"
target=".field__toggle"
state="morph-close"
aria-hidden="true"
>
<img alt="" src="icons/eye.svg" />
</lord-icon>
</button>
</div>
<p class="field__error" id="password-error" data-raised="0" hidden>
<!-- data-raised counts how many times the message has been shown:
the icon draws itself in on the first, and nudges on a repeat. -->
<lord-icon
class="field__error-icon"
current-color
src="icons/warning-circle.json"
trigger="follow(data-raised)"
target=".field__error"
state="in-reveal"
aria-hidden="true"
></lord-icon>
<span class="field__error-text">Must be at least 8 characters</span>
</p>
</div>
<button class="demo__submit" type="submit">Sign in</button>
</form>
</main>
</body>
</html>
main.ts 119 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 { revealText, settleText } from '@shared/motion/text-reveal.ts';
const field = document.querySelector<HTMLElement>('.field')!;
const input = document.querySelector<HTMLInputElement>('.field__input')!;
const toggle = document.querySelector<HTMLButtonElement>('.field__toggle')!;
const form = document.querySelector<HTMLFormElement>('.demo__form')!;
const hint = document.querySelector<HTMLElement>('.field__error')!;
const hintText = document.querySelector<HTMLElement>('.field__error-text')!;
/** The one password this demo accepts. */
const CORRECT = 'password';
/** Read from the markup, which owns the rule. */
const MIN = Number(input.getAttribute('minlength'));
/** What is wrong with the value, or null. */
function problem(): string | null {
// Measured here rather than with checkValidity(): `tooShort` only reports a value the
// user has edited, and this field arrives pre-filled.
if (input.value.length < MIN) return `Must be at least ${MIN} characters`;
if (input.value !== CORRECT) return 'That password is not the one we have on file';
return null;
}
const SLIDE_IN = 12;
const DROP_OUT = 8;
const HINT_EASING = 'cubic-bezier(0.3, 0, 0.2, 1)';
/** Cancels the hint's own slide. The word wave inside it is separate. */
function stopHint(): void {
for (const animation of hint.getAnimations()) animation.cancel();
}
/** True while the hint's slide or its word wave is still running. */
function arriving(): boolean {
return hint.getAnimations().length > 0 || hintText.getAnimations({ subtree: true }).length > 0;
}
/** Shows the message. It slides in from the right and its words arrive one by one. */
function showHint(message: string): void {
const raised = Number(hint.dataset.raised) || 0;
if (raised > 0) {
// Already up with the same message (a keystroke would have hidden it). Raise the
// count so the icon nudges, unless the arrival is still playing.
if (!arriving()) hint.dataset.raised = String(raised + 1);
return;
}
hint.dataset.raised = '1'; // the icon plays its entrance on this
stopHint();
hintText.textContent = message; // new words; the old spans go with the old text
hint.hidden = false;
if (prefersReducedMotion()) return;
hint.animate(
{ transform: [`translateX(${SLIDE_IN}px)`, 'translateX(0)'] },
{ duration: 300, easing: HINT_EASING },
);
revealText(hintText);
}
/** Hides the message: a drop and a fade, then `hidden`. */
async function hideHint(): Promise<void> {
// Already hidden, or already leaving. Every keystroke calls this.
if (hint.dataset.raised === '0') return;
hint.dataset.raised = '0';
stopHint();
settleText(hintText); // a half-finished wave would fade out at half opacity
if (prefersReducedMotion()) {
hint.hidden = true;
return;
}
const out = hint.animate(
{ transform: ['translateY(0)', `translateY(${DROP_OUT}px)`], opacity: [1, 0] },
{ duration: 200, easing: HINT_EASING, fill: 'forwards' },
);
// A rejection means a new message cancelled this exit.
if (!(await out.finished.catch(() => null))) return;
hint.hidden = true;
out.cancel();
}
toggle.addEventListener('click', () => {
const reveal = input.type === 'password';
input.type = reveal ? 'text' : 'password';
toggle.setAttribute('aria-pressed', String(reveal));
toggle.setAttribute('aria-label', reveal ? 'Hide password' : 'Show password');
});
// The lock plays when focus arrives, not on every keystroke.
input.addEventListener('focus', () => (field.dataset.focused = 'true'));
input.addEventListener('blur', () => (field.dataset.focused = 'false'));
form.addEventListener('submit', (event) => {
event.preventDefault();
const wrong = problem();
field.setAttribute('aria-invalid', String(wrong !== null));
if (wrong) showHint(wrong);
else void hideHint();
});
// A changed value invalidates the last answer; the next Sign in asks again.
input.addEventListener('input', () => {
field.setAttribute('aria-invalid', 'false');
void hideHint();
});
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 { revealText, settleText } from '@shared/motion/text-reveal.js';
const field = document.querySelector('.field');
const input = document.querySelector('.field__input');
const toggle = document.querySelector('.field__toggle');
const form = document.querySelector('.demo__form');
const hint = document.querySelector('.field__error');
const hintText = document.querySelector('.field__error-text');
/** The one password this demo accepts. */
const CORRECT = 'password';
/** Read from the markup, which owns the rule. */
const MIN = Number(input.getAttribute('minlength'));
/** What is wrong with the value, or null. */
function problem() {
// Measured here rather than with checkValidity(): `tooShort` only reports a value the
// user has edited, and this field arrives pre-filled.
if (input.value.length < MIN) return `Must be at least ${MIN} characters`;
if (input.value !== CORRECT) return 'That password is not the one we have on file';
return null;
}
const SLIDE_IN = 12;
const DROP_OUT = 8;
const HINT_EASING = 'cubic-bezier(0.3, 0, 0.2, 1)';
/** Cancels the hint's own slide. The word wave inside it is separate. */
function stopHint() {
for (const animation of hint.getAnimations()) animation.cancel();
}
/** True while the hint's slide or its word wave is still running. */
function arriving() {
return hint.getAnimations().length > 0 || hintText.getAnimations({ subtree: true }).length > 0;
}
/** Shows the message. It slides in from the right and its words arrive one by one. */
function showHint(message) {
const raised = Number(hint.dataset.raised) || 0;
if (raised > 0) {
// Already up with the same message (a keystroke would have hidden it). Raise the
// count so the icon nudges, unless the arrival is still playing.
if (!arriving()) hint.dataset.raised = String(raised + 1);
return;
}
hint.dataset.raised = '1'; // the icon plays its entrance on this
stopHint();
hintText.textContent = message; // new words; the old spans go with the old text
hint.hidden = false;
if (prefersReducedMotion()) return;
hint.animate(
{ transform: [`translateX(${SLIDE_IN}px)`, 'translateX(0)'] },
{ duration: 300, easing: HINT_EASING },
);
revealText(hintText);
}
/** Hides the message: a drop and a fade, then `hidden`. */
async function hideHint() {
// Already hidden, or already leaving. Every keystroke calls this.
if (hint.dataset.raised === '0') return;
hint.dataset.raised = '0';
stopHint();
settleText(hintText); // a half-finished wave would fade out at half opacity
if (prefersReducedMotion()) {
hint.hidden = true;
return;
}
const out = hint.animate(
{ transform: ['translateY(0)', `translateY(${DROP_OUT}px)`], opacity: [1, 0] },
{ duration: 200, easing: HINT_EASING, fill: 'forwards' },
);
// A rejection means a new message cancelled this exit.
if (!(await out.finished.catch(() => null))) return;
hint.hidden = true;
out.cancel();
}
toggle.addEventListener('click', () => {
const reveal = input.type === 'password';
input.type = reveal ? 'text' : 'password';
toggle.setAttribute('aria-pressed', String(reveal));
toggle.setAttribute('aria-label', reveal ? 'Hide password' : 'Show password');
});
// The lock plays when focus arrives, not on every keystroke.
input.addEventListener('focus', () => (field.dataset.focused = 'true'));
input.addEventListener('blur', () => (field.dataset.focused = 'false'));
form.addEventListener('submit', (event) => {
event.preventDefault();
const wrong = problem();
field.setAttribute('aria-invalid', String(wrong !== null));
if (wrong) showHint(wrong);
else void hideHint();
});
// A changed value invalidates the last answer; the next Sign in asks again.
input.addEventListener('input', () => {
field.setAttribute('aria-invalid', 'false');
void hideHint();
});
password-field.css 136 lines
@import '@shared/styles/index.css';
.demo {
display: grid;
align-content: start;
justify-items: center;
min-height: 320px;
padding: var(--space-64) var(--gutter);
}
.demo__form {
display: grid;
gap: var(--space-24);
width: 100%;
max-width: 440px;
}
.demo__submit {
justify-self: start;
padding: var(--space-12) var(--space-24);
border: 0;
border-radius: var(--radius-md);
background: var(--accent);
color: var(--ink-inverse);
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
}
.demo__submit:hover {
background: var(--accent-strong);
}
.field {
display: grid;
gap: var(--space-8);
}
.field__label {
color: var(--ink-muted);
font-size: var(--text-sm);
font-weight: 600;
line-height: var(--leading-sm);
}
/* 8px of padding around a 24px row, plus the border: the design's 42px. */
.field__control {
display: flex;
align-items: center;
gap: var(--space-8);
padding: var(--space-8);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
transition:
border-color 150ms ease,
box-shadow 150ms ease;
}
/* Hover and focus share one look. */
.field__control:hover,
.field__control:focus-within {
border-color: var(--accent-border);
box-shadow: 0 0 7.5px color-mix(in srgb, var(--accent-strong) 40%, transparent);
}
/* Invalid outranks both and has no glow. */
.field[aria-invalid='true'] .field__control {
border-color: var(--danger);
box-shadow: none;
}
/* No UA padding, so the field stays 42px tall. */
.field__input {
flex: 1;
min-width: 0;
padding: 0;
border: 0;
background: none;
outline: none;
}
.field__input::placeholder {
color: var(--ink-subtle);
}
/* The icons inherit `color` through current-color, so a colour transition works on them. */
.field__icon {
width: var(--icon-size-24);
height: var(--icon-size-24);
color: var(--ink-muted);
transition: color 150ms ease;
--icon-filter: var(--filter-ink-muted);
}
/* The lock stays grey when the field is invalid; the border and the message carry the colour. */
.field__lock {
color: var(--ink-subtle);
--icon-filter: var(--filter-ink-subtle);
}
.field__toggle .field__icon {
width: var(--icon-size-20);
height: var(--icon-size-20);
}
.field__toggle {
display: grid;
padding: 0;
border: 0;
border-radius: var(--radius-sm);
background: none;
cursor: pointer;
place-items: center;
}
.field__toggle:hover .field__icon {
color: var(--ink);
}
.field__error-icon {
width: var(--icon-size-20);
height: var(--icon-size-20);
color: var(--accent);
}
.field__error {
display: flex;
align-items: center;
gap: var(--space-8);
margin: 0;
color: var(--danger);
font-size: var(--text-sm);
font-weight: 500;
line-height: var(--leading-base);
}
What it imports 6 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);
}
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 |
|---|---|---|
lock |
system-outline-94-lock | |
eye |
system-outline-69-eye | |
warning-circle |
system-outline-260-warning-circle |