# 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 line-round icon. MIT licensed. Live examples: https://gentleface.com/animated-icons

Files: https://gentleface.com/motion/{motion}.svg. List with times: https://gentleface.com/motion/index.json. All: https://gentleface.com/gentleface-animated.zip. Per icon: https://gentleface.com/icons/{name}.json under `motion` (name, svg, react, frames, times).

## On hover

A toolbar where an icon plays once when the pointer arrives or the button takes focus. Move along the row.

```js
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.

```js
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.

```js
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.

```js
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.

```js
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.

```js
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.

```js
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.

```js
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);
});
```

## Menus and panels

The chevron holds its turn while a row is open. The small buttons play when you press them, the dots when you pass over them.

```js
row.addEventListener('click', () => {
  const open = row.getAttribute('aria-expanded') !== 'true';
  row.setAttribute('aria-expanded', open);
  panel.hidden = !open;
  toggle(chevron, open, { on: [0.3, 0.68], hold: 1, off: [1.3, 2.02] });
});
```

## Tab bar

The tab you pick plays once, and only that one.

```js
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.

```js
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.

```js
const svg = await loadIcon('/motion/party-popper-burst.svg', box);
svg.setAttribute('width', 48);
svg.setAttribute('height', 48);
once(svg, [0.22, 1.5]);
```

## With no script at all

A file loops by itself. In an `<img>` it draws black.

```html
<img src="/motion/loader-step.svg" width="16" height="16" alt="">
```

## The helper (https://gentleface.com/motion/gentleface-motion.js)

```js
// 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

```tsx
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 an `aria-live` region.
- **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-round` icon, so one can swap for the other without a jump.
- **Files.** `loadIcon` fetches, so serve the files from your own origin. Or paste the SVG inline.
- **Times.** `frames` is the moving part of the loop, in seconds. Each icon page and its JSON have it, and two-state icons also have `times`.
