Gentleface › Animated icons
Animated icons in use
1,000 Gentleface icons also come animated. Each is one SVG file with native animation: no library, no CSS, no script in the file. It starts and ends on the same drawing as the static icon, so it can play once and stop on the icon you already show. Free under the MIT license.
Every example here is live and runs on those files. Setup · All 1,000 animated icons · Download all (ZIP)
Your system asks for reduced motion, so these examples stay still.
On hover
A toolbar where an icon plays once when the pointer arrives or the button takes focus. Move along the row.
const svg = await loadIcon('/motion/copy-slide.svg', button);
const play = () => once(svg, [0.25, 2]);
button.addEventListener('pointerenter', play);
button.addEventListener('focus', play);On click
The press gets an answer. Like, helpful, save and star keep their state. The others count or change their label for a moment.
button.addEventListener('click', () => {
const liked = button.getAttribute('aria-pressed') !== 'true';
button.setAttribute('aria-pressed', liked);
if (liked) once(svg, [0.2, 1.35]);
});Two states
Menu and close, play and pause, sun and moon. The icon moves to its second drawing and stays there until the next click.
let open = false;
button.addEventListener('click', () => {
open = !open;
toggle(svg, open, { on: [0.7, 1.45], hold: 1.8, off: [2.2, 2.95] });
});Toasts
The icon plays once as the toast appears, so the eye goes to it, then it stays still. It takes the color of the message.
async function toast(text, name, frames) {
const t = document.createElement('div');
t.className = 'toast';
t.innerHTML = '<span class="icon"></span><span class="text"></span>';
t.querySelector('.text').textContent = text;
toasts.append(t); // a container with aria-live="polite"
once(await loadIcon(`/motion/${name}.svg`, t.firstChild), frames);
setTimeout(() => t.remove(), 4500);
}
toast('Changes saved', 'check-circle-draw', [0.2, 1.75]);Loading
The loop runs for as long as the work does, then the result plays once. The row under the buttons loops while it is on screen.
button.addEventListener('click', async () => {
loop(spinner);
await save();
stop(spinner);
once(check, [0.2, 1.75]);
});Notifications
Something arrived: the badge counts and the icon plays once. It says so without a sound or a popup.
socket.addEventListener('message', () => {
badge.textContent = ++unread;
once(bell, [0.2, 1.5]);
});Live status
A state that lasts: connecting, charging, recording. Run the loop only while it is on screen.
new IntersectionObserver(([e]) => {
if (e.isIntersecting) loop(svg); else stop(svg);
}).observe(svg);Forms
The eye switches to the crossed eye while the password shows, and back. Answer a validation, show a search at work. Type in them: nothing here leaves the page.
let shown = false;
eye.addEventListener('click', () => {
shown = !shown;
input.type = shown ? 'text' : 'password';
eye.setAttribute('aria-label', shown ? 'Hide the password' : 'Show the password');
toggle(svg, shown, { on: [0.6, 1.2], hold: 1.6, off: [2.2, 2.76] });
});
input.addEventListener('input', () => {
loop(search);
clearTimeout(timer);
timer = setTimeout(() => stop(search), 700);
});Tab bar
The tab you pick plays once, and only that one.
tab.addEventListener('click', () => {
select(tab);
once(tab.svg, tab.frames);
});On scroll
A feature list where each icon plays as it comes into view. Scroll away and back, or replay.
new IntersectionObserver((entries) => {
// the first callback fires for every target, in view or not
for (const e of entries) if (e.isIntersecting) once(e.target, frames(e.target));
}, { threshold: 0.6 }).observe(svg);Empty states and first run
At 48 px there is room to tell a small story. Whole multiples of 16 keep every line on the pixel grid at rest.
const svg = await loadIcon('/motion/party-popper-burst.svg', box);
svg.setAttribute('width', 48);
svg.setAttribute('height', 48);
once(svg, [0.22, 1.5]);Setup
With no script at all
A file loops by itself. In an <img> it draws black, which is enough for a spinner on a light ground.
<img src="/motion/loader-step.svg" width="16" height="16" alt="">
Inline
Copy the SVG from an icon's page. It takes the text color, and a script can start and stop it: every inline <svg> has pauseAnimations(), unpauseAnimations() and setCurrentTime().
The helper
The examples above run on this one small file. Download it or copy it.
// Gentleface animated icons: load one, start it, stop it. The motion is native SVG animation in the file, so this is
// all the script there is. MIT License. https://gentleface.com/animated-icons
let uid = 0;
let calm = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
const timers = new WeakMap();
/** By default nothing moves when the system asks for reduced motion. Pass false after the user asks for motion. */
export function respectReducedMotion(on) { calm = on && typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches; }
export const isCalm = () => calm;
/** Two copies on one page must not share ids, and some icons use a clipPath or a mask. */
export function uniqueIds(text) {
const p = `gf${++uid}-`;
return text.replace(/\bid="([^"]+)"/g, `id="${p}$1"`).replace(/url\(#([^)]+)\)/g, `url(#${p}$1)`).replace(/href="#([^"]+)"/g, `href="#${p}$1"`);
}
/** Put the text of an animated SVG into `box`, stopped on its rest pose. Returns the <svg>. */
export function insert(text, box) {
const t = document.createElement('template');
t.innerHTML = uniqueIds(text).trim();
const svg = t.content.firstElementChild;
svg.setAttribute('aria-hidden', 'true');
box.replaceChildren(svg);
stop(svg);
return svg;
}
/** Fetch an animated SVG from your own origin and insert it. */
export async function loadIcon(url, box) {
const r = await fetch(url);
if (!r.ok) throw new Error(`${url}: ${r.status}`);
return insert(await r.text(), box);
}
/** Back to the rest pose, stopped. */
export function stop(svg) {
clearTimeout(timers.get(svg));
svg.pauseAnimations();
svg.setCurrentTime(0);
}
/** Run until stop(). For loading and live states. */
export function loop(svg) {
stop(svg);
if (!calm) svg.unpauseAnimations();
}
/** Play from one time to another (seconds), then hold there. */
export function play(svg, from, to) {
clearTimeout(timers.get(svg));
return new Promise((done) => {
const end = () => { svg.pauseAnimations(); svg.setCurrentTime(to); done(); };
if (calm) return end();
svg.setCurrentTime(from);
svg.unpauseAnimations();
timers.set(svg, setTimeout(end, (to - from) * 1000));
});
}
/** Play the moving part once and return to rest. `frames` is the icon's [start, end]: see its page or its JSON. */
export const once = (svg, frames) => play(svg, frames[0], frames[1]).then(() => stop(svg));
/** A two-state icon (menu and x, play and pause, sun and moon): `times` is { on, hold, off } from its page or its JSON. */
export const toggle = (svg, on, times) => (on ? play(svg, times.on[0], times.hold) : play(svg, times.off[0], times.off[1]).then(() => stop(svg)));
React
Each animated icon has a Copy React button on its page. The component passes a ref to the <svg>.
import { useEffect, useRef } from 'react';
import { once, stop } from './gentleface-motion';
import { BellRingIcon } from './BellRingIcon'; // "Copy React" on the bell page
export function BellButton() {
const icon = useRef<SVGSVGElement>(null);
useEffect(() => stop(icon.current!), []);
return (
<button aria-label="Notifications" onPointerEnter={() => once(icon.current!, [0.2, 1.5])}>
<BellRingIcon ref={icon} />
</button>
);
}
Notes
- Reduced motion. The helper keeps icons still when the system asks for reduced motion. A loop on an
<img>cannot be stopped, so use it only for something brief, such as a spinner. - One thing at a time. Motion calls the eye. Play on an event, and stop loops that are off screen.
- Labels. The icons are decorative (
aria-hidden). An icon-only button needs a name on the button. A toast belongs in anaria-liveregion. - Size. Use 16 px or a whole multiple (32, 48) on whole pixels. Rest is then pixel-exact, and the moving frames are vector drawing.
- Style. Animated icons are line with round corners. They start and end on the same drawing as the static
line-roundicon, so one can swap for the other without a jump. - Files.
loadIconfetches, so serve the files from your own origin. Or paste the SVG inline. - Times.
framesis the moving part of the loop, in seconds. Each icon page and its JSON have it, and two-state icons also havetimes.
Files
- One icon:
https://gentleface.com/motion/{motion}.svg, for example/motion/bell-ring.svg. - All of them, with the helper and the times:
/gentleface-animated.zip. - The list:
/motion/index.json. Agents get the same fromget_iconin the MCP server, or from/icons/{name}.jsonundermotion.