Icon Morph — .mv-icon-morph
Pure-CSS icons that morph with their parent’s ARIA state: hamburger ↔ close, plus ↔ minus, play ↔ pause, chevron V ↔ Λ, rotating chevron, arrow → check.
| Category | Micro-interactions |
|---|---|
| Type | CSS only (.mv-icon-morph) |
| Status | stable |
| Keywords | icon, morph, hamburger, menu, play, pause, chevron, toggle, css-only |
When to use
- A menu button should turn its hamburger into a close icon when the menu opens
- Play/pause, plus/minus or chevron icons should morph with the button's aria-pressed or aria-expanded state
- An icon state change is wanted in pure CSS with no script
Avoid when
- The need is a full light/dark theme switch with a sun/moon icon → use Theme Toggle instead
- The icon is not inside a button that carries its own accessible name and ARIA state
Install
node scripts/add.mjs icon-morph --out ./src/marvelousAI agent with the Marvelous UI MCP server: install_components({ slugs: ["icon-morph"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Files copied (dependencies included): tokens/tokens.css, core/base.css, components/icon-morph/icon-morph.css.
Usage
Canonical markup — start from it and customize with attributes, data-* and CSS variables:
<div id="mv-icon-morph-demo" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(88px,1fr));gap:.75rem;width:100%;max-width:760px;text-align:center">
<div style="display:grid;gap:.4rem;justify-items:center">
<button class="mv-button" data-variant="outline" data-size="icon" aria-expanded="false" aria-label="Main menu"><span class="mv-icon-morph" data-icon="menu" aria-hidden="true"><i></i><i></i><i></i></span></button>
<small style="color:var(--mv-fg-muted)">Menu</small>
</div>
<div style="display:grid;gap:.4rem;justify-items:center">
<button class="mv-button" data-variant="outline" data-size="icon" aria-expanded="false" aria-label="Show details"><span class="mv-icon-morph" data-icon="plus" aria-hidden="true"><i></i><i></i></span></button>
<small style="color:var(--mv-fg-muted)">Plus / minus</small>
</div>
<div style="display:grid;gap:.4rem;justify-items:center">
<button class="mv-button" data-size="icon" data-shape="round" aria-pressed="false" aria-label="Play"><span class="mv-icon-morph" data-icon="play" aria-hidden="true" style="--mv-icon-morph-size:1.05em"><i></i><i></i></span></button>
<small style="color:var(--mv-fg-muted)">Play / pause</small>
</div>
<div style="display:grid;gap:.4rem;justify-items:center">
<button class="mv-button" data-variant="outline" data-size="icon" aria-expanded="false" aria-label="Sort"><span class="mv-icon-morph" data-icon="chevron" aria-hidden="true"><i></i><i></i></span></button>
<small style="color:var(--mv-fg-muted)">Chevron</small>
</div>
<div style="display:grid;gap:.4rem;justify-items:center">
<button class="mv-button" data-variant="outline" data-size="icon" aria-expanded="false" aria-label="Expand section"><span class="mv-icon-morph" data-icon="chevron-right" aria-hidden="true"><i></i><i></i></span></button>
<small style="color:var(--mv-fg-muted)">Expand</small>
</div>
<div style="display:grid;gap:.4rem;justify-items:center">
<button class="mv-button" data-variant="secondary" aria-pressed="false">
<span class="mv-icon-morph" data-icon="arrow-check" aria-hidden="true"><svg viewBox="0 0 24 24"><path pathLength="1" d="M5 12h14M13 6l6 6-6 6"/><path pathLength="1" d="M5 12.5l4.5 4.5L19 7.5"/></svg></span>
Send
</button>
<small style="color:var(--mv-fg-muted)">Arrow → check</small>
</div>
<div style="display:grid;gap:.4rem;justify-items:center">
<label class="mv-button" data-variant="ghost" data-size="icon" style="cursor:pointer" title="Menu without JavaScript">
<input type="checkbox" class="mv-sr-only" aria-label="Menu without JavaScript">
<span class="mv-icon-morph" data-icon="menu" aria-hidden="true"><i></i><i></i><i></i></span>
</label>
<small style="color:var(--mv-fg-muted)">CSS only</small>
</div>
</div>
<script type="module">
// Demo only: flip the ARIA state the icons listen to.
document.getElementById("mv-icon-morph-demo")?.addEventListener("click", (e) => {
const b = e.target.closest("button");
if (!b) return;
const attr = b.hasAttribute("aria-pressed") ? "aria-pressed" : "aria-expanded";
const on = b.getAttribute(attr) !== "true";
b.setAttribute(attr, String(on));
if (b.querySelector('[data-icon="play"]')) b.setAttribute("aria-label", on ? "Pause" : "Play");
});
</script>API
Attributes
| Name | Type | Description |
|---|---|---|
data-icon | menu | plus | play | chevron | chevron-right | arrow-check | Icon shape (state 0 → state 1). |
(parent) aria-pressed / aria-expanded / aria-checked = "true" | Switches the icon to state 1. Also: data-state="on|open|active|checked" on the parent, or a checked <input> that is a direct child of the parent (<label>, no JS). | |
data-state | on | open | active | off | On the icon itself: forces the state (off wins). |
CSS classes
| Name | Description |
|---|---|
mv-icon-morph | On a <span aria-hidden="true"> that is a DIRECT child of the button. Content: one <i> per stroke (menu: 3, plus / play / chevron / chevron-right: 2); arrow-check: an <svg viewBox="0 0 24 24"> with two <path pathLength="1"> (arrow, then check). |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-icon-morph-size | 1.25em | Icon size (side length). |
--mv-icon-morph-stroke | 9% of the size | Stroke thickness. |
--mv-icon-morph-duration | var(--mv-duration-normal) | Base duration (delays are derived from it). |
Accessibility
The icon is decorative (aria-hidden): the button carries the name and state (aria-expanded for a menu or section, aria-pressed for play/pause — remember to update aria-label if the label changes). All geometry derives from a single --_on variable, and the step delays (strokes meet, then rotate, and the reverse on close) come from the duration tokens: with reduced motion everything switches instantly.