Icon morph blueprint
Menu ↔ Close
Three menu strokes meet at centre, rotate into a close icon, and preserve a 44px target.
Build it
Choose a target and adapt the source to your interface.
<button
class="menu-toggle"
type="button"
aria-expanded="false"
aria-controls="navigation"
aria-label="Open navigation"
>
<span aria-hidden="true"></span><span aria-hidden="true"></span
><span aria-hidden="true"></span>
</button>
<div id="navigation" hidden></div>
<script>
const toggle = document.querySelector('.menu-toggle');
const navigation = document.querySelector('#navigation');
toggle.addEventListener('click', () => {
const open = toggle.getAttribute('aria-expanded') !== 'true';
toggle.setAttribute('aria-expanded', String(open));
toggle.setAttribute(
'aria-label',
open ? 'Close navigation' : 'Open navigation',
);
navigation.hidden = !open;
});
</script>
<style>
.menu-toggle {
position: relative;
width: 44px;
height: 44px;
border: 0;
border-radius: 6px;
background: transparent;
color: inherit;
cursor: pointer;
}
.menu-toggle span {
position: absolute;
left: 10px;
top: 21px;
width: 24px;
height: 2px;
border-radius: 2px;
background: currentColor;
transition:
transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
opacity 180ms ease-out;
}
.menu-toggle span:first-child {
transform: translateY(-7px);
}
.menu-toggle span:last-child {
transform: translateY(7px);
}
.menu-toggle[aria-expanded='true'] span:first-child {
transform: rotate(45deg);
}
.menu-toggle[aria-expanded='true'] span:nth-child(2) {
opacity: 0;
transform: scaleX(0);
}
.menu-toggle[aria-expanded='true'] span:last-child {
transform: rotate(-45deg);
}
.menu-toggle:focus-visible {
outline: 2px solid #175bc5;
outline-offset: 4px;
}
@media (prefers-reduced-motion: reduce) {
.menu-toggle span {
transition: none;
}
}
</style>Animation breakdown
Frame phases, motion intent, implementation constraints and accessible alternatives.
01
Rest: three strokes at -8px, 0px, and 8px.
02
0–180ms: outer lines centre and rotate, middle line fades.
03
180–220ms: settle.
Motion intent
Three menu strokes meet at centre, rotate into a close icon, and preserve a 44px target.
This duration leaves enough time to read the state change while keeping the interface responsive.
The fast acceleration and long ease-out make the state feel responsive while giving the final pixels time to settle.
Motion specification
- Trigger
- Navigation changes its expanded state.
- Timing
- 220ms
- Easing
- cubic-bezier(.22, 1, .36, 1)
Animated properties
transformopacity
Implementation notes
- Keep menu and close aligned to the same visual anchor.
- Drive the transition from semantic state, not a timer, when the trigger is interactive.
- Use transform, opacity and avoid layout work inside the animation loop.
Reduced motion
Swap to the final state instantly, preserving colour, label, and accessible state.
- Use a minimum 44 by 44 pixel interactive target.
- Expose the menu and close states with aria-pressed, aria-expanded, or an updated accessible label.
- Keep visible keyboard focus independent from the animation.
Avoid
- Do not add bounce unless a real object pickup or spring settle is part of the interaction.
- Do not hide the final state from assistive technology.
- Do not make the user wait for animation before the underlying state updates.