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/rating#standalone rating
Copies the rating folder of the
standalone branch — where every demo lives
on its own — into ./rating.
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 The built-in triggers, plus one of our own for the stars.
import { defineElement } from '@lordicon/element';
import { PreviewMorph } from '@shared/triggers/preview-morph.ts';
// The built-in triggers, plus one of our own for the stars.
defineElement({ triggers: { 'preview-morph': PreviewMorph } });
import { defineElement } from '@lordicon/element';
import { PreviewMorph } from '@shared/triggers/preview-morph.js';
// The built-in triggers, plus one of our own for the stars.
defineElement({ triggers: { 'preview-morph': PreviewMorph } });
One star A real radio underneath. data-chosen is the rating, data-lit the preview.
<!-- One star. data-chosen is the rating, data-lit the preview. The icon
morphs on the first and nudges on the second; the stylesheet colours
on the second. The radio is the real control, only invisible. -->
<label class="star" data-chosen="true" data-lit="true">
<input
class="star__input visually-hidden"
type="radio"
name="rating"
value="1"
/>
<lord-icon
class="star__mark"
current-color
src="icons/star.json"
trigger="preview-morph(data-chosen, preview=data-lit)"
target=".star"
state="morph-select"
aria-hidden="true"
>
<img alt="" src="icons/star.morph-select.svg" />
</lord-icon>
<span class="visually-hidden">1 star</span>
</label>
Pointer and keyboard set the state Hover previews, change chooses. Nothing here mentions an icon.
for (const [at, star] of stars.entries()) {
star.addEventListener('pointerenter', () => {
hovered = at + 1;
preview();
});
}
group.addEventListener('pointerleave', () => {
hovered = 0;
preview();
});
// `change` fires for a click and for an arrow key alike.
group.addEventListener('change', () => {
const picked = inputs.findIndex((input) => input.checked);
if (picked >= 0) choose(picked + 1);
});
for (const [at, star] of stars.entries()) {
star.addEventListener('pointerenter', () => {
hovered = at + 1;
preview();
});
}
group.addEventListener('pointerleave', () => {
hovered = 0;
preview();
});
// `change` fires for a click and for an arrow key alike.
group.addEventListener('change', () => {
const picked = inputs.findIndex((input) => input.checked);
if (picked >= 0) choose(picked + 1);
});
Light the stars up to the pointer Lighting up runs; going dark happens at once.
/**
* Colours the stars up to the pointer, or up to the rating once the pointer has gone.
* Lighting up runs one star at a time; going dark happens at once.
*/
function preview(): void {
const count = hovered || rating;
for (const timer of lighting.splice(0)) clearTimeout(timer);
const arriving: number[] = [];
stars.forEach((star, at) => {
const lit = at < count;
if (lit === (star.dataset.lit === 'true')) return;
if (lit) arriving.push(at);
else star.dataset.lit = 'false';
});
spread(lighting, arriving, (at) => (stars[at].dataset.lit = 'true'));
}
/**
* Colours the stars up to the pointer, or up to the rating once the pointer has gone.
* Lighting up runs one star at a time; going dark happens at once.
*/
function preview() {
const count = hovered || rating;
for (const timer of lighting.splice(0)) clearTimeout(timer);
const arriving = [];
stars.forEach((star, at) => {
const lit = at < count;
if (lit === (star.dataset.lit === 'true')) return;
if (lit) arriving.push(at);
else star.dataset.lit = 'false';
});
spread(lighting, arriving, (at) => (stars[at].dataset.lit = 'true'));
}
Fill the stars up to the rating Only the stars that change are in the run.
/** Sets the rating. The stars that change shape do so one at a time, from the top down. */
function choose(count: number): void {
rating = count;
preview();
for (const timer of filling.splice(0)) clearTimeout(timer);
// A star changes when it should be filled but is not, or the other way round.
const changing = [...stars.keys()].filter(
(at) => at < count !== (stars[at].dataset.chosen === 'true'),
);
spread(filling, changing, (at) => {
stars[at].dataset.chosen = String(at < count);
});
}
/** Sets the rating. The stars that change shape do so one at a time, from the top down. */
function choose(count) {
rating = count;
preview();
for (const timer of filling.splice(0)) clearTimeout(timer);
// A star changes when it should be filled but is not, or the other way round.
const changing = [...stars.keys()].filter(
(at) => at < count !== (stars[at].dataset.chosen === 'true'),
);
spread(filling, changing, (at) => {
stars[at].dataset.chosen = String(at < count);
});
}
One star every 80ms The stagger lives here, in the state changes, not in the icons.
/**
* Applies `change` to the stars in `which`, one every STEP, highest index first. Lighting
* up, the star under the pointer changes first and the run travels back towards one.
* Emptying, the stars come off the end.
*/
function spread(
timers: ReturnType<typeof setTimeout>[],
which: number[],
change: (at: number) => void,
): void {
[...which]
.sort((a, b) => b - a)
.forEach((at, step) => timers.push(setTimeout(() => change(at), step * STEP)));
}
/**
* Applies `change` to the stars in `which`, one every STEP, highest index first. Lighting
* up, the star under the pointer changes first and the run travels back towards one.
* Emptying, the stars come off the end.
*/
function spread(timers, which, change) {
[...which]
.sort((a, b) => b - a)
.forEach((at, step) => timers.push(setTimeout(() => change(at), step * STEP)));
}
Click the top star to clear A radio cannot be unchecked by clicking it, so the demo does it a tick later.
// A radio cannot be unchecked by clicking it, so clicking the star that is the whole
// rating clears it. Deferred with setTimeout: the label re-checks its radio after this
// listener runs, so unchecking it here would be undone at once.
group.addEventListener('click', (event) => {
const at = stars.indexOf((event.target as Element).closest('.star')!);
if (at < 0 || rating !== at + 1) return;
setTimeout(() => {
inputs[at].checked = false;
choose(0);
});
});
// A radio cannot be unchecked by clicking it, so clicking the star that is the whole
// rating clears it. Deferred with setTimeout: the label re-checks its radio after this
// listener runs, so unchecking it here would be undone at once.
group.addEventListener('click', (event) => {
const at = stars.indexOf(event.target.closest('.star'));
if (at < 0 || rating !== at + 1) return;
setTimeout(() => {
inputs[at].checked = false;
choose(0);
});
});
Every file
The demo's own files, whole, at the paths the export uses.
index.html 136 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Rating</title>
<link rel="stylesheet" href="./rating.css" />
<script type="module" src="./main.ts"></script>
</head>
<body>
<main class="demo">
<fieldset class="rating">
<legend class="visually-hidden">Rate this</legend>
<!-- One star. data-chosen is the rating, data-lit the preview. The icon
morphs on the first and nudges on the second; the stylesheet colours
on the second. The radio is the real control, only invisible. -->
<label class="star" data-chosen="true" data-lit="true">
<input
class="star__input visually-hidden"
type="radio"
name="rating"
value="1"
/>
<lord-icon
class="star__mark"
current-color
src="icons/star.json"
trigger="preview-morph(data-chosen, preview=data-lit)"
target=".star"
state="morph-select"
aria-hidden="true"
>
<img alt="" src="icons/star.morph-select.svg" />
</lord-icon>
<span class="visually-hidden">1 star</span>
</label>
<label class="star" data-chosen="true" data-lit="true">
<input
class="star__input visually-hidden"
type="radio"
name="rating"
value="2"
checked
/>
<lord-icon
class="star__mark"
current-color
src="icons/star.json"
trigger="preview-morph(data-chosen, preview=data-lit)"
target=".star"
state="morph-select"
aria-hidden="true"
>
<img alt="" src="icons/star.morph-select.svg" />
</lord-icon>
<span class="visually-hidden">2 stars</span>
</label>
<label class="star" data-chosen="false" data-lit="false">
<input
class="star__input visually-hidden"
type="radio"
name="rating"
value="3"
/>
<lord-icon
class="star__mark"
current-color
src="icons/star.json"
trigger="preview-morph(data-chosen, preview=data-lit)"
target=".star"
state="morph-select"
aria-hidden="true"
>
<img alt="" src="icons/star.svg" />
</lord-icon>
<span class="visually-hidden">3 stars</span>
</label>
<label class="star" data-chosen="false" data-lit="false">
<input
class="star__input visually-hidden"
type="radio"
name="rating"
value="4"
/>
<lord-icon
class="star__mark"
current-color
src="icons/star.json"
trigger="preview-morph(data-chosen, preview=data-lit)"
target=".star"
state="morph-select"
aria-hidden="true"
>
<img alt="" src="icons/star.svg" />
</lord-icon>
<span class="visually-hidden">4 stars</span>
</label>
<label class="star" data-chosen="false" data-lit="false">
<input
class="star__input visually-hidden"
type="radio"
name="rating"
value="5"
/>
<lord-icon
class="star__mark"
current-color
src="icons/star.json"
trigger="preview-morph(data-chosen, preview=data-lit)"
target=".star"
state="morph-select"
aria-hidden="true"
>
<img alt="" src="icons/star.svg" />
</lord-icon>
<span class="visually-hidden">5 stars</span>
</label>
</fieldset>
</main>
</body>
</html>
main.ts 104 lines
import { defineElement } from '@lordicon/element';
import { PreviewMorph } from '@shared/triggers/preview-morph.ts';
// The built-in triggers, plus one of our own for the stars.
defineElement({ triggers: { 'preview-morph': PreviewMorph } });
const group = document.querySelector<HTMLFieldSetElement>('.rating')!;
const stars = [...group.querySelectorAll<HTMLElement>('.star')];
const inputs = stars.map((star) => star.querySelector<HTMLInputElement>('.star__input')!);
/** Delay between two stars in the same run. From the reference recording: five frames at 60fps. */
const STEP = 80;
let rating = inputs.findIndex((input) => input.checked) + 1;
/** The star under the pointer, counting from one. Zero when the pointer is elsewhere. */
let hovered = 0;
// Two runs with separate timers. A pointer move cancels the lighting, a new rating cancels
// the filling; sharing one list would let a pointer move cut a fill short.
const lighting: ReturnType<typeof setTimeout>[] = [];
const filling: ReturnType<typeof setTimeout>[] = [];
/**
* Applies `change` to the stars in `which`, one every STEP, highest index first. Lighting
* up, the star under the pointer changes first and the run travels back towards one.
* Emptying, the stars come off the end.
*/
function spread(
timers: ReturnType<typeof setTimeout>[],
which: number[],
change: (at: number) => void,
): void {
[...which]
.sort((a, b) => b - a)
.forEach((at, step) => timers.push(setTimeout(() => change(at), step * STEP)));
}
/**
* Colours the stars up to the pointer, or up to the rating once the pointer has gone.
* Lighting up runs one star at a time; going dark happens at once.
*/
function preview(): void {
const count = hovered || rating;
for (const timer of lighting.splice(0)) clearTimeout(timer);
const arriving: number[] = [];
stars.forEach((star, at) => {
const lit = at < count;
if (lit === (star.dataset.lit === 'true')) return;
if (lit) arriving.push(at);
else star.dataset.lit = 'false';
});
spread(lighting, arriving, (at) => (stars[at].dataset.lit = 'true'));
}
/** Sets the rating. The stars that change shape do so one at a time, from the top down. */
function choose(count: number): void {
rating = count;
preview();
for (const timer of filling.splice(0)) clearTimeout(timer);
// A star changes when it should be filled but is not, or the other way round.
const changing = [...stars.keys()].filter(
(at) => at < count !== (stars[at].dataset.chosen === 'true'),
);
spread(filling, changing, (at) => {
stars[at].dataset.chosen = String(at < count);
});
}
for (const [at, star] of stars.entries()) {
star.addEventListener('pointerenter', () => {
hovered = at + 1;
preview();
});
}
group.addEventListener('pointerleave', () => {
hovered = 0;
preview();
});
// `change` fires for a click and for an arrow key alike.
group.addEventListener('change', () => {
const picked = inputs.findIndex((input) => input.checked);
if (picked >= 0) choose(picked + 1);
});
// A radio cannot be unchecked by clicking it, so clicking the star that is the whole
// rating clears it. Deferred with setTimeout: the label re-checks its radio after this
// listener runs, so unchecking it here would be undone at once.
group.addEventListener('click', (event) => {
const at = stars.indexOf((event.target as Element).closest('.star')!);
if (at < 0 || rating !== at + 1) return;
setTimeout(() => {
inputs[at].checked = false;
choose(0);
});
});
import { defineElement } from '@lordicon/element';
import { PreviewMorph } from '@shared/triggers/preview-morph.js';
// The built-in triggers, plus one of our own for the stars.
defineElement({ triggers: { 'preview-morph': PreviewMorph } });
const group = document.querySelector('.rating');
const stars = [...group.querySelectorAll('.star')];
const inputs = stars.map((star) => star.querySelector('.star__input'));
/** Delay between two stars in the same run. From the reference recording: five frames at 60fps. */
const STEP = 80;
let rating = inputs.findIndex((input) => input.checked) + 1;
/** The star under the pointer, counting from one. Zero when the pointer is elsewhere. */
let hovered = 0;
// Two runs with separate timers. A pointer move cancels the lighting, a new rating cancels
// the filling; sharing one list would let a pointer move cut a fill short.
const lighting = [];
const filling = [];
/**
* Applies `change` to the stars in `which`, one every STEP, highest index first. Lighting
* up, the star under the pointer changes first and the run travels back towards one.
* Emptying, the stars come off the end.
*/
function spread(timers, which, change) {
[...which]
.sort((a, b) => b - a)
.forEach((at, step) => timers.push(setTimeout(() => change(at), step * STEP)));
}
/**
* Colours the stars up to the pointer, or up to the rating once the pointer has gone.
* Lighting up runs one star at a time; going dark happens at once.
*/
function preview() {
const count = hovered || rating;
for (const timer of lighting.splice(0)) clearTimeout(timer);
const arriving = [];
stars.forEach((star, at) => {
const lit = at < count;
if (lit === (star.dataset.lit === 'true')) return;
if (lit) arriving.push(at);
else star.dataset.lit = 'false';
});
spread(lighting, arriving, (at) => (stars[at].dataset.lit = 'true'));
}
/** Sets the rating. The stars that change shape do so one at a time, from the top down. */
function choose(count) {
rating = count;
preview();
for (const timer of filling.splice(0)) clearTimeout(timer);
// A star changes when it should be filled but is not, or the other way round.
const changing = [...stars.keys()].filter(
(at) => at < count !== (stars[at].dataset.chosen === 'true'),
);
spread(filling, changing, (at) => {
stars[at].dataset.chosen = String(at < count);
});
}
for (const [at, star] of stars.entries()) {
star.addEventListener('pointerenter', () => {
hovered = at + 1;
preview();
});
}
group.addEventListener('pointerleave', () => {
hovered = 0;
preview();
});
// `change` fires for a click and for an arrow key alike.
group.addEventListener('change', () => {
const picked = inputs.findIndex((input) => input.checked);
if (picked >= 0) choose(picked + 1);
});
// A radio cannot be unchecked by clicking it, so clicking the star that is the whole
// rating clears it. Deferred with setTimeout: the label re-checks its radio after this
// listener runs, so unchecking it here would be undone at once.
group.addEventListener('click', (event) => {
const at = stars.indexOf(event.target.closest('.star'));
if (at < 0 || rating !== at + 1) return;
setTimeout(() => {
inputs[at].checked = false;
choose(0);
});
});
rating.css 50 lines
@import '@shared/styles/index.css';
.demo {
display: grid;
place-items: center;
min-height: 100dvh;
padding: var(--space-64) var(--gutter);
}
.rating {
display: flex;
align-items: center;
gap: var(--space-8);
margin: 0;
padding: var(--space-8) var(--space-12);
border: 1px solid var(--border);
border-radius: var(--radius-md);
}
/* No text selection: people sweep back and forth across the row. */
.star {
display: flex;
cursor: pointer;
-webkit-user-select: none;
user-select: none;
}
/* Coloured when lit. No transition: the stagger comes from the state changes. */
.star {
--star-mark: var(--ink-subtle);
--icon-filter: var(--filter-ink-subtle);
}
.star[data-lit='true'] {
--star-mark: var(--accent);
--icon-filter: var(--filter-accent);
}
.star__mark {
width: var(--icon-size-24);
height: var(--icon-size-24);
color: var(--star-mark);
}
/* The radio is invisible, so its focus ring has to land on the star instead. */
.star__input:focus-visible ~ .star__mark {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
What it imports 5 shared files
shared/styles/base.css 115 lines
/*
* Fonts, reset and default typography. Nothing control-specific: every demo styles its
* own control. Only the UI face is here; the mono face is the portal's.
*/
@font-face {
font-family: 'Figtree';
font-style: normal;
font-weight: 300 900;
font-display: swap;
src: url('../fonts/figtree-latin.woff2') format('woff2');
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308,
U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Figtree';
font-style: normal;
font-weight: 300 900;
font-display: swap;
src: url('../fonts/figtree-latin-ext.woff2') format('woff2');
unicode-range:
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329,
U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113,
U+2C60-2C7F, U+A720-A7FF;
}
/* Arial with Figtree's measurements, for the moment before Figtree arrives, so the text does
not move when it does. Measured on Figtree at 400 and 500. */
@font-face {
font-family: 'Figtree fallback';
src: local('Arial'), local('Liberation Sans');
size-adjust: 100.3%;
ascent-override: 94.7%;
descent-override: 24.9%;
line-gap-override: 0%;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
/* An author `display` on a component would beat the UA rule for [hidden], and then
`el.hidden = true` would stop working. Settled once here. */
[hidden] {
display: none !important;
}
body {
margin: 0;
background: var(--surface);
color: var(--ink);
--icon-filter: var(--filter-ink);
font-family: var(--font-ui);
font-size: var(--text-base);
line-height: var(--leading-base);
-webkit-font-smoothing: antialiased;
}
button,
input,
textarea,
select {
font: inherit;
color: inherit;
}
/* A second click on a button would otherwise select its label. */
button {
-webkit-user-select: none;
user-select: none;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/*
* In the accessibility tree but not on screen: labels and legends that the visual design
* has no room for. Clipped rather than `display: none`, which would remove it from the
* tree too, and it also hides live radio inputs that must stay reachable by keyboard.
*/
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/*
* Until the script defines <lord-icon> it is an unknown inline element with no size. These
* rules give it the box it will have, so nothing moves when it is defined.
*/
lord-icon {
display: inline-block;
width: var(--icon-size-32);
height: var(--icon-size-32);
}
/* A placeholder inside the icon fills it until the icon is ready. */
lord-icon:not(:defined) > img {
display: block;
width: 100%;
height: 100%;
}
/* Placeholders are black. Set --icon-filter wherever an icon's colour is set. */
lord-icon > img {
filter: var(--icon-filter);
}
shared/styles/index.css 4 lines
/* The one stylesheet a demo imports. */
@import './palette.css';
@import './tokens.css';
@import './base.css';
shared/styles/palette.css 87 lines
/*
* Generated from the Figma token export.
* Raw values only — roles live in tokens.css. Edit the Figma variables, not this file.
*/
:root {
/* color/ink */
--gray-0: #FFFFFF;
--gray-50: #F7F8FA;
--gray-100: #EEF0F3;
--gray-200: #DEE1E6;
--gray-300: #C3C7D0;
--gray-400: #9BA1AD;
--gray-500: #767C89;
--gray-600: #565C68;
--gray-700: #3D414B;
--gray-800: #282B33;
--gray-900: #16181D;
--gray-950: #0A0B0E;
/* color/signal */
--brand-100: #FFEEE9;
--brand-200: #FFD7CA;
--brand-300: #FFB199;
--brand-400: #FF7D5C;
--brand-500: #FF5A36;
--brand-600: #E8431F;
--brand-700: #C2340F;
/* color/success */
--green-100: #EDF9F1;
--green-300: #A7D9BC;
--green-700: #15803D;
/* color/error */
--red-100: #FDECEC;
--red-300: #F5B5B5;
--red-700: #DC2626;
/* typography/font */
--font-ui-name: 'Figtree';
--font-mono-name: 'JetBrains Mono';
/* typography/size */
--text-xs: 12px;
--text-sm: 14px;
--text-base: 16px;
--text-lg: 20px;
--text-xl: 24px;
--text-2xl: 32px;
--text-3xl: 40px;
/* typography/lineHeight */
--leading-xs: 16px;
--leading-sm: 20px;
--leading-base: 24px;
--leading-lg: 28px;
--leading-xl: 32px;
--leading-2xl: 40px;
--leading-3xl: 48px;
/* spacing (named after the value, the Figma scale is non-linear) */
--space-4: 4px;
--space-8: 8px;
--space-12: 12px;
--space-16: 16px;
--space-24: 24px;
--space-32: 32px;
--space-48: 48px;
--space-64: 64px;
--space-96: 96px;
/* size/icon */
--icon-size-16: 16px;
--icon-size-20: 20px;
--icon-size-24: 24px;
--icon-size-32: 32px;
--icon-size-40: 40px;
--icon-size-48: 48px;
--icon-size-64: 64px;
--icon-size-96: 96px;
/* radius */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-pill: 9999px;
}
shared/styles/tokens.css 93 lines
/*
* Design tokens by role. Raw values come from palette.css (generated); which grey is a
* border and which is muted text is decided here, and so would a dark mode be.
*/
:root {
/* Text. `--ink*` is always a colour; `--text-*` is always a size. */
--ink: var(--gray-950);
/* Text that reads as done or in progress, a step lighter than a heading. */
--ink-strong: var(--gray-800);
/* Body copy: a step down from --ink. */
--ink-soft: var(--gray-600);
--ink-muted: var(--gray-500);
--ink-subtle: var(--gray-400);
/* What has not started yet. */
--ink-faint: var(--gray-300);
--ink-inverse: var(--gray-0);
/* Backgrounds */
--surface: var(--gray-0);
--surface-muted: var(--gray-50);
/* A well inside a surface: the filled shape a single icon sits in. */
--surface-sunken: var(--gray-100);
/* Outlines */
--border: var(--gray-200);
/* The edge of a box that already has a fill, such as an input. */
--border-soft: var(--gray-100);
--border-strong: var(--gray-300);
--border-accent: var(--brand-400);
/* Brand accent */
--accent: var(--brand-500);
--accent-strong: var(--brand-600);
--accent-surface: var(--brand-100);
/* The outline of a tinted box, or a small shape on one. */
--accent-border: var(--brand-300);
/* Text or a mark on either of the above. */
--accent-ink: var(--brand-700);
/* Status. Warning rides the brand ramp — that is what the designs use. */
--success: var(--green-700);
--success-surface: var(--green-100);
--success-border: var(--green-300);
--warning: var(--brand-500);
--warning-surface: var(--brand-100);
--warning-border: var(--brand-300);
--danger: var(--red-700);
--danger-surface: var(--red-100);
--danger-border: var(--red-300);
/* Icons. Fed straight into --lord-icon-* by the demos. */
--icon-color: var(--gray-950);
--icon-accent: var(--brand-500);
/* Icon placeholders are black SVGs. Each filter turns black into the colour of the same
name. Made with a CSS filter generator: make a new one when its colour changes. */
--filter-ink: invert(2%) sepia(4%) saturate(4817%) hue-rotate(193deg) brightness(114%)
contrast(96%);
--filter-ink-soft: invert(33%) sepia(18%) saturate(323%) hue-rotate(182deg) brightness(100%)
contrast(90%);
--filter-ink-muted: invert(55%) sepia(23%) saturate(191%) hue-rotate(181deg) brightness(84%)
contrast(89%);
--filter-ink-subtle: invert(70%) sepia(11%) saturate(284%) hue-rotate(181deg) brightness(91%)
contrast(86%);
--filter-ink-faint: invert(76%) sepia(17%) saturate(99%) hue-rotate(184deg) brightness(99%)
contrast(100%);
--filter-ink-inverse: invert(100%);
--filter-accent: invert(49%) sepia(65%) saturate(3508%) hue-rotate(337deg) brightness(106%)
contrast(113%);
--filter-accent-strong: invert(29%) sepia(85%) saturate(1790%) hue-rotate(350deg)
brightness(94%) contrast(93%);
--filter-success: invert(38%) sepia(22%) saturate(2045%) hue-rotate(94deg) brightness(92%)
contrast(84%);
--filter-warning: var(--filter-accent);
--filter-danger: invert(48%) sepia(74%) saturate(7139%) hue-rotate(343deg) brightness(84%)
contrast(105%);
/* Type stacks. The families themselves are in palette.css. */
--font-ui:
var(--font-ui-name), 'Figtree fallback', -apple-system, BlinkMacSystemFont, 'Segoe UI',
Roboto, sans-serif;
--font-mono: var(--font-mono-name), ui-monospace, SFMono-Regular, Menlo, monospace;
/* Side padding of a page. The portal uses the same value, so demo and text line up. */
--gutter: clamp(var(--space-16), 5vw, var(--space-48));
/* A card's halo: no offset, so it reads as lift rather than as a light source. */
--shadow-sm: 0 0 7.5px color-mix(in srgb, var(--gray-400) 20%, transparent);
/* The same halo under a brand-coloured element. */
--shadow-accent: 0 0 7.5px color-mix(in srgb, var(--accent-strong) 40%, transparent);
}
shared/triggers/preview-morph.ts 65 lines
import {
BaseTrigger,
defaultState,
Morpher,
stateSegment,
type TriggerContext,
} from '@lordicon/element';
/**
* A morph that also plays a preview. The first value is the boolean the icon morphs on, as
* in the built-in `follow`. `preview` names a second one: when it turns true while the icon
* is at rest, the icon plays its default state once and nothing else changes. The rating
* uses it for the stars under the pointer.
*
* <lord-icon trigger="preview-morph(data-chosen, preview=data-lit)" state="morph-select">
*
* defineElement({ triggers: { 'preview-morph': PreviewMorph } });
*
* Under reduced motion the morph jumps and the preview is left out.
*/
export class PreviewMorph extends BaseTrigger {
static readonly primary = 'attr';
#morpher: Morpher;
constructor(context: TriggerContext) {
super(context);
this.#morpher = new Morpher(this.player, this.ratio());
this.watch(this.target, this.option('attr', 'data-chosen'), () => this.morph());
this.watch(this.target, this.option('preview', 'data-lit'), () => this.preview());
this.signal.addEventListener('abort', () => this.#morpher.restore(), { once: true });
}
onReady(): void {
this.#morpher.start(this.read('attr', 'data-chosen'));
}
onComplete(): void {
this.#morpher.complete();
}
private read(option: string, fallback: string): boolean {
return this.target.getAttribute(this.option(option, fallback)) === 'true';
}
private morph(): void {
const on = this.read('attr', 'data-chosen');
if (!this.player.ready || this.#morpher.showing === on) return;
if (this.reducedMotion) this.#morpher.jump(on);
else this.#morpher.animate(on);
}
private preview(): void {
if (!this.player.ready || !this.read('preview', 'data-lit')) return;
// Nothing to preview when the icon already holds, or is on its way to, the chosen
// look; and no second preview while the first one plays.
if (this.#morpher.showing || this.player.playing || this.reducedMotion) return;
const resting = defaultState(this.player.states);
if (resting) void this.player.play({ segment: stateSegment(resting) });
}
}
import { BaseTrigger, defaultState, Morpher, stateSegment } from '@lordicon/element';
/**
* A morph that also plays a preview. The first value is the boolean the icon morphs on, as
* in the built-in `follow`. `preview` names a second one: when it turns true while the icon
* is at rest, the icon plays its default state once and nothing else changes. The rating
* uses it for the stars under the pointer.
*
* <lord-icon trigger="preview-morph(data-chosen, preview=data-lit)" state="morph-select">
*
* defineElement({ triggers: { 'preview-morph': PreviewMorph } });
*
* Under reduced motion the morph jumps and the preview is left out.
*/
export class PreviewMorph extends BaseTrigger {
static primary = 'attr';
#morpher;
constructor(context) {
super(context);
this.#morpher = new Morpher(this.player, this.ratio());
this.watch(this.target, this.option('attr', 'data-chosen'), () => this.morph());
this.watch(this.target, this.option('preview', 'data-lit'), () => this.preview());
this.signal.addEventListener('abort', () => this.#morpher.restore(), { once: true });
}
onReady() {
this.#morpher.start(this.read('attr', 'data-chosen'));
}
onComplete() {
this.#morpher.complete();
}
read(option, fallback) {
return this.target.getAttribute(this.option(option, fallback)) === 'true';
}
morph() {
const on = this.read('attr', 'data-chosen');
if (!this.player.ready || this.#morpher.showing === on) return;
if (this.reducedMotion) this.#morpher.jump(on);
else this.#morpher.animate(on);
}
preview() {
if (!this.player.ready || !this.read('preview', 'data-lit')) return;
// Nothing to preview when the icon already holds, or is on its way to, the chosen
// look; and no second preview while the first one plays.
if (this.#morpher.showing || this.player.playing || this.reducedMotion) return;
const resting = defaultState(this.player.states);
if (resting) void this.player.play({ segment: stateSegment(resting) });
}
}
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 |
|---|---|---|
star |
system-outline-237-star (edited by hand) |