Back to the library
Icon morph — menu to close
Variant: Menu to close
Three bars becoming a cross in two stages of 144ms rather than one move of 288ms: the outer bars converge on the centre while the middle collapses out from under them, and only then do they rotate. Travelling and turning at once is the version everyone ships, and it reads as a scribble — there is no frame in which the menu is legibly gone and the cross is not yet there. Both directions cost the same, because opening a menu and closing it are one decision.
controliconmorphsvg
The source
A complete HTML document. Paste it into an empty .html file and it plays — no stylesheet, script, font, or image to fetch.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Icon morph — menu to close</title>
<style>
:root {
/* Neutrals — fixed, never remixed. */
--ivory: #faf9f5;
--paper: #ffffff;
--oat: #e3dacc;
--slate: #141413;
/* Knobs — the remix API. A knob exists iff declared here and used below. */
--hue: 38.8;
--lift: 0;
--chroma: 1;
--round: 1;
--speed: 1;
/* Accents — derived from the knobs with the family's offsets baked in,
so one hue roll moves them together. Defaults reproduce the original hex. */
--clay: oklch(calc(0.6724 + var(--lift)) calc(0.1308 * var(--chroma)) var(--hue));
--clay-d: oklch(calc(0.5797 + var(--lift)) calc(0.127 * var(--chroma)) var(--hue));
--olive: oklch(
calc(0.6118 + var(--lift)) calc(0.0713 * var(--chroma)) calc(var(--hue) + 88.3)
);
--rust: oklch(
calc(0.5408 + var(--lift)) calc(0.1357 * var(--chroma)) calc(var(--hue) - 10.3)
);
/* Clock — every duration in the document runs off --t. All four siblings
share this cycle and the same beat map, so they can be compared frame
for frame: press at 8%, morph 11-19%, press back at 55%, return 58-66%. */
--cycle: 3.6s;
--t: calc(var(--cycle) / var(--speed));
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
--ease-accent: cubic-bezier(0.32, 0.72, 0, 1);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
display: grid;
place-items: center;
background: var(--ivory);
font-family:
system-ui,
-apple-system,
'Segoe UI',
Roboto,
sans-serif;
color: var(--slate);
}
/* The chip is here to make the morph legible as a control rather than a
logo: something was pressed, and the icon answered. */
.chip {
display: grid;
place-items: center;
width: clamp(72px, 20vw, 96px);
aspect-ratio: 1;
border-radius: calc(18px * var(--round));
background: var(--paper);
box-shadow: inset 0 0 0 1px var(--oat);
animation: press var(--t) infinite;
}
.icon {
display: block;
width: 58%;
height: 58%;
}
.icon rect {
fill: var(--slate);
}
.pose-a rect {
transform-box: fill-box;
transform-origin: center;
}
/* Only ever visible under reduced motion, where the two poses cross-fade
instead of travelling. */
.pose-b {
opacity: 0;
}
.top {
animation: top var(--t) infinite;
}
.mid {
animation: mid var(--t) infinite;
}
.bot {
animation: bot var(--t) infinite;
}
@keyframes press {
0%,
8% {
transform: scale(1);
animation-timing-function: var(--ease-out);
}
11% {
transform: scale(0.955);
animation-timing-function: var(--ease-out);
}
16%,
55% {
transform: scale(1);
animation-timing-function: var(--ease-out);
}
58% {
transform: scale(0.955);
animation-timing-function: var(--ease-out);
}
63%,
100% {
transform: scale(1);
}
}
/* Two stages, 144ms each, not one 288ms move. Travelling and rotating at
the same time is the version everyone ships and it reads as a scribble:
the bars arrive at the centre already turning, so there is no frame in
which the menu is legibly gone and the cross is not yet there. Converge
first, then cross, and the X looks built out of the menu. The 0.78 on
scaleX pulls the 18-unit bar back to a 14-unit arm, which is what keeps
the diagonal from outgrowing the bars it came from. */
@keyframes top {
0%,
11% {
transform: translateY(0) rotate(0deg) scaleX(1);
animation-timing-function: var(--ease-in-out);
}
15% {
transform: translateY(5.9px) rotate(0deg) scaleX(1);
animation-timing-function: var(--ease-accent);
}
19%,
58% {
transform: translateY(5.9px) rotate(45deg) scaleX(0.78);
animation-timing-function: var(--ease-accent);
}
62% {
transform: translateY(5.9px) rotate(0deg) scaleX(1);
animation-timing-function: var(--ease-in-out);
}
66%,
100% {
transform: translateY(0) rotate(0deg) scaleX(1);
}
}
@keyframes bot {
0%,
11% {
transform: translateY(0) rotate(0deg) scaleX(1);
animation-timing-function: var(--ease-in-out);
}
15% {
transform: translateY(-5.9px) rotate(0deg) scaleX(1);
animation-timing-function: var(--ease-accent);
}
19%,
58% {
transform: translateY(-5.9px) rotate(-45deg) scaleX(0.78);
animation-timing-function: var(--ease-accent);
}
62% {
transform: translateY(-5.9px) rotate(0deg) scaleX(1);
animation-timing-function: var(--ease-in-out);
}
66%,
100% {
transform: translateY(0) rotate(0deg) scaleX(1);
}
}
/* The middle bar goes out during the converge stage, not the cross — it
has to be gone before anything reaches the centre, or the X is drawn
over a bar that is still there. It shrinks to a sliver rather than
fading in place, so it reads as being absorbed. */
@keyframes mid {
0%,
11% {
transform: scaleX(1);
opacity: 1;
animation-timing-function: var(--ease-in-out);
}
15%,
62% {
transform: scaleX(0.05);
opacity: 0;
animation-timing-function: var(--ease-in-out);
}
66%,
100% {
transform: scaleX(1);
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
/* Gentler, not absent. The travel and the rotation both go; the two
states stay, cross-fading on exactly the same beats so the rhythm is
unchanged and only the mechanism differs. */
.chip {
animation: none;
}
.pose-a rect {
animation: none;
transform: none;
opacity: 1;
}
.pose-a {
animation: cross-out calc(var(--t) * 1.8) ease infinite;
}
.pose-b {
animation: cross-in calc(var(--t) * 1.8) ease infinite;
}
@keyframes cross-out {
0%,
11% {
opacity: 1;
}
19%,
58% {
opacity: 0;
}
66%,
100% {
opacity: 1;
}
}
@keyframes cross-in {
0%,
11% {
opacity: 0;
}
19%,
58% {
opacity: 1;
}
66%,
100% {
opacity: 0;
}
}
}
</style>
</head>
<body>
<div class="chip" role="img" aria-label="A menu icon folding into a close icon">
<svg class="icon" viewBox="0 0 24 24">
<g class="pose-a">
<rect class="top" x="3" y="5" width="18" height="2.2" rx="1.1" />
<rect class="mid" x="3" y="10.9" width="18" height="2.2" rx="1.1" />
<rect class="bot" x="3" y="16.8" width="18" height="2.2" rx="1.1" />
</g>
<g class="pose-b">
<rect x="5" y="10.9" width="14" height="2.2" rx="1.1" transform="rotate(45 12 12)" />
<rect x="5" y="10.9" width="14" height="2.2" rx="1.1" transform="rotate(-45 12 12)" />
</g>
</svg>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.