Icon Morph .mv-icon-morph
부모의 ARIA 상태에 따라 모핑되는 순수 CSS 아이콘: 햄버거 ↔ 닫기, 플러스 ↔ 마이너스, 재생 ↔ 일시 정지, 셰브런 V ↔ Λ, 회전하는 셰브런, 화살표 → 체크.
| 카테고리 | 마이크로 인터랙션 |
|---|---|
| 유형 | CSS 전용(.mv-icon-morph) |
| 상태 | 안정 |
| 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
설치
node scripts/add.mjs icon-morph --out ./src/marvelousMarvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["icon-morph"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
복사되는 파일(의존성 포함): tokens/tokens.css, core/base.css, components/icon-morph/icon-morph.css.
사용법
기본 마크업입니다. 여기서 시작해 속성, data-*, CSS 변수로 커스터마이즈하세요:
<div id="mv-icon-morph-demo" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(88px,1fr));gap:.75rem;width:100%;min-width:0;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 | 유형 | 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. In the no-JS pattern (<label> + visually hidden checkbox), the label shows the focus ring while the checkbox has keyboard focus, and Space toggles it; give the checkbox its own aria-label. Forced colors: the bars are painted in ButtonText.