Icon State Morph
Press the button and its icon morphs into its other state: play to pause, plus to close, menu to back arrow, sound on to muted. The lines bend into their new places rather than cross-fading, with a little turn or squeeze for character.
How to use it
- Paste the snippet just before
</head>. - Add
data-icon-state-morphto an empty<button>, set to the icon you want:play-pause,plus-close,menu-arroworsound-mute.
Options
Add any of these attributes to the same element.
data-on | Start in the second state (pause, close, back, muted). |
data-labels | The button’s two labels for screen readers, comma-separated. Defaults suit each icon, e.g. Play,Pause. |
data-autoplay | Toggle every couple of seconds while in view. Handy for demos. |
Size it with font-size and colour it with color. Its aria-label follows the state. Listen for the toggle event (e.detail.on) to play or pause your media, or call animacious.iconMorph(button) to change it from code.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Icon State Morph: a button whose icon morphs smoothly between two states
// when pressed: play ↔ pause, plus ↔ close, menu ↔ back arrow and sound
// on ↔ muted. The lines and shapes bend into their new places (no
// cross-fade), with a little turn or squeeze for character.
// Its label for screen readers follows the state.
// Usage: <button data-icon-state-morph="play-pause"></button>
// Icons: play-pause (default), plus-close, menu-arrow, sound-mute.
// Size it with font-size, colour it with color.
// Optional: data-on (start in the second state), data-labels (the two
// labels, e.g. "Play,Pause"), data-autoplay (it toggles itself every
// couple of seconds while in view; handy for demos).
// Listen for the "toggle" event: e.detail.on is true in the second state.
// Script use: animacious.iconMorph(button) toggles it from code.
var DEFAULTS = { every: 1700 };
// Each icon: its paths in both states (same commands, so they morph point
// for point), how far it turns on the way, and its two labels.
var ICONS = {
'play-pause': {
paths: [
{ fill: true, d: ['M7 4 L13 7.7 L13 16.3 L7 20 Z', 'M6 5 L10 5 L10 19 L6 19 Z'] },
{ fill: true, d: ['M13 7.7 L19.5 12 L19.5 12 L13 16.3 Z', 'M14 5 L18 5 L18 19 L14 19 Z'] }
],
turn: 0, labels: ['Play', 'Pause']
},
'plus-close': {
paths: [
{ d: ['M12 5 L12 19', 'M12 4.5 L12 19.5'] },
{ d: ['M5 12 L19 12', 'M4.5 12 L19.5 12'] }
],
turn: 135, labels: ['Add', 'Close']
},
'menu-arrow': {
paths: [
{ d: ['M4 6 L20 6', 'M12.5 5 L19.5 12'] },
{ d: ['M4 12 L20 12', 'M4.5 12 L19.5 12'] },
{ d: ['M4 18 L20 18', 'M12.5 19 L19.5 12'] }
],
turn: 180, labels: ['Menu', 'Back']
},
'sound-mute': {
paths: [
{ fill: true, d: ['M3 9.5 L7 9.5 L12 5 L12 19 L7 14.5 L3 14.5 Z', 'M3 9.5 L7 9.5 L12 5 L12 19 L7 14.5 L3 14.5 Z'] },
{ d: ['M15.5 9 Q17.5 12 15.5 15', 'M16 9 Q18.5 12 21 15'] },
{ d: ['M18.5 6 Q23 12 18.5 18', 'M21 9 Q18.5 12 16 15'] }
],
turn: 0, labels: ['Mute', 'Unmute']
}
};
var css =
'.ism{display:inline-grid;place-items:center;width:1.8em;height:1.8em;padding:0;border:0;background:none;color:inherit;cursor:pointer;-webkit-tap-highlight-color:transparent}' +
'.ism svg{display:block;width:1.2em;height:1.2em;overflow:visible;pointer-events:none}' +
'.ism path{stroke:currentColor;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
var reduced = function () { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; };
function setup(btn) {
var icon = ICONS[btn.getAttribute('data-icon-state-morph')] || ICONS['play-pause'];
var on = btn.dataset.on !== undefined && btn.dataset.on !== 'false';
btn.classList.add('ism');
if (btn.tagName === 'BUTTON' && !btn.getAttribute('type')) btn.type = 'button';
btn.textContent = '';
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('aria-hidden', 'true');
var paths = icon.paths.map(function (p) {
var path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', p.d[on ? 1 : 0]);
path.setAttribute('fill', p.fill ? 'currentColor' : 'none');
// A thin stroke on the solid shapes softens their corners.
if (p.fill) path.style.strokeWidth = 1.2;
svg.appendChild(path);
return path;
});
btn.appendChild(svg);
var labels = btn.dataset.labels ? btn.dataset.labels.split(',') : icon.labels;
btn._ism = { icon: icon, svg: svg, paths: paths, on: on, labels: labels };
btn.setAttribute('aria-label', labels[on ? 1 : 0].trim());
if (icon.turn && on) anime.utils.set(svg, { rotate: icon.turn });
}
function toggle(btn) {
var s = btn._ism;
var on = (s.on = !s.on);
btn.setAttribute('aria-label', s.labels[on ? 1 : 0].trim());
btn.dispatchEvent(new CustomEvent('toggle', { bubbles: true, detail: { on: on } }));
var i = on ? 1 : 0;
if (reduced()) {
s.paths.forEach(function (p, n) { p.setAttribute('d', s.icon.paths[n].d[i]); });
anime.utils.set(s.svg, { rotate: on ? s.icon.turn : 0 });
return;
}
s.paths.forEach(function (p, n) {
anime.animate(p, { d: s.icon.paths[n].d[i], duration: 420, delay: n * 40, ease: 'inOut(3)' });
});
if (s.icon.turn) {
// Turn forwards into the second state and back out of it, with a spring.
anime.animate(s.svg, { rotate: on ? s.icon.turn : 0, duration: 800, ease: 'outElastic(1, .6)' });
} else {
// No turn for these: a quick squeeze instead.
anime.animate(s.svg, { scale: [{ to: 0.78, duration: 140, ease: 'out(2)' }, { to: 1, duration: 600, ease: 'outElastic(1, .5)' }] });
}
}
function init() {
document.querySelectorAll('[data-icon-state-morph]').forEach(function (btn) {
if (btn._ism) return;
setup(btn);
btn.addEventListener('click', function () { toggle(btn); });
if (btn.dataset.autoplay !== undefined && btn.dataset.autoplay !== 'false' && !reduced()) {
var timer = null;
var tick = function () {
if (!btn.matches(':hover') && document.activeElement !== btn) toggle(btn);
timer = setTimeout(tick, DEFAULTS.every);
};
new IntersectionObserver(function (entries) {
clearTimeout(timer);
if (entries[0].isIntersecting) timer = setTimeout(tick, 600 + Math.random() * 600);
}).observe(btn);
}
});
}
window.animacious = window.animacious || {};
window.animacious.iconMorph = function (btn) { if (btn._ism) toggle(btn); };
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><button data-icon-state-morph="play-pause"></button>
<script>
document.querySelector("[data-icon-state-morph]").addEventListener("toggle", (e) => {
e.detail.on ? video.play() : video.pause();
});
</script>