19 / 20
All 20 in 20 Tailwind CSS Popovers
- 01Tailwind Popover Show and Hide with the Popover API
- 02Tailwind Auto vs Manual Popover Behavior
- 03Tailwind Anchored Popover with CSS Anchor Positioning
- 04Tailwind Popover Entry and Exit Animation
- 05Tailwind Popover Backdrop and Light Dismiss
- 06Tailwind Position Area Popover Placement Grid
- 07Tailwind Popover Flip with Position Try Fallbacks
- 08Tailwind Tooltip Arrow Pointing to Anchor
- 09Tailwind Dropdown Matching Anchor Width
- 10Tailwind Nested Popover Submenu
- 11Tailwind Custom Select Listbox Popover
- 12Tailwind Tooltip on Hover and Focus
- 13Tailwind User Profile Dropdown Menu
- 14Tailwind Date Picker Popover
- 15Tailwind Filter Dropdown with Checkboxes
- 16Tailwind Popover Fallback and Polyfill
- 17Tailwind Popover vs Dialog Modal
- 18Tailwind Glassmorphism Popover
- 19Tailwind Dark Mode Popover Toggle
- 20Tailwind Accessible Tooltip with ARIA
Tailwind Dark Mode Popover Toggle
One number drives the whole theme. @theme inline defines every colour as color-mix(light, dark, var(--tpop19-d)); the root sets --tpop19-d to 0 or 1 with four utilities — dark:, has-checked: and dark:has-checked: for the flip-back — and because --tpop19-d is a registered @property, the root can transition it: the page and the open popover crossfade between palettes in the same frame, zero JS.
Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time
Untitled UI React · Affiliate link
The code
<section class="tpop-19 group/root w-full min-h-svh bg-tpop19-page px-4.5 py-9.5 font-tpop19-sans text-tpop19-ink transition-[--tpop19-d] duration-500 ease-in-out motion-reduce:transition-none dark:[--tpop19-d:1] has-checked:[--tpop19-d:1] dark:has-checked:[--tpop19-d:0]" aria-label="Dark and light mode popover demo">
<div class="grid min-h-[calc(100svh-76px)] max-w-full place-items-center">
<div class="w-full min-w-0 max-w-[27rem] rounded-2xl border border-tpop19-rule bg-tpop19-panel p-4.5 shadow-[0_18px_40px_-30px_oklch(0_0_0/0.45)]">
<div class="mb-3.5 flex flex-wrap items-center justify-between gap-2.5">
<p class="text-[10.5px] font-bold uppercase tracking-[0.2em] text-tpop19-mut">Appearance</p>
<div class="relative flex">
<input id="tpop-19-switch" type="checkbox" class="peer sr-only">
<label for="tpop-19-switch" class="inline-flex min-h-11 cursor-pointer items-center gap-2.5 rounded-full border border-tpop19-rule px-3 text-[11.5px] font-bold uppercase tracking-[0.12em] text-tpop19-ink select-none peer-focus-visible:outline-3 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-tpop19-acc">
<span aria-hidden="true" class="relative h-4.5 w-8 flex-none rounded-full bg-tpop19-rule">
<span class="absolute top-0.5 left-0.5 grid size-3.5 place-items-center rounded-full bg-tpop19-ink text-tpop19-panel transition-transform duration-200 motion-reduce:transition-none group-has-checked/root:translate-x-3.5 dark:translate-x-3.5 dark:group-has-checked/root:translate-x-0"></span>
</span>
<span class="group-has-checked/root:hidden dark:hidden dark:group-has-checked/root:inline">Light</span>
<span class="hidden group-has-checked/root:inline dark:inline dark:group-has-checked/root:hidden">Dark</span>
</label>
</div>
</div>
<h2 class="mb-2.5 text-[clamp(20px,4.4vw,25px)] font-semibold leading-tight tracking-[-0.02em]">One dial, two palettes</h2>
<p class="mb-4 text-sm leading-relaxed text-tpop19-mut text-pretty">The panel is in the top layer and still inherits the dial, so it crossfades with the page — open it, then flip the switch.</p>
<button id="tpop-19-trigger" type="button" popovertarget="tpop-19-panel" class="min-h-11 cursor-pointer rounded-xl bg-tpop19-acc px-4 text-[13.5px] font-semibold text-tpop19-onacc transition-[filter] [anchor-name:--tpop19-a] hover:brightness-110 focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-tpop19-acc">Open themed panel</button>
<dl class="mt-4.5 grid gap-2 border-t border-tpop19-rule pt-3.5">
<div class="flex min-w-0 items-center justify-between gap-3"><dt class="flex items-center gap-2 font-tpop19-mono text-xs font-semibold text-tpop19-acc"><span aria-hidden="true" class="size-3 rounded-[4px] border border-tpop19-rule bg-tpop19-page"></span>--color-tpop19-page</dt><dd class="text-[12.5px] text-tpop19-mut">page</dd></div>
<div class="flex min-w-0 items-center justify-between gap-3"><dt class="flex items-center gap-2 font-tpop19-mono text-xs font-semibold text-tpop19-acc"><span aria-hidden="true" class="size-3 rounded-[4px] border border-tpop19-rule bg-tpop19-panel"></span>--color-tpop19-panel</dt><dd class="text-[12.5px] text-tpop19-mut">surfaces</dd></div>
<div class="flex min-w-0 items-center justify-between gap-3"><dt class="flex items-center gap-2 font-tpop19-mono text-xs font-semibold text-tpop19-acc"><span aria-hidden="true" class="size-3 rounded-[4px] bg-tpop19-ink"></span>--color-tpop19-ink</dt><dd class="text-[12.5px] text-tpop19-mut">text</dd></div>
</dl>
</div>
</div>
<div popover="auto" id="tpop-19-panel" role="group" aria-label="Themed panel" class="fixed m-auto w-[min(18rem,calc(100vw-28px))] rounded-2xl border border-tpop19-rule bg-tpop19-panel p-4 text-tpop19-ink opacity-0 shadow-[0_20px_40px_-24px_oklch(0_0_0/0.45)] transition-[opacity,translate,display,overlay] duration-150 ease-out transition-discrete [position-anchor:--tpop19-a] [position-area:block-end_span-inline-end] [position-try-fallbacks:flip-block] open:opacity-100 starting:open:-translate-y-1 starting:open:opacity-0 motion-reduce:transition-none supports-[anchor-name:--a]:inset-auto supports-[anchor-name:--a]:m-0 supports-[anchor-name:--a]:mt-2.5">
<p class="mb-2 text-[11px] font-bold uppercase tracking-[0.16em] text-tpop19-mut">Themed panel</p>
<p class="mb-3 text-[13.5px] leading-relaxed">Flip the switch with me open. Both surfaces fade in the same frame because they read the same dial.</p>
<p class="border-t border-tpop19-rule pt-2.5 font-tpop19-mono text-[11.5px] font-semibold text-tpop19-acc">zero JavaScript</p>
</div>
</section><section class="tpop-19 group/root w-full min-h-svh bg-tpop19-page px-4.5 py-9.5 font-tpop19-sans text-tpop19-ink transition-[--tpop19-d] duration-500 ease-in-out motion-reduce:transition-none dark:[--tpop19-d:1] has-checked:[--tpop19-d:1] dark:has-checked:[--tpop19-d:0]" aria-label="Dark and light mode popover demo">
<div class="grid min-h-[calc(100svh-76px)] max-w-full place-items-center">
<div class="w-full min-w-0 max-w-[27rem] rounded-2xl border border-tpop19-rule bg-tpop19-panel p-4.5 shadow-[0_18px_40px_-30px_oklch(0_0_0/0.45)]">
<div class="mb-3.5 flex flex-wrap items-center justify-between gap-2.5">
<p class="text-[10.5px] font-bold uppercase tracking-[0.2em] text-tpop19-mut">Appearance</p>
<div class="relative flex">
<input id="tpop-19-switch" type="checkbox" class="peer sr-only">
<label for="tpop-19-switch" class="inline-flex min-h-11 cursor-pointer items-center gap-2.5 rounded-full border border-tpop19-rule px-3 text-[11.5px] font-bold uppercase tracking-[0.12em] text-tpop19-ink select-none peer-focus-visible:outline-3 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-tpop19-acc">
<span aria-hidden="true" class="relative h-4.5 w-8 flex-none rounded-full bg-tpop19-rule">
<span class="absolute top-0.5 left-0.5 grid size-3.5 place-items-center rounded-full bg-tpop19-ink text-tpop19-panel transition-transform duration-200 motion-reduce:transition-none group-has-checked/root:translate-x-3.5 dark:translate-x-3.5 dark:group-has-checked/root:translate-x-0"></span>
</span>
<span class="group-has-checked/root:hidden dark:hidden dark:group-has-checked/root:inline">Light</span>
<span class="hidden group-has-checked/root:inline dark:inline dark:group-has-checked/root:hidden">Dark</span>
</label>
</div>
</div>
<h2 class="mb-2.5 text-[clamp(20px,4.4vw,25px)] font-semibold leading-tight tracking-[-0.02em]">One dial, two palettes</h2>
<p class="mb-4 text-sm leading-relaxed text-tpop19-mut text-pretty">The panel is in the top layer and still inherits the dial, so it crossfades with the page — open it, then flip the switch.</p>
<button id="tpop-19-trigger" type="button" popovertarget="tpop-19-panel" class="min-h-11 cursor-pointer rounded-xl bg-tpop19-acc px-4 text-[13.5px] font-semibold text-tpop19-onacc transition-[filter] [anchor-name:--tpop19-a] hover:brightness-110 focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-tpop19-acc">Open themed panel</button>
<dl class="mt-4.5 grid gap-2 border-t border-tpop19-rule pt-3.5">
<div class="flex min-w-0 items-center justify-between gap-3"><dt class="flex items-center gap-2 font-tpop19-mono text-xs font-semibold text-tpop19-acc"><span aria-hidden="true" class="size-3 rounded-[4px] border border-tpop19-rule bg-tpop19-page"></span>--color-tpop19-page</dt><dd class="text-[12.5px] text-tpop19-mut">page</dd></div>
<div class="flex min-w-0 items-center justify-between gap-3"><dt class="flex items-center gap-2 font-tpop19-mono text-xs font-semibold text-tpop19-acc"><span aria-hidden="true" class="size-3 rounded-[4px] border border-tpop19-rule bg-tpop19-panel"></span>--color-tpop19-panel</dt><dd class="text-[12.5px] text-tpop19-mut">surfaces</dd></div>
<div class="flex min-w-0 items-center justify-between gap-3"><dt class="flex items-center gap-2 font-tpop19-mono text-xs font-semibold text-tpop19-acc"><span aria-hidden="true" class="size-3 rounded-[4px] bg-tpop19-ink"></span>--color-tpop19-ink</dt><dd class="text-[12.5px] text-tpop19-mut">text</dd></div>
</dl>
</div>
</div>
<div popover="auto" id="tpop-19-panel" role="group" aria-label="Themed panel" class="fixed m-auto w-[min(18rem,calc(100vw-28px))] rounded-2xl border border-tpop19-rule bg-tpop19-panel p-4 text-tpop19-ink opacity-0 shadow-[0_20px_40px_-24px_oklch(0_0_0/0.45)] transition-[opacity,translate,display,overlay] duration-150 ease-out transition-discrete [position-anchor:--tpop19-a] [position-area:block-end_span-inline-end] [position-try-fallbacks:flip-block] open:opacity-100 starting:open:-translate-y-1 starting:open:opacity-0 motion-reduce:transition-none supports-[anchor-name:--a]:inset-auto supports-[anchor-name:--a]:m-0 supports-[anchor-name:--a]:mt-2.5">
<p class="mb-2 text-[11px] font-bold uppercase tracking-[0.16em] text-tpop19-mut">Themed panel</p>
<p class="mb-3 text-[13.5px] leading-relaxed">Flip the switch with me open. Both surfaces fade in the same frame because they read the same dial.</p>
<p class="border-t border-tpop19-rule pt-2.5 font-tpop19-mono text-[11.5px] font-semibold text-tpop19-acc">zero JavaScript</p>
</div>
</section>@import "tailwindcss";
/* The theme dial: registered so it can transition (0 = light, 1 = dark). */
@property --tpop19-d {
syntax: '<number>';
inherits: true;
initial-value: 0;
}
/* inline: each utility carries the expression itself, so it resolves against the dial the element
inherits rather than once on :root. Light value last, dark value first. */
@theme inline {
--color-tpop19-page: color-mix(in oklch, oklch(0.185 0.01 270) calc(var(--tpop19-d) * 100%), oklch(0.975 0.003 270));
--color-tpop19-panel: color-mix(in oklch, oklch(0.235 0.01 270) calc(var(--tpop19-d) * 100%), oklch(1 0 0));
--color-tpop19-ink: color-mix(in oklch, oklch(0.94 0.008 270) calc(var(--tpop19-d) * 100%), oklch(0.2 0.01 270));
--color-tpop19-mut: color-mix(in oklch, oklch(0.74 0.015 270) calc(var(--tpop19-d) * 100%), oklch(0.48 0.012 270));
--color-tpop19-rule: color-mix(in oklch, oklch(0.32 0.012 270) calc(var(--tpop19-d) * 100%), oklch(0.9 0.006 270));
--color-tpop19-acc: color-mix(in oklch, oklch(0.74 0.12 262) calc(var(--tpop19-d) * 100%), oklch(0.5 0.17 262));
--color-tpop19-onacc: color-mix(in oklch, oklch(0.185 0.01 270) calc(var(--tpop19-d) * 100%), oklch(1 0 0));
}
@theme {
--font-tpop19-sans: 'Geist', system-ui, sans-serif;
--font-tpop19-mono: 'Geist Mono', ui-monospace, monospace;
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's utilities, so the
stage is sized before the browser build compiles. */
.tpop-19 {
width: 100%;
min-height: 100svh;
}@import "tailwindcss";
/* The theme dial: registered so it can transition (0 = light, 1 = dark). */
@property --tpop19-d {
syntax: '<number>';
inherits: true;
initial-value: 0;
}
/* inline: each utility carries the expression itself, so it resolves against the dial the element
inherits rather than once on :root. Light value last, dark value first. */
@theme inline {
--color-tpop19-page: color-mix(in oklch, oklch(0.185 0.01 270) calc(var(--tpop19-d) * 100%), oklch(0.975 0.003 270));
--color-tpop19-panel: color-mix(in oklch, oklch(0.235 0.01 270) calc(var(--tpop19-d) * 100%), oklch(1 0 0));
--color-tpop19-ink: color-mix(in oklch, oklch(0.94 0.008 270) calc(var(--tpop19-d) * 100%), oklch(0.2 0.01 270));
--color-tpop19-mut: color-mix(in oklch, oklch(0.74 0.015 270) calc(var(--tpop19-d) * 100%), oklch(0.48 0.012 270));
--color-tpop19-rule: color-mix(in oklch, oklch(0.32 0.012 270) calc(var(--tpop19-d) * 100%), oklch(0.9 0.006 270));
--color-tpop19-acc: color-mix(in oklch, oklch(0.74 0.12 262) calc(var(--tpop19-d) * 100%), oklch(0.5 0.17 262));
--color-tpop19-onacc: color-mix(in oklch, oklch(0.185 0.01 270) calc(var(--tpop19-d) * 100%), oklch(1 0 0));
}
@theme {
--font-tpop19-sans: 'Geist', system-ui, sans-serif;
--font-tpop19-mono: 'Geist Mono', ui-monospace, monospace;
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's utilities, so the
stage is sized before the browser build compiles. */
.tpop-19 {
width: 100%;
min-height: 100svh;
}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";
/* The theme dial: registered so it can transition (0 = light, 1 = dark). */
@property --tpop19-d {
syntax: '<number>';
inherits: true;
initial-value: 0;
}
/* inline: each utility carries the expression itself, so it resolves against the dial the element
inherits rather than once on :root. Light value last, dark value first. */
@theme inline {
--color-tpop19-page: color-mix(in oklch, oklch(0.185 0.01 270) calc(var(--tpop19-d) * 100%), oklch(0.975 0.003 270));
--color-tpop19-panel: color-mix(in oklch, oklch(0.235 0.01 270) calc(var(--tpop19-d) * 100%), oklch(1 0 0));
--color-tpop19-ink: color-mix(in oklch, oklch(0.94 0.008 270) calc(var(--tpop19-d) * 100%), oklch(0.2 0.01 270));
--color-tpop19-mut: color-mix(in oklch, oklch(0.74 0.015 270) calc(var(--tpop19-d) * 100%), oklch(0.48 0.012 270));
--color-tpop19-rule: color-mix(in oklch, oklch(0.32 0.012 270) calc(var(--tpop19-d) * 100%), oklch(0.9 0.006 270));
--color-tpop19-acc: color-mix(in oklch, oklch(0.74 0.12 262) calc(var(--tpop19-d) * 100%), oklch(0.5 0.17 262));
--color-tpop19-onacc: color-mix(in oklch, oklch(0.185 0.01 270) calc(var(--tpop19-d) * 100%), oklch(1 0 0));
}
@theme {
--font-tpop19-sans: 'Geist', system-ui, sans-serif;
--font-tpop19-mono: 'Geist Mono', ui-monospace, monospace;
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's utilities, so the
stage is sized before the browser build compiles. */
.tpop-19 {
width: 100%;
min-height: 100svh;
}@import "tailwindcss";
/* The theme dial: registered so it can transition (0 = light, 1 = dark). */
@property --tpop19-d {
syntax: '<number>';
inherits: true;
initial-value: 0;
}
/* inline: each utility carries the expression itself, so it resolves against the dial the element
inherits rather than once on :root. Light value last, dark value first. */
@theme inline {
--color-tpop19-page: color-mix(in oklch, oklch(0.185 0.01 270) calc(var(--tpop19-d) * 100%), oklch(0.975 0.003 270));
--color-tpop19-panel: color-mix(in oklch, oklch(0.235 0.01 270) calc(var(--tpop19-d) * 100%), oklch(1 0 0));
--color-tpop19-ink: color-mix(in oklch, oklch(0.94 0.008 270) calc(var(--tpop19-d) * 100%), oklch(0.2 0.01 270));
--color-tpop19-mut: color-mix(in oklch, oklch(0.74 0.015 270) calc(var(--tpop19-d) * 100%), oklch(0.48 0.012 270));
--color-tpop19-rule: color-mix(in oklch, oklch(0.32 0.012 270) calc(var(--tpop19-d) * 100%), oklch(0.9 0.006 270));
--color-tpop19-acc: color-mix(in oklch, oklch(0.74 0.12 262) calc(var(--tpop19-d) * 100%), oklch(0.5 0.17 262));
--color-tpop19-onacc: color-mix(in oklch, oklch(0.185 0.01 270) calc(var(--tpop19-d) * 100%), oklch(1 0 0));
}
@theme {
--font-tpop19-sans: 'Geist', system-ui, sans-serif;
--font-tpop19-mono: 'Geist Mono', ui-monospace, monospace;
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's utilities, so the
stage is sized before the browser build compiles. */
.tpop-19 {
width: 100%;
min-height: 100svh;
}<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=Geist:wght@400..800&family=Geist+Mono:wght@400..600&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=Geist:wght@400..800&family=Geist+Mono:wght@400..600&display=swap">What changed in the port
Which parts became utilities, which stayed CSS, and why.
- Four token blocks (light, checked-dark, OS-dark, OS-dark-checked) of six properties each — 24 declarations — became one dial and four root utilities; every colour is defined once, as a light/dark pair.
.pop-19__switch's hand-rolled clip becamepeer sr-only; the label's focus ring ispeer-focus-visible:outline-3.- UI upgrade from the CSS original: the theme crossfades over 500ms — page, card, open popover and swatches together — because
--tpop19-dis a registered@propertythat the root transitions; the original snapped (and stills undermotion-reduce:). - UI upgrade: the token list shows live swatches of each colour, the label always names the palette currently showing, and hex fallbacks are unnecessary here — this demo is
color-mix()-native, which Tailwind v4's own floor already requires.
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 Dark Mode Popover Toggle
Source: https://codefronts.com/snippets/tailwind-css-popovers/tailwind-dark-mode-popover-toggle/
One number drives the whole theme. @theme inline defines every colour as color-mix(light, dark, var(--tpop19-d)); the root sets --tpop19-d to 0 or 1 with four utilities — dark:, has-checked: and dark:has-checked: for the flip-back — and because --tpop19-d is a registered @property, the root can transition it: the page and the open popover crossfade between palettes in the same frame, zero JS.
## HTML
```html
<section class="tpop-19 group/root w-full min-h-svh bg-tpop19-page px-4.5 py-9.5 font-tpop19-sans text-tpop19-ink transition-[--tpop19-d] duration-500 ease-in-out motion-reduce:transition-none dark:[--tpop19-d:1] has-checked:[--tpop19-d:1] dark:has-checked:[--tpop19-d:0]" aria-label="Dark and light mode popover demo">
<div class="grid min-h-[calc(100svh-76px)] max-w-full place-items-center">
<div class="w-full min-w-0 max-w-[27rem] rounded-2xl border border-tpop19-rule bg-tpop19-panel p-4.5 shadow-[0_18px_40px_-30px_oklch(0_0_0/0.45)]">
<div class="mb-3.5 flex flex-wrap items-center justify-between gap-2.5">
<p class="text-[10.5px] font-bold uppercase tracking-[0.2em] text-tpop19-mut">Appearance</p>
<div class="relative flex">
<input id="tpop-19-switch" type="checkbox" class="peer sr-only">
<label for="tpop-19-switch" class="inline-flex min-h-11 cursor-pointer items-center gap-2.5 rounded-full border border-tpop19-rule px-3 text-[11.5px] font-bold uppercase tracking-[0.12em] text-tpop19-ink select-none peer-focus-visible:outline-3 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-tpop19-acc">
<span aria-hidden="true" class="relative h-4.5 w-8 flex-none rounded-full bg-tpop19-rule">
<span class="absolute top-0.5 left-0.5 grid size-3.5 place-items-center rounded-full bg-tpop19-ink text-tpop19-panel transition-transform duration-200 motion-reduce:transition-none group-has-checked/root:translate-x-3.5 dark:translate-x-3.5 dark:group-has-checked/root:translate-x-0"></span>
</span>
<span class="group-has-checked/root:hidden dark:hidden dark:group-has-checked/root:inline">Light</span>
<span class="hidden group-has-checked/root:inline dark:inline dark:group-has-checked/root:hidden">Dark</span>
</label>
</div>
</div>
<h2 class="mb-2.5 text-[clamp(20px,4.4vw,25px)] font-semibold leading-tight tracking-[-0.02em]">One dial, two palettes</h2>
<p class="mb-4 text-sm leading-relaxed text-tpop19-mut text-pretty">The panel is in the top layer and still inherits the dial, so it crossfades with the page — open it, then flip the switch.</p>
<button id="tpop-19-trigger" type="button" popovertarget="tpop-19-panel" class="min-h-11 cursor-pointer rounded-xl bg-tpop19-acc px-4 text-[13.5px] font-semibold text-tpop19-onacc transition-[filter] [anchor-name:--tpop19-a] hover:brightness-110 focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-tpop19-acc">Open themed panel</button>
<dl class="mt-4.5 grid gap-2 border-t border-tpop19-rule pt-3.5">
<div class="flex min-w-0 items-center justify-between gap-3"><dt class="flex items-center gap-2 font-tpop19-mono text-xs font-semibold text-tpop19-acc"><span aria-hidden="true" class="size-3 rounded-[4px] border border-tpop19-rule bg-tpop19-page"></span>--color-tpop19-page</dt><dd class="text-[12.5px] text-tpop19-mut">page</dd></div>
<div class="flex min-w-0 items-center justify-between gap-3"><dt class="flex items-center gap-2 font-tpop19-mono text-xs font-semibold text-tpop19-acc"><span aria-hidden="true" class="size-3 rounded-[4px] border border-tpop19-rule bg-tpop19-panel"></span>--color-tpop19-panel</dt><dd class="text-[12.5px] text-tpop19-mut">surfaces</dd></div>
<div class="flex min-w-0 items-center justify-between gap-3"><dt class="flex items-center gap-2 font-tpop19-mono text-xs font-semibold text-tpop19-acc"><span aria-hidden="true" class="size-3 rounded-[4px] bg-tpop19-ink"></span>--color-tpop19-ink</dt><dd class="text-[12.5px] text-tpop19-mut">text</dd></div>
</dl>
</div>
</div>
<div popover="auto" id="tpop-19-panel" role="group" aria-label="Themed panel" class="fixed m-auto w-[min(18rem,calc(100vw-28px))] rounded-2xl border border-tpop19-rule bg-tpop19-panel p-4 text-tpop19-ink opacity-0 shadow-[0_20px_40px_-24px_oklch(0_0_0/0.45)] transition-[opacity,translate,display,overlay] duration-150 ease-out transition-discrete [position-anchor:--tpop19-a] [position-area:block-end_span-inline-end] [position-try-fallbacks:flip-block] open:opacity-100 starting:open:-translate-y-1 starting:open:opacity-0 motion-reduce:transition-none supports-[anchor-name:--a]:inset-auto supports-[anchor-name:--a]:m-0 supports-[anchor-name:--a]:mt-2.5">
<p class="mb-2 text-[11px] font-bold uppercase tracking-[0.16em] text-tpop19-mut">Themed panel</p>
<p class="mb-3 text-[13.5px] leading-relaxed">Flip the switch with me open. Both surfaces fade in the same frame because they read the same dial.</p>
<p class="border-t border-tpop19-rule pt-2.5 font-tpop19-mono text-[11.5px] font-semibold text-tpop19-acc">zero JavaScript</p>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
/* The theme dial: registered so it can transition (0 = light, 1 = dark). */
@property --tpop19-d {
syntax: '<number>';
inherits: true;
initial-value: 0;
}
/* inline: each utility carries the expression itself, so it resolves against the dial the element
inherits rather than once on :root. Light value last, dark value first. */
@theme inline {
--color-tpop19-page: color-mix(in oklch, oklch(0.185 0.01 270) calc(var(--tpop19-d) * 100%), oklch(0.975 0.003 270));
--color-tpop19-panel: color-mix(in oklch, oklch(0.235 0.01 270) calc(var(--tpop19-d) * 100%), oklch(1 0 0));
--color-tpop19-ink: color-mix(in oklch, oklch(0.94 0.008 270) calc(var(--tpop19-d) * 100%), oklch(0.2 0.01 270));
--color-tpop19-mut: color-mix(in oklch, oklch(0.74 0.015 270) calc(var(--tpop19-d) * 100%), oklch(0.48 0.012 270));
--color-tpop19-rule: color-mix(in oklch, oklch(0.32 0.012 270) calc(var(--tpop19-d) * 100%), oklch(0.9 0.006 270));
--color-tpop19-acc: color-mix(in oklch, oklch(0.74 0.12 262) calc(var(--tpop19-d) * 100%), oklch(0.5 0.17 262));
--color-tpop19-onacc: color-mix(in oklch, oklch(0.185 0.01 270) calc(var(--tpop19-d) * 100%), oklch(1 0 0));
}
@theme {
--font-tpop19-sans: 'Geist', system-ui, sans-serif;
--font-tpop19-mono: 'Geist Mono', ui-monospace, monospace;
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's utilities, so the
stage is sized before the browser build compiles. */
.tpop-19 {
width: 100%;
min-height: 100svh;
}
```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 Dark Mode Popover Toggle
Source: https://codefronts.com/snippets/tailwind-css-popovers/tailwind-dark-mode-popover-toggle/
One number drives the whole theme. @theme inline defines every colour as color-mix(light, dark, var(--tpop19-d)); the root sets --tpop19-d to 0 or 1 with four utilities — dark:, has-checked: and dark:has-checked: for the flip-back — and because --tpop19-d is a registered @property, the root can transition it: the page and the open popover crossfade between palettes in the same frame, zero JS.
## HTML
```html
<section class="tpop-19 group/root w-full min-h-svh bg-tpop19-page px-4.5 py-9.5 font-tpop19-sans text-tpop19-ink transition-[--tpop19-d] duration-500 ease-in-out motion-reduce:transition-none dark:[--tpop19-d:1] has-checked:[--tpop19-d:1] dark:has-checked:[--tpop19-d:0]" aria-label="Dark and light mode popover demo">
<div class="grid min-h-[calc(100svh-76px)] max-w-full place-items-center">
<div class="w-full min-w-0 max-w-[27rem] rounded-2xl border border-tpop19-rule bg-tpop19-panel p-4.5 shadow-[0_18px_40px_-30px_oklch(0_0_0/0.45)]">
<div class="mb-3.5 flex flex-wrap items-center justify-between gap-2.5">
<p class="text-[10.5px] font-bold uppercase tracking-[0.2em] text-tpop19-mut">Appearance</p>
<div class="relative flex">
<input id="tpop-19-switch" type="checkbox" class="peer sr-only">
<label for="tpop-19-switch" class="inline-flex min-h-11 cursor-pointer items-center gap-2.5 rounded-full border border-tpop19-rule px-3 text-[11.5px] font-bold uppercase tracking-[0.12em] text-tpop19-ink select-none peer-focus-visible:outline-3 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-tpop19-acc">
<span aria-hidden="true" class="relative h-4.5 w-8 flex-none rounded-full bg-tpop19-rule">
<span class="absolute top-0.5 left-0.5 grid size-3.5 place-items-center rounded-full bg-tpop19-ink text-tpop19-panel transition-transform duration-200 motion-reduce:transition-none group-has-checked/root:translate-x-3.5 dark:translate-x-3.5 dark:group-has-checked/root:translate-x-0"></span>
</span>
<span class="group-has-checked/root:hidden dark:hidden dark:group-has-checked/root:inline">Light</span>
<span class="hidden group-has-checked/root:inline dark:inline dark:group-has-checked/root:hidden">Dark</span>
</label>
</div>
</div>
<h2 class="mb-2.5 text-[clamp(20px,4.4vw,25px)] font-semibold leading-tight tracking-[-0.02em]">One dial, two palettes</h2>
<p class="mb-4 text-sm leading-relaxed text-tpop19-mut text-pretty">The panel is in the top layer and still inherits the dial, so it crossfades with the page — open it, then flip the switch.</p>
<button id="tpop-19-trigger" type="button" popovertarget="tpop-19-panel" class="min-h-11 cursor-pointer rounded-xl bg-tpop19-acc px-4 text-[13.5px] font-semibold text-tpop19-onacc transition-[filter] [anchor-name:--tpop19-a] hover:brightness-110 focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-tpop19-acc">Open themed panel</button>
<dl class="mt-4.5 grid gap-2 border-t border-tpop19-rule pt-3.5">
<div class="flex min-w-0 items-center justify-between gap-3"><dt class="flex items-center gap-2 font-tpop19-mono text-xs font-semibold text-tpop19-acc"><span aria-hidden="true" class="size-3 rounded-[4px] border border-tpop19-rule bg-tpop19-page"></span>--color-tpop19-page</dt><dd class="text-[12.5px] text-tpop19-mut">page</dd></div>
<div class="flex min-w-0 items-center justify-between gap-3"><dt class="flex items-center gap-2 font-tpop19-mono text-xs font-semibold text-tpop19-acc"><span aria-hidden="true" class="size-3 rounded-[4px] border border-tpop19-rule bg-tpop19-panel"></span>--color-tpop19-panel</dt><dd class="text-[12.5px] text-tpop19-mut">surfaces</dd></div>
<div class="flex min-w-0 items-center justify-between gap-3"><dt class="flex items-center gap-2 font-tpop19-mono text-xs font-semibold text-tpop19-acc"><span aria-hidden="true" class="size-3 rounded-[4px] bg-tpop19-ink"></span>--color-tpop19-ink</dt><dd class="text-[12.5px] text-tpop19-mut">text</dd></div>
</dl>
</div>
</div>
<div popover="auto" id="tpop-19-panel" role="group" aria-label="Themed panel" class="fixed m-auto w-[min(18rem,calc(100vw-28px))] rounded-2xl border border-tpop19-rule bg-tpop19-panel p-4 text-tpop19-ink opacity-0 shadow-[0_20px_40px_-24px_oklch(0_0_0/0.45)] transition-[opacity,translate,display,overlay] duration-150 ease-out transition-discrete [position-anchor:--tpop19-a] [position-area:block-end_span-inline-end] [position-try-fallbacks:flip-block] open:opacity-100 starting:open:-translate-y-1 starting:open:opacity-0 motion-reduce:transition-none supports-[anchor-name:--a]:inset-auto supports-[anchor-name:--a]:m-0 supports-[anchor-name:--a]:mt-2.5">
<p class="mb-2 text-[11px] font-bold uppercase tracking-[0.16em] text-tpop19-mut">Themed panel</p>
<p class="mb-3 text-[13.5px] leading-relaxed">Flip the switch with me open. Both surfaces fade in the same frame because they read the same dial.</p>
<p class="border-t border-tpop19-rule pt-2.5 font-tpop19-mono text-[11.5px] font-semibold text-tpop19-acc">zero JavaScript</p>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
/* The theme dial: registered so it can transition (0 = light, 1 = dark). */
@property --tpop19-d {
syntax: '<number>';
inherits: true;
initial-value: 0;
}
/* inline: each utility carries the expression itself, so it resolves against the dial the element
inherits rather than once on :root. Light value last, dark value first. */
@theme inline {
--color-tpop19-page: color-mix(in oklch, oklch(0.185 0.01 270) calc(var(--tpop19-d) * 100%), oklch(0.975 0.003 270));
--color-tpop19-panel: color-mix(in oklch, oklch(0.235 0.01 270) calc(var(--tpop19-d) * 100%), oklch(1 0 0));
--color-tpop19-ink: color-mix(in oklch, oklch(0.94 0.008 270) calc(var(--tpop19-d) * 100%), oklch(0.2 0.01 270));
--color-tpop19-mut: color-mix(in oklch, oklch(0.74 0.015 270) calc(var(--tpop19-d) * 100%), oklch(0.48 0.012 270));
--color-tpop19-rule: color-mix(in oklch, oklch(0.32 0.012 270) calc(var(--tpop19-d) * 100%), oklch(0.9 0.006 270));
--color-tpop19-acc: color-mix(in oklch, oklch(0.74 0.12 262) calc(var(--tpop19-d) * 100%), oklch(0.5 0.17 262));
--color-tpop19-onacc: color-mix(in oklch, oklch(0.185 0.01 270) calc(var(--tpop19-d) * 100%), oklch(1 0 0));
}
@theme {
--font-tpop19-sans: 'Geist', system-ui, sans-serif;
--font-tpop19-mono: 'Geist Mono', ui-monospace, monospace;
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's utilities, so the
stage is sized before the browser build compiles. */
.tpop-19 {
width: 100%;
min-height: 100svh;
}
```How this works
Tokens that read a dial. @theme inline writes the expression into every utility instead of a variable reference, so bg-tpop19-panel resolves against whatever --tpop19-d the element inherits:
@property --tpop19-d { syntax: '<number>'; inherits: true; initial-value: 0; }
@theme inline {
--color-tpop19-panel: color-mix(in oklch,
oklch(0.235 0.01 270) calc(var(--tpop19-d) * 100%), oklch(1 0 0));
}
<section class="dark:[--tpop19-d:1] has-checked:[--tpop19-d:1]
dark:has-checked:[--tpop19-d:0] transition-[--tpop19-d] duration-500">The panel can't drift. A popover is in the top layer but still in the DOM, so it inherits --tpop19-d from the root like everything else — mid-transition included.
The flip-back rule. On a dark OS the unchecked state is dark and checking must return light; dark:has-checked: is emitted after has-checked: (the dark variant sorts later), so it wins. Without it the switch does nothing for visitors whose OS already matches.
Make it yours
- Snap instead of fade: delete
transition-[--tpop19-d] duration-500. - High-contrast third palette: add a second dial (
--tpop19-hc) and nest anothercolor-mix(). - Theme the page from the panel: move the switch inside the popover —
has-checked:still sees it.
Gotchas — read before shipping
- Without
@propertythe dial is a plain string and cannot transition — it will snap. @theme(notinline) would resolve the mix once on:root, and the dial would stop working per element.sr-only, nothidden, on the checkbox —display:noneremoves it from the tab order.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 125+ | 26+ | 131+ | 125+ |
@property is Chrome 85 / Safari 16.4 / Firefox 128; :has() Chrome 105 / Safari 15.4 / Firefox 121; the floor is anchoring the panel. Without :has() the demo still follows the OS preference.