Icon morph blueprint
Sun ↔ Moon
A theme control makes the mode change visible through a calm celestial transformation.
Build it
Choose a target and adapt the source to your interface.
<div class="theme-demo" data-dark="false">
<button
class="theme-control"
type="button"
aria-label="Dark appearance"
aria-pressed="false"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<defs>
<mask
id="moon-cutout"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="24"
height="24"
>
<rect width="24" height="24" fill="white" />
<circle class="theme-cutout" cx="24" cy="0" r="5" fill="black" />
</mask>
</defs>
<circle
cx="12"
cy="12"
r="5"
fill="currentColor"
mask="url(#moon-cutout)"
/>
<path
class="theme-rays"
d="M12 2v2m0 16v2M2 12h2m16 0h2M5 5l1.5 1.5m11 11L19 19M5 19l1.5-1.5m11-11L19 5"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
/>
</svg>
</button>
</div>
<script>
const button = document.querySelector('.theme-control');
button.addEventListener('click', () => {
const dark = button.getAttribute('aria-pressed') !== 'true';
button.setAttribute('aria-pressed', String(dark));
document.querySelector('.theme-demo').dataset.dark = String(dark);
});
</script>
<style>
.theme-demo {
display: inline-flex;
padding: 24px;
border-radius: 8px;
background: #f4f6f9;
color: #20252d;
transition:
background 360ms ease-out,
color 360ms ease-out;
}
.theme-demo[data-dark='true'] {
background: #19222f;
color: #f4f6f9;
}
.theme-control {
display: grid;
place-items: center;
width: 44px;
height: 44px;
padding: 10px;
border: 0;
border-radius: 6px;
background: transparent;
color: inherit;
cursor: pointer;
}
.theme-control svg {
width: 24px;
height: 24px;
}
.theme-rays {
transform-origin: 12px 12px;
transition:
transform 160ms ease-out,
opacity 160ms ease-out;
}
.theme-cutout {
transition: transform 300ms cubic-bezier(0.32, 0.72, 0, 1);
}
.theme-control[aria-pressed='true'] .theme-rays {
transform: scale(0.6) rotate(45deg);
opacity: 0;
}
.theme-control[aria-pressed='true'] .theme-cutout {
transform: translate(-7px, 7px);
}
.theme-control:focus-visible {
outline: 2px solid currentColor;
outline-offset: 4px;
}
@media (prefers-reduced-motion: reduce) {
.theme-demo,
.theme-rays,
.theme-cutout {
transition: none;
}
}
</style>Animation breakdown
Frame phases, motion intent, implementation constraints and accessible alternatives.
Rest: circular sun with eight short rays.
0–160ms: rays retract and the disc shifts.
160–300ms: a dark mask creates the crescent.
300–360ms: the theme surface crossfades.
Motion intent
A theme control makes the mode change visible through a calm celestial transformation.
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
- The colour theme changes.
- Timing
- 360ms
- Easing
- cubic-bezier(.32, .72, 0, 1)
Animated properties
Implementation notes
- Keep sun and moon 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 sun and moon 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.