Icon morph blueprint

Plus ↔ Close

Two neutral strokes rotate into a close affordance, keeping the origin and target constant.

Playground

Playhead 0ms / 200ms

Build it

Choose a target and adapt the source to your interface.

65 lines
index.html
<button
  class="plus-close"
  type="button"
  aria-expanded="false"
  aria-label="Open disclosure"
>
  <span
    class="plus-close__bar plus-close__bar--horizontal"
    aria-hidden="true"
  ></span>
  <span
    class="plus-close__bar plus-close__bar--vertical"
    aria-hidden="true"
  ></span>
</button>
<script>
  const control = document.querySelector('.plus-close');
  control.addEventListener('click', () => {
    const expanded = control.getAttribute('aria-expanded') !== 'true';
    control.setAttribute('aria-expanded', String(expanded));
    control.setAttribute(
      'aria-label',
      expanded ? 'Close disclosure' : 'Open disclosure',
    );
  });
</script>

<style>
  .plus-close {
    position: relative;
    width: 44px;
    height: 44px;
    border: 0;
    background: transparent;
    color: currentColor;
  }
  .plus-close__bar {
    position: absolute;
    top: 50%;
    left: 50%;
    width: 24px;
    height: 2px;
    border-radius: 999px;
    background: currentColor;
    transform-origin: center;
    transition: transform 200ms cubic-bezier(0.22, 1, 0.36, 1);
  }
  .plus-close__bar--horizontal {
    transform: translate(-50%, -50%) rotate(0deg);
  }
  .plus-close__bar--vertical {
    transform: translate(-50%, -50%) rotate(90deg);
  }
  .plus-close[aria-expanded='true'] .plus-close__bar--horizontal {
    transform: translate(-50%, -50%) rotate(45deg);
  }
  .plus-close[aria-expanded='true'] .plus-close__bar--vertical {
    transform: translate(-50%, -50%) rotate(-45deg);
  }
  @media (prefers-reduced-motion: reduce) {
    .plus-close__bar {
      transition-duration: 0.001ms;
    }
  }
</style>

Animation breakdown

Frame phases, motion intent, implementation constraints and accessible alternatives.

200ms
01

Rest: vertical and horizontal strokes intersect.

02

0–140ms: both rotate around their centre.

03

140–200ms: settle at the crossed state.

Motion intent

Two neutral strokes rotate into a close affordance, keeping the origin and target constant.

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
An inline disclosure opens or closes.
Timing
200ms
Easing
cubic-bezier(.22, 1, .36, 1)

Animated properties

transformopacity

Implementation notes

  • Keep plus 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 plus 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