TransitionsFree

Dark Mode

The light theme is a full layer laid over the dark one, uncovered by a circle that starts from the center of the switch and covers the scene in 0.6 s; a second press closes it back into the button. CSS for the transition, 0.8 KB of JavaScript for the button, a 150 ms fade when the user reduces motion.

CSSclip-pathToggle

Updated

Dark Mode

Dark theme active

Hover or click the scene to interact

This effect is free — the Transitions category contains 52 effects total, including 9 free. Effect.Labs has 840 vanilla effects. Explore the category →

Usage examples

800 mExit 14 — Orléans-Centre
87km/h
90
Arrival 9:42 pm · 186 kmBreak due in 1 h 05

① Switching to the night map — a truck driver's GPS

WhenAt sunset on the A10 highway, the driver taps the button at the top right of the screen: the day map, with exit 14 in 800 m and the mandatory break due in 1 h 05, turns into the night map.
WhyThe new theme starts from the button that was tapped: the driver sees where the change comes from without looking for it, and the map, the road and the bottom banner stay exactly in place, because both themes are two layers overlapping to the pixel.
SettingsReversed direction: the day map lives in .th-circular-dark (bottom layer), the night map in .th-circular-light (revealed layer), moon icon at rest; scene enlarged to 496 × 316 px, the same SVG drawing in both layers, colors per layer (blue route by day, amber at night).
CHART · 1:50,000
Speed over ground6.2 kn
Course over ground128°
Depth18.4 m
Port-Joinville11.8 NM · ETA 7:50 pm
⟵ 1 M ⟶

② Night lighting for the navigation screen — a cruising sailboat's chartplotter

WhenAt nightfall, heading for Île d'Yeu: the light day screen, with the nautical chart and the speed over ground, switches to a red-on-black screen that preserves the helmsman's night vision.
WhyThe circle starts from the button that was tapped, and a full layer arrives: nothing flashes white during the switch, which matters when the eyes have already adapted to the dark.
SettingsRevealed layer in #050505, lines and figures in red #ff4a3d and #7a1d16; 496 × 316 px scene; data panel on the right placed below the button (top 58 px) so it does not hide it.
LE MÉLIÈS

October Tides

Fri 31 Oct · 8:45 pm · original version

Screen3
RowF
Seat12

1 seat · full price · €9.50

Le Méliès · screen 3

Dark auditorium mode

Once seated, one tap on the switch: the dark ticket spreads from the button, and the QR code stays readable on its dimmed tile.

Screen

③ Ticket in dark auditorium mode — an independent movie theater's app

WhenThe moviegoer takes seat 12, row F, screen 3: one tap on the switch and the light ticket shown in the lobby becomes a dark ticket that no longer dazzles the neighbors.
WhyThe reveal starts from the corner of the phone where the thumb is and stays gentle; the QR code keeps a dimmed gray background (#bdbdc4) in the dark theme, so the usher can still scan it.
SettingsPhone-shaped scene, 214 × 332 px; light ticket in the bottom layer, dark ticket revealed; QR plate dimmed rather than inverted, ticket text in #a8a29e gray.

How it works

The scene .th-circular-scene (300 × 220 px, 16 px corners, overflow: hidden) stacks two layers of its exact size with position: absolute; inset: 0: .th-circular-dark (background #0f172a, z-index 1) and, on top, .th-circular-light (background #f8fafc, z-index 2). Each carries its own version of the content — here a card with a title, a line of text and a progress bar. The .th-circular-toggle button, 36 px, 12 px from the top and right, sits above both layers (z-index 10): it does not move during the switch, only its icon changes, sun over the dark theme, moon over the light one.

At rest, the light layer is clipped to clip-path: circle(0% at calc(100% - 30px) 30px): a zero-radius circle centered exactly on the button (30 px = 12 px margin + 18 px half-button), whatever size you give the scene. The .active class takes it to circle(150% …) with transition: clip-path 0.6s cubic-bezier(0.4, 0, 0.2, 1). Measured 300 ms after the click: a radius of 118.7% in Chromium 151, 110.7% in Firefox 153, 115.4% in WebKit 26; the corner farthest from the button is at 125% (330 px), so the scene is fully light at about 0.35 s and the tail of the curve slows down out of view. A circle() percentage refers to the box diagonal divided by √2: from any inside point, 150% always covers everything, even if you stretch the scene.

The script (0.8 KB, no dependency) walks through every .th-circular-scene on the page: several scenes coexist with no id and no global function. On click, it toggles .active, updates aria-pressed and alternates aria-hidden between the two layers, so a screen reader only reads the theme on screen. Removing .active plays the same transition backwards: the circle closes into the button (measured 34 to 46% radius 300 ms after the second click, 0% at 1.5 s). Two full layers rather than a color change is the principle itself: for a circle to separate the old theme from the new one, both must be painted at the same time. The effect does not read prefers-color-scheme and does not remember the choice: it always starts dark (see the FAQ to add both).

Accessibility

  • Button: a real <button> named 'Light theme' (aria-label), with aria-pressed set to true when the light theme shows. Measured: reached with the Tab key in Chromium, toggled by Enter and by Space in Chromium, Firefox and WebKit; a 2 px violet outline on keyboard focus (:focus-visible).
  • Duplicated content: both layers carry the same text. The script sets aria-hidden="true" on the hidden one, so a screen reader announces only one version. If you add links or fields inside the layers, also make the hidden layer inert (the inert attribute), otherwise the Tab key will enter it.
  • Reduced motion: under prefers-reduced-motion: reduce, no more circle (clip-path: none); the light layer appears with a 150 ms opacity fade. Measured: opacity 0.39 (Chromium) and 0.33 (Firefox) 60 ms after the click, 1 at 300 ms.
  • Contrast: on the dark theme, white title at 15.7:1 and the 50% white line at 4.97:1; on the light theme, #0f172a title at 17.1:1 and #64748b line at 4.6:1. Keep these two secondary colors if you change the backgrounds.

Browser compatibility

CSS (animated clip-path: circle(), calc() in the circle position, :focus-visible) + 0.8 KB of JavaScript, no dependency. Measured in Chromium 151, Firefox 153 and WebKit 26: layers overlapping to the pixel, circle centered on the button, same round trip, same keys. Unprefixed clip-path with basic shapes and transitions exists since Chrome 55, Firefox 54 and Safari 12.1. The minimum versions come from inset: 0, which stacks the layers: Chrome and Edge 87, Firefox 66, Safari 14.1 (iOS 14.5). :focus-visible dates from Chrome 86, Firefox 85 and Safari 15.4; before that, the browser keeps its default focus ring, which the effect does not remove.

Chrome 87+✓ Full
Firefox 66+✓ Full
Safari 14.1+✓ Full
Edge 87+✓ Full
Mobile iOS 14.5+✓ Full
Android Chrome 87+✓ Full

Without clip-path (a very old browser), the light layer is never clipped: the scene stays light all the time and only the button icon changes. Without JavaScript, the scene stays in its dark theme and the button does nothing; you can put the active class in the HTML to serve the light theme directly.

The code

Copy the three blocks into your page. No dependencies.

HTML
<div class="th-circular-root">
  <div class="th-circular-scene">
    <button class="th-circular-toggle" type="button" aria-pressed="false" aria-label="Light theme">
      <svg class="th-circular-sun" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
      <svg class="th-circular-moon" viewBox="0 0 24 24" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
    </button>
    <div class="th-circular-dark">
      <div class="th-circular-card">
        <h3>Dark Mode</h3>
        <p>Dark theme active</p>
        <div class="th-circular-card-bar"><div class="th-circular-card-bar-fill"></div></div>
      </div>
    </div>
    <div class="th-circular-light" aria-hidden="true">
      <div class="th-circular-card">
        <h3>Light Mode</h3>
        <p>Light theme active</p>
        <div class="th-circular-card-bar"><div class="th-circular-card-bar-fill"></div></div>
      </div>
    </div>
  </div>
</div>
CSS
.th-circular-root,
.th-circular-root *,
.th-circular-root *::before,
.th-circular-root *::after {
  box-sizing: border-box
}

.th-circular-root {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 250px;
  overflow: hidden;
  position: relative;
  background: rgb(26, 26, 36);
  color: rgb(255, 255, 255);
  font-family: sans-serif;
}

.th-circular-scene {
  width: 300px;
  height: 220px;
  position: relative;
  border-radius: 16px;
  overflow: hidden;
}

/* Les deux thèmes sont deux calques superposés de la taille de la scène */
.th-circular-dark,
.th-circular-light {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
}

.th-circular-dark {
  background: #0f172a;
  z-index: 1;
}

/* Le clair attend, réduit à un cercle de rayon 0 centré sur le bouton (30 px du bord haut et du bord droit) */
.th-circular-light {
  background: #f8fafc;
  z-index: 2;
  clip-path: circle(0% at calc(100% - 30px) 30px);
  transition: clip-path 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}

.th-circular-scene.active .th-circular-light {
  clip-path: circle(150% at calc(100% - 30px) 30px);
}

.th-circular-dark h3 {
  color: #fff;
  font-size: 1rem;
  font-weight: 700;
  margin: 0;
}

.th-circular-dark p {
  color: rgba(255, 255, 255, 0.5);
  font-size: 0.75rem;
  margin: 0;
}

.th-circular-light h3 {
  color: #0f172a;
  font-size: 1rem;
  font-weight: 700;
  margin: 0;
}

.th-circular-light p {
  color: #64748b;
  font-size: 0.75rem;
  margin: 0;
}

.th-circular-toggle {
  position: absolute;
  top: 12px;
  right: 12px;
  width: 36px;
  height: 36px;
  border-radius: 50%;
  border: none;
  padding: 0;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 10;
  background: rgba(255, 255, 255, 0.1);
  transition: background-color 0.3s ease;
}

.th-circular-scene.active .th-circular-toggle {
  background: rgba(0, 0, 0, 0.08);
}

.th-circular-toggle:focus-visible {
  outline: 2px solid #a78bfa;
  outline-offset: 2px;
}

.th-circular-toggle svg {
  width: 18px;
  height: 18px;
  stroke-width: 2;
  fill: none;
}

.th-circular-sun {
  stroke: #fbbf24;
}

.th-circular-moon {
  stroke: #6366f1;
  display: none;
}

.th-circular-scene.active .th-circular-sun {
  display: none;
}

.th-circular-scene.active .th-circular-moon {
  display: block;
}

.th-circular-card {
  width: 220px;
  padding: 16px;
  border-radius: 12px;
}

.th-circular-dark .th-circular-card {
  background: rgba(255, 255, 255, 0.05);
  border: 1px solid rgba(255, 255, 255, 0.08);
}

.th-circular-light .th-circular-card {
  background: #fff;
  border: 1px solid #e2e8f0;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}

.th-circular-card-bar {
  height: 6px;
  border-radius: 3px;
  margin-top: 10px;
}

.th-circular-dark .th-circular-card-bar {
  background: rgba(255, 255, 255, 0.08);
}

.th-circular-light .th-circular-card-bar {
  background: #e2e8f0;
}

.th-circular-card-bar-fill {
  height: 100%;
  width: 65%;
  border-radius: 3px;
  background: linear-gradient(90deg, #8b5cf6, #a78bfa);
}

/* Mouvement réduit : pas de cercle qui grandit, un fondu de 150 ms */
@media (prefers-reduced-motion: reduce) {
  .th-circular-light,
  .th-circular-scene.active .th-circular-light {
    clip-path: none;
  }
  .th-circular-light {
    opacity: 0;
    transition: opacity 0.15s linear;
  }
  .th-circular-scene.active .th-circular-light {
    opacity: 1;
  }
}
JavaScript (fx-0705)
(function () {
  /* fx-0705 Dark Mode : le bouton de chaque .th-circular-scene pose ou retire .active (le CSS fait la transition) */
  document.querySelectorAll('.th-circular-scene').forEach(function (scene) {
    var btn = scene.querySelector('.th-circular-toggle');
    var dark = scene.querySelector('.th-circular-dark');
    var light = scene.querySelector('.th-circular-light');
    if (!btn) return;
    function sync() {
      var on = scene.classList.contains('active');
      btn.setAttribute('aria-pressed', on ? 'true' : 'false');
      if (dark) dark.setAttribute('aria-hidden', on ? 'true' : 'false');
      if (light) light.setAttribute('aria-hidden', on ? 'false' : 'true');
    }
    btn.addEventListener('click', function () {
      scene.classList.toggle('active');
      sync();
    });
    sync();
  });
})();

Customize

Options passed to the API or data-* attributes:

Option / propertyDefaultEffect
Duration and curve (CSS — transition on .th-circular-light) 0.6s cubic-bezier(0.4, 0, 0.2, 1) The scene is covered at about 0.35 s, the rest of the curve plays out of view. 0.9 s gives a more solemn switch; under 0.4 s, the eye no longer sees the circle leave the button.
Starting point (CSS — at calc(100% - 30px) 30px) button center: 30 px from the right edge, 30 px from the top If you move the button, change this position in both rules (rest and .active): on the left, at 30px 30px; in the center, at 50% 50%. The circle should always come out of the element that was pressed.
Final radius (CSS — circle(150% …)) 150% From any inside point, 141% is always enough (the percentage refers to the diagonal divided by √2). Lowering it to 125% shortens the invisible part of the transition without uncovering a corner.
Colors of both themes (CSS — background of .th-circular-dark and .th-circular-light, then their h3, p, cards) #0f172a and #f8fafc Each layer has its own text rules: change them together to keep at least 4.5:1 on the secondary line.
Switch direction (HTML — content of the two layers) dark below, light revealed To go from light to dark, put the light version in the bottom layer and the dark one in the revealed layer, and swap the two button icons: the circle then uncovers the night.
Reduced motion (CSS — @media (prefers-reduced-motion: reduce) block) 150 ms opacity fade Use transition: none for an instant change; do not put the circle back in this block.

FAQ

Does the effect follow the system theme and remember the visitor's choice?

No. It always starts from the dark theme, does not read prefers-color-scheme and stores nothing. For both, a few lines in the script are enough, before the first paint: var choice = localStorage.getItem('theme'); if (choice ? choice === 'light' : matchMedia('(prefers-color-scheme: light)').matches) { scene.classList.add('active'); sync(); }, then localStorage.setItem('theme', scene.classList.contains('active') ? 'light' : 'dark') in the click handler, after sync(). Set the class before the scene shows (in the server-side HTML, or with transition: none during loading), otherwise the circle plays when the page opens.

Why two full layers rather than a color change?

Because a circle separating the old theme from the new one needs both painted at the same time: the top layer is simply clipped. The price is duplicated content, which the script hides from screen readers with aria-hidden. For a whole page, that duplication gets heavy: the View Transitions API (see View Transitions in this category) captures the old page as an image and can play the same circle on the real page, without duplicating the DOM.

Dark Mode or Wipe Transition (fx-0707) for a theme switch?

Both overlap two full layers and clip one of them. Here the new theme grows as a circle from the button, which ties the gesture to the change; Wipe Transition moves it from left to right with a glowing line on the border, which shows before and after side by side for 0.6 s. Choose Dark Mode when the switch is visible and well placed, Wipe Transition when the change has no point of origin (a setting in a menu, a scheduled time).