Back to the library
Icon morph — sun to moon
Variant: Sun to moon
The theme toggle. Eight rays retract into the disc 18ms apart around the dial rather than all at once — eight simultaneous exits read as the sun switching off, a cascade reads as it closing down — and the crescent is bitten out by a masked circle sliding in from the upper right, an occlusion rather than a second icon cross-faded in. The disc travels clay to slate on the same beat: the geometry says a change happened, the colour says which way.
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 — sun to moon</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. Same beat map as
the rest of the family: press at 8%, morph 11-19%, return 58-66%. */
--cycle: 3.6s;
--t: calc(var(--cycle) / var(--speed));
--ease-out: cubic-bezier(0.23, 1, 0.32, 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);
}
.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%;
}
/* Dash 2.6 (the ray's whole length) then a 6-unit gap, so the pattern
never wraps back round and leaves a sliver at the far end — a bare
`stroke-dasharray: 2.6` repeats every 5.2 and does exactly that. */
.ray {
fill: none;
stroke: var(--clay);
stroke-width: 2;
stroke-linecap: round;
stroke-dasharray: 2.6 6;
animation: retract var(--t) infinite;
animation-delay: calc(var(--_i) * 0.018s / var(--speed));
}
.disc {
fill: var(--clay);
animation: warm var(--t) infinite;
}
.bite {
animation: bite var(--t) infinite;
}
/* Only ever visible under reduced motion. */
.pose-b {
fill: var(--slate);
opacity: 0;
}
@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);
}
}
/* Each ray retracts into the disc rather than fading, and they go 18ms
apart around the dial instead of together: eight simultaneous exits read
as the sun switching off, a cascade reads as it closing down. The path
is authored inner point first, so a rising dashoffset eats the outer end
and the light appears to be pulled in. */
@keyframes retract {
0%,
11% {
stroke-dashoffset: 0;
animation-timing-function: var(--ease-out);
}
19%,
58% {
stroke-dashoffset: 2.8;
animation-timing-function: var(--ease-out);
}
66%,
100% {
stroke-dashoffset: 0;
}
}
/* The crescent is bitten out by a masked circle sliding in from the upper
right — an occlusion, not a second icon cross-faded in, which is why the
disc keeps one continuous edge the whole way across instead of two
outlines briefly overlapping. Parked at 9.6 the bite is 13.6 from the
disc centre, clear of the 12 where the two circles would first touch. */
@keyframes bite {
0%,
11% {
transform: translate(9.6px, -9.6px);
animation-timing-function: var(--ease-accent);
}
19%,
58% {
transform: translate(3.4px, -3.4px);
animation-timing-function: var(--ease-accent);
}
66%,
100% {
transform: translate(9.6px, -9.6px);
}
}
/* Clay to slate on the same beat. This is the part that actually says
which theme you are heading for — the geometry says a change happened,
the colour says which way. */
@keyframes warm {
0%,
11% {
fill: var(--clay);
animation-timing-function: ease;
}
19%,
58% {
fill: var(--slate);
animation-timing-function: ease;
}
66%,
100% {
fill: var(--clay);
}
}
@media (prefers-reduced-motion: reduce) {
/* Gentler, not absent. The rays stop retracting and the bite stays
parked; the two poses cross-fade on the beats the morph was using, so
the colour change — the part carrying the meaning — survives intact. */
.chip {
animation: none;
}
.ray {
animation: none;
stroke-dashoffset: 0;
}
.disc {
animation: none;
}
.bite {
animation: none;
transform: translate(9.6px, -9.6px);
}
.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 sun icon closing down into a crescent moon">
<svg class="icon" viewBox="0 0 24 24">
<mask id="bite-mask" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
<rect x="0" y="0" width="24" height="24" fill="#fff" />
<circle class="bite" cx="12" cy="12" r="6.6" fill="#000" />
</mask>
<g class="pose-a">
<g class="rays">
<line class="ray" style="--_i: 0" x1="12" y1="4.2" x2="12" y2="1.6" />
<line
class="ray"
style="--_i: 1"
x1="12"
y1="4.2"
x2="12"
y2="1.6"
transform="rotate(45 12 12)"
/>
<line
class="ray"
style="--_i: 2"
x1="12"
y1="4.2"
x2="12"
y2="1.6"
transform="rotate(90 12 12)"
/>
<line
class="ray"
style="--_i: 3"
x1="12"
y1="4.2"
x2="12"
y2="1.6"
transform="rotate(135 12 12)"
/>
<line
class="ray"
style="--_i: 4"
x1="12"
y1="4.2"
x2="12"
y2="1.6"
transform="rotate(180 12 12)"
/>
<line
class="ray"
style="--_i: 5"
x1="12"
y1="4.2"
x2="12"
y2="1.6"
transform="rotate(225 12 12)"
/>
<line
class="ray"
style="--_i: 6"
x1="12"
y1="4.2"
x2="12"
y2="1.6"
transform="rotate(270 12 12)"
/>
<line
class="ray"
style="--_i: 7"
x1="12"
y1="4.2"
x2="12"
y2="1.6"
transform="rotate(315 12 12)"
/>
</g>
<circle class="disc" cx="12" cy="12" r="5.4" mask="url(#bite-mask)" />
</g>
<!-- The static crescent: the same two circles, resolved by hand at their
two intersection points so the reduced-motion pose needs no mask. -->
<path
class="pose-b"
d="M16.41 15.12 A5.4 5.4 0 1 1 8.88 7.6 A6.6 6.6 0 0 0 16.41 15.12 Z"
/>
</svg>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.