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.

CategoryMicro-interactions
TypeCSS only (.mv-icon-morph)
Statusstable
Keywordsicon, morph, hamburger, menu, play, pause, chevron, toggle, css-only

When to use

Avoid when

Install

node scripts/add.mjs icon-morph --out ./src/marvelous

AI 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

NameTypeDescription
data-iconmenu | plus | play | chevron | chevron-right | arrow-checkIcon 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-stateon | open | active | offOn the icon itself: forces the state (off wins).

CSS classes

NameDescription
mv-icon-morphOn 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

NameDefaultDescription
--mv-icon-morph-size1.25emIcon size (side length).
--mv-icon-morph-stroke9% of the sizeStroke thickness.
--mv-icon-morph-durationvar(--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.