06 / 20
All 20 in 20 Tailwind Liquid Glass Effects
- 01Tailwind Liquid Glass Card with Hover Refraction
- 02Tailwind Liquid Glass Button with Glossy Highlight
- 03Tailwind Liquid Glass Navigation Bar
- 04Tailwind Liquid Gooey Toggle Switch
- 05Tailwind Liquid Glass Modal Dialog
- 06Tailwind Liquid Glass Theme Switcher
- 07Tailwind Liquid Glass Input Form Fields
- 08Tailwind SVG Displacement Liquid Glass Background
- 09Tailwind Liquid Glass Dropdown Menu
- 10Tailwind Liquid Glass Pricing Table
- 11Tailwind Liquid Glass Tooltip Popover
- 12Tailwind Liquid Glass Audio Player UI
- 13Tailwind Liquid Glass Range Slider
- 14Tailwind Liquid Glass Toast Notification
- 15Tailwind Liquid Glass Sidebar Navigation
- 16Tailwind Liquid Glass Accordion
- 17Tailwind Liquid Glass Login Card
- 18Tailwind Liquid Glass Floating Action Button
- 19Tailwind Liquid Glass Loading Skeleton
- 20Tailwind Liquid Glass Multi-Step Form Wizard
Tailwind Liquid Glass Theme Switcher
A dark/light toggle that releases a liquid glass ripple across the scene as it flips. In the port the entire theme lives in six custom properties re-pointed by one data-theme rule, so every utility in the markup — bg-lgt06-surface, text-lgt06-fg, border-lgt06-rim — changes meaning at once and not a single class is duplicated per theme.
Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time
Untitled UI React · Affiliate link
The code
<section id="lgt-06-root" data-theme="dark" class="lgt-06 grid w-full min-h-svh place-items-center bg-lgt06-bg p-15 font-lgt06-sans" aria-label="Tailwind liquid glass theme switcher">
<div class="relative w-[min(400px,92vw)] rounded-[26px] border border-lgt06-rim bg-lgt06-surface p-8.5 text-lgt06-fg backdrop-blur-[20px] backdrop-saturate-170 shadow-[inset_0_1px_0_var(--color-lgt06-rim),0_30px_60px_-30px_rgba(0,0,0,.5)]">
<h3 id="lgt-06-title" class="mb-2 text-[26px] font-extrabold">Good evening</h3>
<p class="mb-5.5 text-[15px] leading-relaxed text-lgt06-muted">Toggle the switch — a liquid glass ripple sweeps the new theme across the scene.</p>
<button id="lgt-06-switch" type="button" role="switch" aria-checked="false" aria-label="Toggle light mode" class="group relative h-9.5 w-19 cursor-pointer rounded-full border border-lgt06-rim bg-lgt06-track p-0 backdrop-blur-[8px] shadow-[inset_0_2px_8px_rgba(0,0,0,.3)] focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-lgt06-fg">
<span id="lgt-06-knob" class="absolute top-0.75 left-0.75 grid size-8 place-items-center rounded-full bg-linear-to-b from-white to-white/70 shadow-[0_4px_10px_rgba(0,0,0,.35),inset_0_1px_0_#fff] transition-[left] duration-400 ease-lgt06 group-aria-checked:left-[41px] motion-reduce:transition-none">
<span aria-hidden="true" class="absolute text-base text-lgt06-moon opacity-100 transition-opacity duration-300 group-aria-checked:opacity-0">☾</span>
<span aria-hidden="true" class="absolute text-base text-lgt06-sun opacity-0 transition-opacity duration-300 group-aria-checked:opacity-100">☀</span>
</span>
</button>
</div>
</section><section id="lgt-06-root" data-theme="dark" class="lgt-06 grid w-full min-h-svh place-items-center bg-lgt06-bg p-15 font-lgt06-sans" aria-label="Tailwind liquid glass theme switcher">
<div class="relative w-[min(400px,92vw)] rounded-[26px] border border-lgt06-rim bg-lgt06-surface p-8.5 text-lgt06-fg backdrop-blur-[20px] backdrop-saturate-170 shadow-[inset_0_1px_0_var(--color-lgt06-rim),0_30px_60px_-30px_rgba(0,0,0,.5)]">
<h3 id="lgt-06-title" class="mb-2 text-[26px] font-extrabold">Good evening</h3>
<p class="mb-5.5 text-[15px] leading-relaxed text-lgt06-muted">Toggle the switch — a liquid glass ripple sweeps the new theme across the scene.</p>
<button id="lgt-06-switch" type="button" role="switch" aria-checked="false" aria-label="Toggle light mode" class="group relative h-9.5 w-19 cursor-pointer rounded-full border border-lgt06-rim bg-lgt06-track p-0 backdrop-blur-[8px] shadow-[inset_0_2px_8px_rgba(0,0,0,.3)] focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-lgt06-fg">
<span id="lgt-06-knob" class="absolute top-0.75 left-0.75 grid size-8 place-items-center rounded-full bg-linear-to-b from-white to-white/70 shadow-[0_4px_10px_rgba(0,0,0,.35),inset_0_1px_0_#fff] transition-[left] duration-400 ease-lgt06 group-aria-checked:left-[41px] motion-reduce:transition-none">
<span aria-hidden="true" class="absolute text-base text-lgt06-moon opacity-100 transition-opacity duration-300 group-aria-checked:opacity-0">☾</span>
<span aria-hidden="true" class="absolute text-base text-lgt06-sun opacity-0 transition-opacity duration-300 group-aria-checked:opacity-100">☀</span>
</span>
</button>
</div>
</section>@import "tailwindcss";
@theme {
--color-lgt06-bg: #12101f;
--color-lgt06-surface: rgb(255 255 255 / 0.12);
--color-lgt06-fg: #f2f0fa;
--color-lgt06-muted: rgb(255 255 255 / 0.75);
--color-lgt06-rim: rgb(255 255 255 / 0.35);
--color-lgt06-track: rgb(120 110 160 / 0.5);
--color-lgt06-moon: #3a3560;
--color-lgt06-sun: #e0a020;
--font-lgt06-sans: 'Manrope', system-ui, sans-serif;
--ease-lgt06: cubic-bezier(0.5, -0.3, 0.4, 1.4);
--animate-lgt06-ripple: lgt-06-ripple 0.6s ease-out forwards;
}
/* One rule re-points the whole palette. Every utility in the markup reads these,
so the entire scene flips with a single attribute and no duplicated classes. */
.lgt-06[data-theme="light"] {
--color-lgt06-bg: #dfe7f7;
--color-lgt06-surface: rgb(255 255 255 / 0.55);
--color-lgt06-fg: #161425;
--color-lgt06-muted: rgb(30 28 50 / 0.7);
--color-lgt06-rim: rgb(255 255 255 / 0.8);
--color-lgt06-track: rgb(255 255 255 / 0.6);
}
@keyframes lgt-06-ripple {
from {
transform: scale(0);
opacity: 0.9;
}
to {
transform: scale(1);
opacity: 0;
}
}@import "tailwindcss";
@theme {
--color-lgt06-bg: #12101f;
--color-lgt06-surface: rgb(255 255 255 / 0.12);
--color-lgt06-fg: #f2f0fa;
--color-lgt06-muted: rgb(255 255 255 / 0.75);
--color-lgt06-rim: rgb(255 255 255 / 0.35);
--color-lgt06-track: rgb(120 110 160 / 0.5);
--color-lgt06-moon: #3a3560;
--color-lgt06-sun: #e0a020;
--font-lgt06-sans: 'Manrope', system-ui, sans-serif;
--ease-lgt06: cubic-bezier(0.5, -0.3, 0.4, 1.4);
--animate-lgt06-ripple: lgt-06-ripple 0.6s ease-out forwards;
}
/* One rule re-points the whole palette. Every utility in the markup reads these,
so the entire scene flips with a single attribute and no duplicated classes. */
.lgt-06[data-theme="light"] {
--color-lgt06-bg: #dfe7f7;
--color-lgt06-surface: rgb(255 255 255 / 0.55);
--color-lgt06-fg: #161425;
--color-lgt06-muted: rgb(30 28 50 / 0.7);
--color-lgt06-rim: rgb(255 255 255 / 0.8);
--color-lgt06-track: rgb(255 255 255 / 0.6);
}
@keyframes lgt-06-ripple {
from {
transform: scale(0);
opacity: 0.9;
}
to {
transform: scale(1);
opacity: 0;
}
}(() => {
const root = document.querySelector('#lgt-06-root');
const sw = root.querySelector('#lgt-06-switch');
const knob = root.querySelector('#lgt-06-knob');
const title = root.querySelector('#lgt-06-title');
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
const apply = () => {
const next = root.dataset.theme === 'dark' ? 'light' : 'dark';
root.dataset.theme = next;
sw.setAttribute('aria-checked', String(next === 'light'));
title.textContent = next === 'light' ? 'Good morning' : 'Good evening';
};
const ripple = () => {
const r = knob.getBoundingClientRect();
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
const far = Math.hypot(Math.max(cx, innerWidth - cx), Math.max(cy, innerHeight - cy));
const el = document.createElement('span');
el.className = 'fixed z-9999 pointer-events-none rounded-full border border-lgt06-rim bg-lgt06-surface backdrop-blur-[26px] backdrop-saturate-180 animate-lgt06-ripple';
el.style.left = (cx - far) + 'px';
el.style.top = (cy - far) + 'px';
el.style.width = el.style.height = (far * 2) + 'px';
root.appendChild(el);
el.addEventListener('animationend', () => el.remove(), { once: true });
};
sw.addEventListener('click', () => {
if (reduce) { apply(); return; }
if (document.startViewTransition) { document.startViewTransition(apply); return; }
ripple();
setTimeout(apply, 180);
});
})();(() => {
const root = document.querySelector('#lgt-06-root');
const sw = root.querySelector('#lgt-06-switch');
const knob = root.querySelector('#lgt-06-knob');
const title = root.querySelector('#lgt-06-title');
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
const apply = () => {
const next = root.dataset.theme === 'dark' ? 'light' : 'dark';
root.dataset.theme = next;
sw.setAttribute('aria-checked', String(next === 'light'));
title.textContent = next === 'light' ? 'Good morning' : 'Good evening';
};
const ripple = () => {
const r = knob.getBoundingClientRect();
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
const far = Math.hypot(Math.max(cx, innerWidth - cx), Math.max(cy, innerHeight - cy));
const el = document.createElement('span');
el.className = 'fixed z-9999 pointer-events-none rounded-full border border-lgt06-rim bg-lgt06-surface backdrop-blur-[26px] backdrop-saturate-180 animate-lgt06-ripple';
el.style.left = (cx - far) + 'px';
el.style.top = (cy - far) + 'px';
el.style.width = el.style.height = (far * 2) + 'px';
root.appendChild(el);
el.addEventListener('animationend', () => el.remove(), { once: true });
};
sw.addEventListener('click', () => {
if (reduce) { apply(); return; }
if (document.startViewTransition) { document.startViewTransition(apply); return; }
ripple();
setTimeout(apply, 180);
});
})();Setup — do this once per project
Skip this if your project already runs Tailwind v4.
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>Compiles in the browser — perfect for a CodePen or a quick test, not for production.
npm install tailwindcss @tailwindcss/vite
npm install tailwindcss @tailwindcss/vite// vite.config.js ("type": "module" in package.json)
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})// vite.config.js ("type": "module" in package.json)
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})Any v4 setup works — Vite, PostCSS or the CLI. Nothing in this demo is bundler-specific.
@import "tailwindcss";
@theme {
--color-lgt06-bg: #12101f;
--color-lgt06-surface: rgb(255 255 255 / 0.12);
--color-lgt06-fg: #f2f0fa;
--color-lgt06-muted: rgb(255 255 255 / 0.75);
--color-lgt06-rim: rgb(255 255 255 / 0.35);
--color-lgt06-track: rgb(120 110 160 / 0.5);
--color-lgt06-moon: #3a3560;
--color-lgt06-sun: #e0a020;
--font-lgt06-sans: 'Manrope', system-ui, sans-serif;
--ease-lgt06: cubic-bezier(0.5, -0.3, 0.4, 1.4);
--animate-lgt06-ripple: lgt-06-ripple 0.6s ease-out forwards;
}
/* One rule re-points the whole palette. Every utility in the markup reads these,
so the entire scene flips with a single attribute and no duplicated classes. */
.lgt-06[data-theme="light"] {
--color-lgt06-bg: #dfe7f7;
--color-lgt06-surface: rgb(255 255 255 / 0.55);
--color-lgt06-fg: #161425;
--color-lgt06-muted: rgb(30 28 50 / 0.7);
--color-lgt06-rim: rgb(255 255 255 / 0.8);
--color-lgt06-track: rgb(255 255 255 / 0.6);
}
@keyframes lgt-06-ripple {
from {
transform: scale(0);
opacity: 0.9;
}
to {
transform: scale(1);
opacity: 0;
}
}@import "tailwindcss";
@theme {
--color-lgt06-bg: #12101f;
--color-lgt06-surface: rgb(255 255 255 / 0.12);
--color-lgt06-fg: #f2f0fa;
--color-lgt06-muted: rgb(255 255 255 / 0.75);
--color-lgt06-rim: rgb(255 255 255 / 0.35);
--color-lgt06-track: rgb(120 110 160 / 0.5);
--color-lgt06-moon: #3a3560;
--color-lgt06-sun: #e0a020;
--font-lgt06-sans: 'Manrope', system-ui, sans-serif;
--ease-lgt06: cubic-bezier(0.5, -0.3, 0.4, 1.4);
--animate-lgt06-ripple: lgt-06-ripple 0.6s ease-out forwards;
}
/* One rule re-points the whole palette. Every utility in the markup reads these,
so the entire scene flips with a single attribute and no duplicated classes. */
.lgt-06[data-theme="light"] {
--color-lgt06-bg: #dfe7f7;
--color-lgt06-surface: rgb(255 255 255 / 0.55);
--color-lgt06-fg: #161425;
--color-lgt06-muted: rgb(30 28 50 / 0.7);
--color-lgt06-rim: rgb(255 255 255 / 0.8);
--color-lgt06-track: rgb(255 255 255 / 0.6);
}
@keyframes lgt-06-ripple {
from {
transform: scale(0);
opacity: 0.9;
}
to {
transform: scale(1);
opacity: 0;
}
}<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;800&display=swap">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;800&display=swap">What changed in the port
Which parts became utilities, which stayed CSS, and why.
- Because utilities compile to
var(--color-*), re-pointing six variables under[data-theme=light]restyles every surface — nodark:duplication anywhere in the markup. group-aria-checked:moves the knob and crossfades the glyphs from the same attribute a screen reader announces.- The ripple element is created in JS but described by a Tailwind class string, so it inherits the collection's glass vocabulary rather than inventing its own.
- The ripple is appended to the demo root, not to
document.body— scoped demos should never leak nodes into the host page. - Native View Transitions are used when present and the manual ripple covers everything else; both paths converge on the same attribute flip.
Here's a working CSS demo from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Tailwind Liquid Glass Theme Switcher
Source: https://codefronts.com/design-styles/tailwind-liquid-glass-effects/tailwind-liquid-glass-theme-switcher/
A dark/light toggle that releases a liquid glass ripple across the scene as it flips. In the port the entire theme lives in six custom properties re-pointed by one data-theme rule, so every utility in the markup — bg-lgt06-surface, text-lgt06-fg, border-lgt06-rim — changes meaning at once and not a single class is duplicated per theme.
## HTML
```html
<section id="lgt-06-root" data-theme="dark" class="lgt-06 grid w-full min-h-svh place-items-center bg-lgt06-bg p-15 font-lgt06-sans" aria-label="Tailwind liquid glass theme switcher">
<div class="relative w-[min(400px,92vw)] rounded-[26px] border border-lgt06-rim bg-lgt06-surface p-8.5 text-lgt06-fg backdrop-blur-[20px] backdrop-saturate-170 shadow-[inset_0_1px_0_var(--color-lgt06-rim),0_30px_60px_-30px_rgba(0,0,0,.5)]">
<h3 id="lgt-06-title" class="mb-2 text-[26px] font-extrabold">Good evening</h3>
<p class="mb-5.5 text-[15px] leading-relaxed text-lgt06-muted">Toggle the switch — a liquid glass ripple sweeps the new theme across the scene.</p>
<button id="lgt-06-switch" type="button" role="switch" aria-checked="false" aria-label="Toggle light mode" class="group relative h-9.5 w-19 cursor-pointer rounded-full border border-lgt06-rim bg-lgt06-track p-0 backdrop-blur-[8px] shadow-[inset_0_2px_8px_rgba(0,0,0,.3)] focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-lgt06-fg">
<span id="lgt-06-knob" class="absolute top-0.75 left-0.75 grid size-8 place-items-center rounded-full bg-linear-to-b from-white to-white/70 shadow-[0_4px_10px_rgba(0,0,0,.35),inset_0_1px_0_#fff] transition-[left] duration-400 ease-lgt06 group-aria-checked:left-[41px] motion-reduce:transition-none">
<span aria-hidden="true" class="absolute text-base text-lgt06-moon opacity-100 transition-opacity duration-300 group-aria-checked:opacity-0">☾</span>
<span aria-hidden="true" class="absolute text-base text-lgt06-sun opacity-0 transition-opacity duration-300 group-aria-checked:opacity-100">☀</span>
</span>
</button>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-lgt06-bg: #12101f;
--color-lgt06-surface: rgb(255 255 255 / 0.12);
--color-lgt06-fg: #f2f0fa;
--color-lgt06-muted: rgb(255 255 255 / 0.75);
--color-lgt06-rim: rgb(255 255 255 / 0.35);
--color-lgt06-track: rgb(120 110 160 / 0.5);
--color-lgt06-moon: #3a3560;
--color-lgt06-sun: #e0a020;
--font-lgt06-sans: 'Manrope', system-ui, sans-serif;
--ease-lgt06: cubic-bezier(0.5, -0.3, 0.4, 1.4);
--animate-lgt06-ripple: lgt-06-ripple 0.6s ease-out forwards;
}
/* One rule re-points the whole palette. Every utility in the markup reads these,
so the entire scene flips with a single attribute and no duplicated classes. */
.lgt-06[data-theme="light"] {
--color-lgt06-bg: #dfe7f7;
--color-lgt06-surface: rgb(255 255 255 / 0.55);
--color-lgt06-fg: #161425;
--color-lgt06-muted: rgb(30 28 50 / 0.7);
--color-lgt06-rim: rgb(255 255 255 / 0.8);
--color-lgt06-track: rgb(255 255 255 / 0.6);
}
@keyframes lgt-06-ripple {
from {
transform: scale(0);
opacity: 0.9;
}
to {
transform: scale(1);
opacity: 0;
}
}
```
## JavaScript
```js
(() => {
const root = document.querySelector('#lgt-06-root');
const sw = root.querySelector('#lgt-06-switch');
const knob = root.querySelector('#lgt-06-knob');
const title = root.querySelector('#lgt-06-title');
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
const apply = () => {
const next = root.dataset.theme === 'dark' ? 'light' : 'dark';
root.dataset.theme = next;
sw.setAttribute('aria-checked', String(next === 'light'));
title.textContent = next === 'light' ? 'Good morning' : 'Good evening';
};
const ripple = () => {
const r = knob.getBoundingClientRect();
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
const far = Math.hypot(Math.max(cx, innerWidth - cx), Math.max(cy, innerHeight - cy));
const el = document.createElement('span');
el.className = 'fixed z-9999 pointer-events-none rounded-full border border-lgt06-rim bg-lgt06-surface backdrop-blur-[26px] backdrop-saturate-180 animate-lgt06-ripple';
el.style.left = (cx - far) + 'px';
el.style.top = (cy - far) + 'px';
el.style.width = el.style.height = (far * 2) + 'px';
root.appendChild(el);
el.addEventListener('animationend', () => el.remove(), { once: true });
};
sw.addEventListener('click', () => {
if (reduce) { apply(); return; }
if (document.startViewTransition) { document.startViewTransition(apply); return; }
ripple();
setTimeout(apply, 180);
});
})();
```Here's a working CSS demo from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Tailwind Liquid Glass Theme Switcher
Source: https://codefronts.com/design-styles/tailwind-liquid-glass-effects/tailwind-liquid-glass-theme-switcher/
A dark/light toggle that releases a liquid glass ripple across the scene as it flips. In the port the entire theme lives in six custom properties re-pointed by one data-theme rule, so every utility in the markup — bg-lgt06-surface, text-lgt06-fg, border-lgt06-rim — changes meaning at once and not a single class is duplicated per theme.
## HTML
```html
<section id="lgt-06-root" data-theme="dark" class="lgt-06 grid w-full min-h-svh place-items-center bg-lgt06-bg p-15 font-lgt06-sans" aria-label="Tailwind liquid glass theme switcher">
<div class="relative w-[min(400px,92vw)] rounded-[26px] border border-lgt06-rim bg-lgt06-surface p-8.5 text-lgt06-fg backdrop-blur-[20px] backdrop-saturate-170 shadow-[inset_0_1px_0_var(--color-lgt06-rim),0_30px_60px_-30px_rgba(0,0,0,.5)]">
<h3 id="lgt-06-title" class="mb-2 text-[26px] font-extrabold">Good evening</h3>
<p class="mb-5.5 text-[15px] leading-relaxed text-lgt06-muted">Toggle the switch — a liquid glass ripple sweeps the new theme across the scene.</p>
<button id="lgt-06-switch" type="button" role="switch" aria-checked="false" aria-label="Toggle light mode" class="group relative h-9.5 w-19 cursor-pointer rounded-full border border-lgt06-rim bg-lgt06-track p-0 backdrop-blur-[8px] shadow-[inset_0_2px_8px_rgba(0,0,0,.3)] focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-lgt06-fg">
<span id="lgt-06-knob" class="absolute top-0.75 left-0.75 grid size-8 place-items-center rounded-full bg-linear-to-b from-white to-white/70 shadow-[0_4px_10px_rgba(0,0,0,.35),inset_0_1px_0_#fff] transition-[left] duration-400 ease-lgt06 group-aria-checked:left-[41px] motion-reduce:transition-none">
<span aria-hidden="true" class="absolute text-base text-lgt06-moon opacity-100 transition-opacity duration-300 group-aria-checked:opacity-0">☾</span>
<span aria-hidden="true" class="absolute text-base text-lgt06-sun opacity-0 transition-opacity duration-300 group-aria-checked:opacity-100">☀</span>
</span>
</button>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-lgt06-bg: #12101f;
--color-lgt06-surface: rgb(255 255 255 / 0.12);
--color-lgt06-fg: #f2f0fa;
--color-lgt06-muted: rgb(255 255 255 / 0.75);
--color-lgt06-rim: rgb(255 255 255 / 0.35);
--color-lgt06-track: rgb(120 110 160 / 0.5);
--color-lgt06-moon: #3a3560;
--color-lgt06-sun: #e0a020;
--font-lgt06-sans: 'Manrope', system-ui, sans-serif;
--ease-lgt06: cubic-bezier(0.5, -0.3, 0.4, 1.4);
--animate-lgt06-ripple: lgt-06-ripple 0.6s ease-out forwards;
}
/* One rule re-points the whole palette. Every utility in the markup reads these,
so the entire scene flips with a single attribute and no duplicated classes. */
.lgt-06[data-theme="light"] {
--color-lgt06-bg: #dfe7f7;
--color-lgt06-surface: rgb(255 255 255 / 0.55);
--color-lgt06-fg: #161425;
--color-lgt06-muted: rgb(30 28 50 / 0.7);
--color-lgt06-rim: rgb(255 255 255 / 0.8);
--color-lgt06-track: rgb(255 255 255 / 0.6);
}
@keyframes lgt-06-ripple {
from {
transform: scale(0);
opacity: 0.9;
}
to {
transform: scale(1);
opacity: 0;
}
}
```
## JavaScript
```js
(() => {
const root = document.querySelector('#lgt-06-root');
const sw = root.querySelector('#lgt-06-switch');
const knob = root.querySelector('#lgt-06-knob');
const title = root.querySelector('#lgt-06-title');
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
const apply = () => {
const next = root.dataset.theme === 'dark' ? 'light' : 'dark';
root.dataset.theme = next;
sw.setAttribute('aria-checked', String(next === 'light'));
title.textContent = next === 'light' ? 'Good morning' : 'Good evening';
};
const ripple = () => {
const r = knob.getBoundingClientRect();
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
const far = Math.hypot(Math.max(cx, innerWidth - cx), Math.max(cy, innerHeight - cy));
const el = document.createElement('span');
el.className = 'fixed z-9999 pointer-events-none rounded-full border border-lgt06-rim bg-lgt06-surface backdrop-blur-[26px] backdrop-saturate-180 animate-lgt06-ripple';
el.style.left = (cx - far) + 'px';
el.style.top = (cy - far) + 'px';
el.style.width = el.style.height = (far * 2) + 'px';
root.appendChild(el);
el.addEventListener('animationend', () => el.remove(), { once: true });
};
sw.addEventListener('click', () => {
if (reduce) { apply(); return; }
if (document.startViewTransition) { document.startViewTransition(apply); return; }
ripple();
setTimeout(apply, 180);
});
})();
```How this works
The theme is variables, and utilities read variables. This is the structural argument for v4: because bg-lgt06-surface compiles to background: var(--color-lgt06-surface), re-pointing that variable under an attribute changes every surface in the demo without touching a single class:
.lgt-06[data-theme="light"] {
--color-lgt06-surface: oklch(0.98 0.01 260 / 0.55);
--color-lgt06-fg: oklch(0.18 0.03 285);
--color-lgt06-rim: oklch(1 0 0 / 0.8);
}The switch is a button with role="switch", and the knob is positioned by an aria-checked: variant. There is no separate 'is light' class to keep in sync — the attribute assistive tech reads is the attribute that moves the knob.
The ripple is a real element with a real class list. The script creates it, sizes it to cover the viewport from the knob's centre, and lets animate-lgt06-ripple scale it out. It carries the same glass utilities as everything else, which is why the sweep looks like the material rather than like a coloured circle.
Native View Transitions when available. If document.startViewTransition exists, the same circular reveal is delegated to it; otherwise the manual ripple runs. Both paths end in the same attribute flip, so there is only one state machine.
Make it yours
- Rebrand both modes by editing the two token blocks — the dark values in
@theme, the light overrides in the[data-theme=light]rule. - Change the sweep speed in the
--animate-lgt06-rippletheme entry; 600ms is the point where it reads as liquid rather than as a wipe. - Swap the sun/moon glyphs for SVG icons; the
aria-checked:opacity crossfade needs no changes. - Move the ripple origin by measuring a different anchor element in the script.
- Persist the choice in localStorage and read it before first paint if you ship this on a real site — the demo deliberately stays stateless.
Gotchas — read before shipping
- Drive the theme from one attribute and one token block. The moment you start writing
dark:variants alongside a custom theme attribute you have two sources of truth and they will drift. role="switch"requiresaria-checkedto be maintained — a switch that never updates it is worse than a plain button.- Guard the View Transitions call with a feature check; without it, unsupported browsers throw and the theme never flips.
- The ripple must be removed on
animationend. Leaving expanded full-viewport elements in the DOM is a slow, invisible memory leak. - Both themes need their own contrast check. A token set that passes in dark almost never passes unchanged in light.
- Under reduced motion, swap instantly rather than shortening the ripple — a 200ms full-screen sweep is still a full-screen sweep.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 128+ | 111+ |
The hand-rolled clip-path ripple works everywhere Tailwind v4 does; native View Transitions (Chrome 111+, Safari 18+) are used opportunistically when present. Under prefers-reduced-motion the theme swaps instantly with no sweep at all.