Web Animations Easy
Animated Icon Set
Toggle play/pause and menu/close SVG icons with accessible labels and a small path animation.
Open in Lab
MCP
html css javascript react
Targets: TS JS HTML React
Code
/* Animated Icon Set */
:root {
--bg: #0b1020;
--panel: #121a2d;
--line: #263555;
--fg: #eef2ff;
--muted: #a8b5d1;
--accent: #78ffd0;
--accent-2: #78a9ff;
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(900px 520px at 50% -8%, #17203a 0%, var(--bg) 62%);
color: var(--fg);
font: 15px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
padding: clamp(1rem, 4vw, 3rem);
display: grid;
place-items: center;
}
button, input { font: inherit; }
.demo {
width: min(620px, 100%);
background: linear-gradient(180deg, #16203a, var(--panel));
border: 1px solid var(--line);
border-radius: 22px;
padding: clamp(1.25rem, 3vw, 2rem);
box-shadow: 0 24px 70px -30px #000;
}
.demo__head h1 { margin: 0 0 .35rem; font-size: 1.15rem; letter-spacing: -.01em; }
.muted { margin: 0 0 1.5rem; color: var(--muted); font-size: .9rem; }
/* ---------- icon buttons ---------- */
.icons {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: .75rem;
}
.icon-btn {
display: grid;
justify-items: center;
gap: .6rem;
padding: 1.15rem .5rem .9rem;
background: #101a30;
border: 1px solid var(--line);
border-radius: 16px;
color: var(--fg);
cursor: pointer;
transition: border-color .18s ease, background .18s ease, transform .18s ease;
}
.icon-btn:hover { background: #14203a; border-color: #35507f; }
.icon-btn:active { transform: translateY(1px); }
.icon-btn:focus-visible { outline: 2px solid var(--accent-2); outline-offset: 3px; }
.icon-btn[aria-pressed="true"] {
border-color: color-mix(in oklab, var(--accent) 50%, var(--line));
background: #0f2a26;
}
.icon-btn__label {
font-size: .74rem;
letter-spacing: .07em;
text-transform: uppercase;
color: var(--muted);
transition: color .18s ease;
}
.icon-btn[aria-pressed="true"] .icon-btn__label { color: var(--accent); }
.icon {
width: 36px;
height: 36px;
fill: none;
stroke: var(--fg);
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
overflow: visible;
transition: stroke .18s ease;
}
.icon-btn[aria-pressed="true"] .icon { stroke: var(--accent); }
/* heart fills once fully morphed */
.fillable { fill: transparent; transition: fill .25s ease; }
.icon-btn[aria-pressed="true"] .fillable { fill: color-mix(in oklab, var(--accent) 28%, transparent); }
/* check: ring + JS-driven dash draw on the tick */
.ring { stroke: var(--line); transition: stroke .18s ease; }
.icon-btn[aria-pressed="true"] .ring { stroke: var(--accent); }
.status {
margin: 1.35rem 0 1rem;
padding: .65rem .8rem;
border-radius: 12px;
background: #0c1425;
border: 1px solid var(--line);
color: var(--muted);
font-size: .85rem;
}
.switch {
display: inline-flex;
align-items: center;
gap: .55rem;
color: var(--muted);
font-size: .85rem;
cursor: pointer;
}
.switch input { accent-color: var(--accent); width: 16px; height: 16px; }
@media (prefers-reduced-motion: reduce) {
.icon-btn, .icon, .fillable, .ring, .icon-btn__label { transition: none; }
}/* Animated Icon Set — real path-data interpolation, zero dependencies.
*
* Each morphing <path> carries data-from / data-to path strings that use the
* same command sequence and the same number of points. We parse both into flat
* number arrays once, then on toggle we rAF-tween t: 0 -> 1 and rebuild `d`.
* Degenerate (repeated) points are how a 2-point line morphs into a 9-point
* heart without visual tearing.
*/
const REDUCED = window.matchMedia("(prefers-reduced-motion: reduce)");
const DURATION = 420;
const status = document.querySelector(".status");
const reduceBox = document.querySelector("#reduce");
const LABELS = {
play: { off: ["Pause", "Play"], on: ["Play", "Pause"] },
menu: { off: ["Menu", "Open menu"], on: ["Close", "Close menu"] },
check: { off: ["Done", "Mark as done"], on: ["Undone", "Mark as not done"] },
heart: { off: ["Like", "Add to favourites"], on: ["Liked", "Remove from favourites"] },
};
/* ---------- path parsing ---------- */
/** "M 4 7 L 20 7" -> { commands: ["M","L"], nums: [4,7,20,7] } */
function parsePath(d) {
const tokens = d.match(/[a-zA-Z]|-?\d*\.?\d+/g) || [];
const commands = [];
const nums = [];
for (const tok of tokens) {
if (/[a-zA-Z]/.test(tok)) commands.push(tok);
else nums.push(parseFloat(tok));
}
return { commands, nums };
}
function buildPath(commands, nums) {
let out = "";
let i = 0;
for (const cmd of commands) {
out += (out ? " " : "") + cmd;
// every command used here (M / L) takes exactly one x,y pair
out += ` ${round(nums[i++])} ${round(nums[i++])}`;
}
return out;
}
const round = (n) => Math.round(n * 1000) / 1000;
const easeInOut = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
/* ---------- morph engine ---------- */
class Morpher {
constructor(path) {
this.el = path;
this.a = parsePath(path.dataset.from);
this.b = parsePath(path.dataset.to);
if (this.a.nums.length !== this.b.nums.length) {
console.warn("morph endpoints differ in point count", path);
}
this.el.setAttribute("d", buildPath(this.a.commands, this.a.nums));
}
/** t = 0 (from) .. 1 (to) */
set(t) {
const { commands, nums: from } = this.a;
const to = this.b.nums;
const mixed = new Array(from.length);
for (let i = 0; i < from.length; i++) {
mixed[i] = from[i] + (to[i] - from[i]) * t;
}
this.el.setAttribute("d", buildPath(commands, mixed));
}
}
/* ---------- per-button controller ---------- */
function snapping() {
return reduceBox.checked || REDUCED.matches;
}
class IconToggle {
constructor(btn) {
this.btn = btn;
this.name = btn.dataset.icon;
this.on = false;
this.t = 0;
this.raf = 0;
this.morphers = [...btn.querySelectorAll("[data-morph]")].map((p) => new Morpher(p));
// the check icon draws its tick with a dash offset instead of a morph
this.tick = btn.querySelector(".tick");
if (this.tick) {
this.tickLen = this.tick.getTotalLength();
this.tick.style.strokeDasharray = this.tickLen;
this.tick.style.strokeDashoffset = this.tickLen;
}
btn.addEventListener("click", () => this.toggle());
this.render(0);
}
render(t) {
const e = easeInOut(t);
for (const m of this.morphers) m.set(e);
if (this.tick) this.tick.style.strokeDashoffset = String(this.tickLen * (1 - e));
}
toggle() {
this.on = !this.on;
const target = this.on ? 1 : 0;
const [label, aria] = LABELS[this.name][this.on ? "on" : "off"];
this.btn.setAttribute("aria-pressed", String(this.on));
this.btn.setAttribute("aria-label", aria);
if (this.btn.hasAttribute("aria-expanded")) {
this.btn.setAttribute("aria-expanded", String(this.on));
}
this.btn.querySelector(".icon-btn__label").textContent = label;
status.textContent = `${aria} — state: ${this.on ? "on" : "off"}`;
cancelAnimationFrame(this.raf);
if (snapping()) {
this.t = target;
this.render(target);
return;
}
const start = performance.now();
const from = this.t;
const delta = target - from;
// shorten the tween if we interrupt mid-flight
const dur = DURATION * Math.max(0.25, Math.abs(delta));
const step = (now) => {
const p = Math.min(1, (now - start) / dur);
this.t = from + delta * p;
this.render(this.t);
if (p < 1) this.raf = requestAnimationFrame(step);
};
this.raf = requestAnimationFrame(step);
}
}
const toggles = [...document.querySelectorAll(".icon-btn")].map((b) => new IconToggle(b));
reduceBox.addEventListener("change", () => {
status.textContent = snapping()
? "Reduced motion: state changes apply instantly."
: "Motion enabled: paths tween between keyframes.";
for (const t of toggles) {
cancelAnimationFrame(t.raf);
t.t = t.on ? 1 : 0;
t.render(t.t);
}
});<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Animated Icon Set</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="demo" data-demo="Animated Icon Set">
<header class="demo__head">
<h1>Animated icon set</h1>
<p class="muted">Icons morph by interpolating path data point-by-point — same node count in, same node count out.</p>
</header>
<div class="icons">
<button class="icon-btn" type="button" data-icon="play" aria-pressed="false" aria-label="Play">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path data-morph
data-from="M 8 5 L 8 19 L 8 19 L 8 5"
data-to="M 7 5 L 7 19 L 19 12 L 19 12" />
<path data-morph
data-from="M 16 5 L 16 19 L 16 19 L 16 5"
data-to="M 19 12 L 19 12 L 19 12 L 19 12" />
</svg>
<span class="icon-btn__label">Pause</span>
</button>
<button class="icon-btn" type="button" data-icon="menu" aria-pressed="false" aria-expanded="false" aria-label="Open menu">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path data-morph data-from="M 4 7 L 20 7" data-to="M 6 6 L 18 18" />
<path data-morph data-from="M 4 12 L 20 12" data-to="M 12 12 L 12 12" />
<path data-morph data-from="M 4 17 L 20 17" data-to="M 6 18 L 18 6" />
</svg>
<span class="icon-btn__label">Menu</span>
</button>
<button class="icon-btn" type="button" data-icon="check" aria-pressed="false" aria-label="Mark as done">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<circle class="ring" cx="12" cy="12" r="9" />
<path class="tick" d="M 7.5 12.5 L 10.8 15.8 L 16.5 8.8" />
</svg>
<span class="icon-btn__label">Done</span>
</button>
<button class="icon-btn" type="button" data-icon="heart" aria-pressed="false" aria-label="Add to favourites">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path class="fillable" data-morph
data-from="M 12 12 L 12 12 L 12 12 L 12 12 L 12 12 L 12 12 L 12 12 L 12 12 L 12 12"
data-to="M 12 20 L 4 12.5 L 4 8 L 8 5.5 L 12 8.5 L 16 5.5 L 20 8 L 20 12.5 L 12 20" />
</svg>
<span class="icon-btn__label">Like</span>
</button>
</div>
<p class="status" role="status" aria-live="polite">Ready — click an icon or focus it and press Enter.</p>
<label class="switch">
<input type="checkbox" id="reduce" />
<span>Simulate reduced motion (snap instead of tween)</span>
</label>
</main>
<script src="script.js"></script>
</body>
</html>import { useState } from "react";
export function SvgIconToggle() {
const [items, setItems] = useState(["Alpha", "Beta", "Gamma"]);
return (
<section className="demo">
<h2>Animated Icon Set</h2>
<p>Vanilla behavior maps cleanly to Pointer Events and DOM state.</p>
<div className="stack">
{items.map((item, index) => (
<button
className="item"
key={item}
onClick={() => setItems([...items.slice(0, index), ...items.slice(index + 1), item])}
>
{item}
</button>
))}
</div>
</section>
);
}Animated Icon Set
Toggle play/pause and menu/close SVG icons with accessible labels and a small path animation.
Support notes
SVG animation can be driven by SMIL, CSS, or JavaScript. Keep path point counts compatible for reliable morphing and provide a non-animated state for reduced motion.
Included demo
- Vanilla HTML, CSS, and JavaScript with zero external dependencies.
- React equivalent using the same interaction model.
- A Lab route for trying the behavior in isolation.
Integration checklist
Keep state updates separate from presentation, preserve semantic labels, and add persistence or server callbacks at the boundary where your product needs them.