Icon morph blueprint

Menu ↔ Close

Three menu strokes meet at centre, rotate into a close icon, and preserve a 44px target.

Playground

Playhead 0ms / 220ms

Build it

Choose a target and adapt the source to your interface.

75 lines
index.html
<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.

220ms
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.

Related blueprints

Browse all blueprints