Icon morph blueprint
Plus ↔ Close
Two neutral strokes rotate into a close affordance, keeping the origin and target constant.
Build it
Choose a target and adapt the source to your interface.
<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.
Rest: vertical and horizontal strokes intersect.
0–140ms: both rotate around their centre.
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
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.