Icon Toggle
A sun and a moon stacked in the center of a 72 px round button: on click, one turns from −90° to 0° while growing, the other leaves at 90° while shrinking, in 0.5 s, and the label below changes its text. It is the switch's micro-interaction, not the page switch; 0.6 KB of JavaScript, a 150 ms fade with no rotation when the user reduces motion.
Updated
This effect is part of Effect.Labs — 840 vanilla effects, some free, some premium. Transitions has 52 effects, including 9 free. Explore the category →
3 usage examples



How it works
The .th-icon-btn button is a real 72 px round <button>, 5% white background and a 2 px 10% white border. It holds two 32 px stroke-only SVG icons (fill: none, 1.5 width): .th-icon-sun, in #fbbf24, and .th-icon-moon, in #94a3b8. Both are position: absolute in the same spot, centered by the flex button: one replaces the other with nothing shifting. At rest (dark theme), the moon is upright and full (rotate(0deg) scale(1), opacity 1); the sun waits turned −90°, half size and invisible.
The .active class swaps the roles: the sun comes to rotate(0deg) scale(1) and opacity 1, the moon leaves to rotate(90deg) scale(.5) and opacity 0, both with transition: opacity .5s, transform .5s on the cubic-bezier(.4, 0, .2, 1) curve. The two icons therefore turn in the same direction, clockwise, like a disc rotating a quarter turn: one fades out as it turns while the other arrives. The button takes a 10% amber background and a 30% amber border (background-color .5s, border-color .5s). Measured 300 ms after the click: sun at 0.89 opacity in Chromium 151 and WebKit 26, 0.88 in Firefox 153, turned about −10°; everything is in place at 0.5 s, and the second click plays the quarter turn backwards.
The script (0.6 KB) arms every .th-icon-btn on the page, with no id and no global function: on click, it toggles .active, updates aria-pressed and writes into the neighboring .th-icon-label (in the same .th-icon-scene) the text of its data-on or data-off attribute — 'Light mode' / 'Dark mode' here. Labels thus live in the HTML, translatable without touching the script. The effect animates the button and its label, and nothing else: it does not change the page theme, does not read prefers-color-scheme and remembers nothing. Hook your own theme change onto the same click (or read aria-pressed).
Accessibility
- Button: a
<button type="button">named 'Light mode' (aria-label, the name does not change) witharia-pressedset totruewhen the sun shows; both icons arearia-hidden. Measured: reached with the Tab key in Chromium, toggled by Enter and Space in Chromium, Firefox and WebKit; a 2 px violet outline on keyboard focus. - Reduced motion: under
prefers-reduced-motion: reduce, no more rotation or scaling (transform: none), the two icons hand over with a 150 ms opacity fade. Measured: sun at 0.39 (Chromium) and 0.33 (Firefox) opacity 60 ms after the click, 1 at 300 ms, with no transform. - Contrast: label in 50% white on
#0a0a0fat 5.3:1; the icons, as graphics, exceed 3:1 on the button background (amber#fbbf24and gray#94a3b8on black). The text label doubles the icon: the state does not rely on the sun or moon shape alone.
Browser compatibility
CSS (transform and opacity transitions, :focus-visible) + 0.6 KB of JavaScript (closest(), data- attributes), no dependency. Measured in Chromium 151, Firefox 153 and WebKit 26: the same values at 60 and 300 ms within a few hundredths, same way back, French and English labels. transform transitions are universal; closest() exists since Chrome 41, Firefox 35 and Safari 6; :focus-visible since Chrome 86, Firefox 85 and Safari 15.4 (before that, the default focus ring stays). The minimum versions come from gap in flex boxes: Chrome and Edge 84, Firefox 63, Safari 14.1 (iOS 14.5); before that, the items touch.
Without JavaScript, the button stays on the moon with the 'Dark mode' label and does not react; if your page switches theme on the server, set active and aria-pressed="true" in the HTML to show the sun directly.
The code
HTML structure to paste into your page (the CSS + JS come with Premium):
<div class="th-icon-root">
<div class="th-icon-scene">
<button class="th-icon-btn" type="button" aria-pressed="false" aria-label="Light mode">
<span class="th-icon-sun" aria-hidden="true"><svg>…</svg></span>
<span class="th-icon-moon" aria-hidden="true"><svg>…</svg></span>
</button>
<span class="th-icon-label" data-on="Light mode" data-off="Dark mode">Dark mode</span>
</div>
</div>
The full HTML + CSS + JS of Icon Toggle, ready to paste — plus all 840 effects. Or $299 once, lifetime access.
Customize
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
Duration and curve (CSS — transition on .th-icon-sun / .th-icon-moon) |
.5s cubic-bezier(.4, 0, .2, 1) | 0.35 s for a snappy switch; beyond 0.7 s, users wait for the end before moving on. |
Exit angle and scale (CSS — rotate(-90deg) scale(.5) and rotate(90deg) scale(.5)) |
quarter turn, half size | 180° gives a more striking spin; scale(0) makes the icon vanish into the center. Keep opposite signs so both turn the same way. |
Size (CSS — .th-icon-btn and svg) |
72 px and 32 px | Keep the icon at about 45% of the button: 42 / 22 px in a list, 88 / 38 px for a wall screen. |
Labels (HTML — data-on and data-off on .th-icon-label) |
'Light mode' / 'Dark mode' | Put the real state there ('Night heating · 17 °C'); remove .th-icon-label if the button stands alone, the script does without it. Update aria-label too. |
Colors (CSS — icon stroke, background and border of .th-icon-btn.active) |
sun #fbbf24, moon #94a3b8 |
On a light background, darken both strokes (#d97706, #475569) to keep 3:1. |
Reduced motion (CSS — @media (prefers-reduced-motion: reduce) block) |
150 ms fade, no rotation | transition: none for an instant swap. |
FAQ
aria-pressed and its label, nothing else. To change the theme, add your own code to the same click, for instance document.documentElement.classList.toggle('light', btn.classList.contains('active')), or pair it with another switch from this category. The effect does not read prefers-color-scheme either and remembers nothing: if the page starts light, set active and aria-pressed="true" in the HTML.position: absolute, the button keeps its size and nothing around it moves..th-icon-btn it finds and looks for the label in the surrounding .th-icon-scene, with no id. A list of forty streets with one button each works as is; keep one .th-icon-scene per button-label pair.