25 Tailwind CSS FAQ Accordion
06 / 25

All 25 in 25 Tailwind CSS FAQ Accordion

  1. 01Tailwind Peer-Checked FAQ Accordion with No JavaScript
  2. 02Tailwind Native Details/Summary FAQ with Custom Styled Icons
  3. 03Tailwind Exclusive Accordion — Only One FAQ Open at a Time
  4. 04Tailwind Multi-Open FAQ Accordion with a Live CSS Counter
  5. 05Tailwind CSS FAQ Accordion Template (Free, Copy-Paste)
  6. 06Tailwind Dark Mode FAQ Accordion with light-dark() and No .dark Class
  7. 07Tailwind Glassmorphism FAQ Accordion with Frosted Backdrop Blur
  8. 08Tailwind Minimalist E-Commerce FAQ Accordion for Product Pages
  9. 09Tailwind Neumorphic Soft-UI FAQ Accordion Cards
  10. 10Tailwind Animate Height Auto FAQ with grid-rows 0fr to 1fr
  11. 11Tailwind FAQ Chevron Rotate 180 Degrees on Toggle
  12. 12Tailwind Animated Plus to Minus Icon Morph FAQ Accordion
  13. 13Tailwind Spring Bounce FAQ Accordion with linear() Easing
  14. 14Tailwind Two-Column FAQ Accordion Grid with Independent Columns
  15. 15Tailwind Horizontal FAQ Accordion with Sideways Expanding Panels
  16. 16Tailwind Categorised FAQ Help Centre with a Sticky Sidebar Menu
  17. 17Tailwind Card-Style FAQ Panels with a Layered Shadow Elevation System
  18. 18Tailwind FAQ Accordion with a Live Search Bar Filter
  19. 19Tailwind Expand All and Collapse All FAQ Accordion Buttons
  20. 20Tailwind FAQ with a Still Have Questions Contact CTA Card
  21. 21Tailwind Accessible ARIA FAQ Accordion with Keyboard Navigation
  22. 22Tailwind FAQ with FAQPage JSON-LD Schema for Rich Snippets and AI Search
  23. 23Bootstrap 5 Accordion Rebuilt in Tailwind (and the JavaScript You Delete)
  24. 24Tailwind FAQ Component in React, Vue, Svelte and Astro (One Port Recipe)
  25. 25Tailwind FAQ with Voice Search Using the Speech Recognition API
Tailwind + JSMIT licensed

Tailwind Dark Mode FAQ Accordion with light-dark() and No .dark Class

The counter-argument to dark:. Every colour is declared once as a light-dark() pair inside @theme, and a single color-scheme flip re-skins the whole component — including native scrollbars, form controls and selection colours that a .dark class never reaches. A true two-way switch that starts from the visitor's OS preference, with zero duplicated utilities, a small seed script, and a View Transition cross-fade.

Live Demo
Plain CSSTailwind v4
Try it

Complete Tailwind CSS landing page templates in HTML, React, Next.js and Vue · from $49

Cruip · Affiliate link

Browse Cruip templates

The code

<section class="tfa-06 group/tfa06 w-full min-h-svh grid place-items-center bg-tfa06-bg font-tfa06-sans text-tfa06-ink px-5 py-12 [color-scheme:light] has-[#tfa06-mode:checked]:[color-scheme:dark]">
  <div class="w-[min(660px,100%)]">
    <div class="flex items-center justify-between gap-4">
      <span class="text-[14px] font-extrabold tracking-[-0.01em]">Nightshift Docs</span>
      <input id="tfa06-mode" type="checkbox" role="switch" class="peer absolute opacity-0 pointer-events-none">
      <label for="tfa06-mode" title="Dark theme" class="flex min-h-11 cursor-pointer items-center gap-2 rounded-full border border-tfa06-edge bg-tfa06-panel px-2.5 py-1.5 text-tfa06-mut hover:border-tfa06-accedge peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-tfa06-acc">
        <span aria-hidden="true" class="text-tfa06-sun"><svg viewBox="0 0 24 24" width="13" height="13"><circle cx="12" cy="12" r="4.4" fill="currentColor"/><g stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M12 2.5v3M12 18.5v3M2.5 12h3M18.5 12h3M5 5l2.1 2.1M16.9 16.9L19 19M19 5l-2.1 2.1M7.1 16.9L5 19"/></g></svg></span>
        <span aria-hidden="true" class="relative h-5 w-9 rounded-full bg-tfa06-edge group-has-[#tfa06-mode:checked]/tfa06:bg-tfa06-acc after:absolute after:left-0.5 after:top-0.5 after:size-4 after:rounded-full after:bg-tfa06-panel after:shadow-[0_1px_4px_var(--color-tfa06-shadow)] after:transition-transform after:duration-300 after:ease-[cubic-bezier(0.34,1.56,0.64,1)] group-has-[#tfa06-mode:checked]/tfa06:after:translate-x-4 motion-reduce:after:transition-none"></span>
        <span aria-hidden="true" class="group-has-[#tfa06-mode:checked]/tfa06:text-tfa06-acc"><svg viewBox="0 0 24 24" width="13" height="13"><path d="M20 14.5A8.5 8.5 0 019.5 4a8.5 8.5 0 1010.5 10.5z" fill="currentColor"/></svg></span>
        <span class="sr-only">Dark theme</span>
      </label>
    </div>

    <header class="mt-7">
      <h1 class="text-[clamp(28px,5vw,38px)] font-extrabold leading-[1.08] tracking-[-0.025em]">Frequently asked questions</h1>
      <p class="mt-3 max-w-[54ch] text-[14.5px] leading-[1.7] text-tfa06-mut">Every colour below is one <code class="rounded bg-tfa06-tint px-1.5 py-0.5 font-tfa06-mono text-[0.87em] text-tfa06-acc">light-dark()</code> token. Flip the switch — or your OS theme — and the whole component re-skins.</p>
    </header>

    <div data-panel class="mt-6 flex flex-col gap-2.5">
      <details class="group rounded-2xl border border-tfa06-edge bg-tfa06-panel shadow-[0_4px_16px_var(--color-tfa06-shadow)] open:border-tfa06-accedge" open>
        <summary class="flex min-h-11 cursor-pointer list-none items-center justify-between gap-4 px-5 py-4 text-[15px] font-semibold hover:text-tfa06-acc focus-visible:rounded-2xl focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tfa06-acc [&::-webkit-details-marker]:hidden">Does dark mode save battery?
          <i aria-hidden="true" class="ml-auto size-2.5 shrink-0 -translate-y-0.5 rotate-45 border-b-2 border-r-2 border-tfa06-mut transition-[rotate,translate] duration-350 group-hover:border-tfa06-acc group-open:translate-y-0.5 group-open:rotate-[225deg] group-open:border-tfa06-acc motion-reduce:transition-none"></i>
        </summary>
        <p class="max-w-[58ch] px-5 pb-4 text-[14px] leading-[1.7] text-tfa06-mut">On OLED screens, meaningfully — black pixels are unlit. On LCD panels the backlight runs regardless, so the gain is comfort, not power.</p>
      </details>
      <details class="group rounded-2xl border border-tfa06-edge bg-tfa06-panel shadow-[0_4px_16px_var(--color-tfa06-shadow)] open:border-tfa06-accedge">
        <summary class="flex min-h-11 cursor-pointer list-none items-center justify-between gap-4 px-5 py-4 text-[15px] font-semibold hover:text-tfa06-acc focus-visible:rounded-2xl focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tfa06-acc [&::-webkit-details-marker]:hidden">Why not just add a .dark class?
          <i aria-hidden="true" class="ml-auto size-2.5 shrink-0 -translate-y-0.5 rotate-45 border-b-2 border-r-2 border-tfa06-mut transition-[rotate,translate] duration-350 group-hover:border-tfa06-acc group-open:translate-y-0.5 group-open:rotate-[225deg] group-open:border-tfa06-acc motion-reduce:transition-none"></i>
        </summary>
        <p class="max-w-[58ch] px-5 pb-4 text-[14px] leading-[1.7] text-tfa06-mut">Class-swapping duplicates every colour rule and misses native UI: scrollbars, form controls and selection colours stay light. <code class="rounded bg-tfa06-tint px-1.5 py-0.5 font-tfa06-mono text-[0.87em] text-tfa06-acc">color-scheme</code> re-skins those for free, and <code class="rounded bg-tfa06-tint px-1.5 py-0.5 font-tfa06-mono text-[0.87em] text-tfa06-acc">light-dark()</code> keeps each token in one place.</p>
      </details>
      <details class="group rounded-2xl border border-tfa06-edge bg-tfa06-panel shadow-[0_4px_16px_var(--color-tfa06-shadow)] open:border-tfa06-accedge">
        <summary class="flex min-h-11 cursor-pointer list-none items-center justify-between gap-4 px-5 py-4 text-[15px] font-semibold hover:text-tfa06-acc focus-visible:rounded-2xl focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tfa06-acc [&::-webkit-details-marker]:hidden">Should dark mode be pure black?
          <i aria-hidden="true" class="ml-auto size-2.5 shrink-0 -translate-y-0.5 rotate-45 border-b-2 border-r-2 border-tfa06-mut transition-[rotate,translate] duration-350 group-hover:border-tfa06-acc group-open:translate-y-0.5 group-open:rotate-[225deg] group-open:border-tfa06-acc motion-reduce:transition-none"></i>
        </summary>
        <p class="max-w-[58ch] px-5 pb-4 text-[14px] leading-[1.7] text-tfa06-mut">Usually no — near-black surfaces (this panel is oklch 0.22) reduce smearing on OLED and let shadows still read. Reserve true black for media canvases.</p>
      </details>
      <details class="group rounded-2xl border border-tfa06-edge bg-tfa06-panel shadow-[0_4px_16px_var(--color-tfa06-shadow)] open:border-tfa06-accedge">
        <summary class="flex min-h-11 cursor-pointer list-none items-center justify-between gap-4 px-5 py-4 text-[15px] font-semibold hover:text-tfa06-acc focus-visible:rounded-2xl focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tfa06-acc [&::-webkit-details-marker]:hidden">How do I persist the user's choice?
          <i aria-hidden="true" class="ml-auto size-2.5 shrink-0 -translate-y-0.5 rotate-45 border-b-2 border-r-2 border-tfa06-mut transition-[rotate,translate] duration-350 group-hover:border-tfa06-acc group-open:translate-y-0.5 group-open:rotate-[225deg] group-open:border-tfa06-acc motion-reduce:transition-none"></i>
        </summary>
        <p class="max-w-[58ch] px-5 pb-4 text-[14px] leading-[1.7] text-tfa06-mut">Store one value ('light' / 'dark' / 'auto') in localStorage and restore it before first paint in a tiny inline script. The utilities need zero changes — you are only setting the checkbox.</p>
      </details>
    </div>

    <p class="mt-6 text-[12.5px] text-tfa06-mut">Starts from your OS preference, then the switch wins both ways. Checked = <code class="rounded bg-tfa06-tint px-1.5 py-0.5 font-tfa06-mono text-[0.87em] text-tfa06-acc">has-[#tfa06-mode:checked]:[color-scheme:dark]</code>.</p>
  </div>
</section>
@import "tailwindcss";

@theme {
  /* Every entry: a plain light value first (pre-2024 floor), then the light-dark() pair. */
  --color-tfa06-bg: #f8f9fc;
  --color-tfa06-bg: light-dark(oklch(0.975 0.008 250), oklch(0.17 0.02 258));
  --color-tfa06-panel: #ffffff;
  --color-tfa06-panel: light-dark(oklch(1 0 0), oklch(0.22 0.022 258));
  --color-tfa06-ink: #2b2f3a;
  --color-tfa06-ink: light-dark(oklch(0.22 0.025 258), oklch(0.96 0.006 250));
  --color-tfa06-mut: #5f6472;
  --color-tfa06-mut: light-dark(oklch(0.47 0.02 255), oklch(0.79 0.015 252));
  --color-tfa06-edge: #e4e6ee;
  --color-tfa06-edge: light-dark(oklch(0.91 0.01 250), oklch(0.32 0.025 256));
  --color-tfa06-accedge: #8ba6e8;
  --color-tfa06-accedge: light-dark(oklch(0.72 0.1 255), oklch(0.55 0.11 250));
  --color-tfa06-tint: #eaeffc;
  --color-tfa06-tint: light-dark(oklch(0.95 0.025 255), oklch(0.27 0.045 252));
  --color-tfa06-acc: #3f5fd0;
  --color-tfa06-acc: light-dark(oklch(0.48 0.19 260), oklch(0.8 0.12 250));
  --color-tfa06-sun: #d99b2b;
  --color-tfa06-sun: light-dark(oklch(0.68 0.14 75), oklch(0.78 0.14 75));
  --color-tfa06-shadow: #d7dae6;
  --color-tfa06-shadow: light-dark(oklch(0.3 0.05 255 / 0.1), oklch(0 0 0 / 0.4));
  --font-tfa06-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
  --font-tfa06-mono: 'JetBrains Mono', ui-monospace, monospace;
}
/* Scheme default is the [color-scheme:light] utility on the root, NOT a rule here:
   unlayered CSS beats Tailwind's utilities layer, so declaring it here would silently defeat
   has-checked:[color-scheme:dark]. */

.tfa-06 {
  width: 100%;
  min-height: 100svh;
}
/* light-dark() tokens do not change computed value when color-scheme flips, so a CSS colour
   transition on them freezes at the start colour in Chromium. The cross-fade is a View
   Transition instead; these two lines just set its timing. */

::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: .45s;
  animation-timing-function: cubic-bezier(.4,0,.2,1);
}

.tfa-06 [data-panel] {
  interpolate-size: allow-keywords;
}

@supports (interpolate-size: allow-keywords) {
  .tfa-06 details::details-content {
    height: 0;
    overflow: clip;
    opacity: 0;
    transition: height .38s cubic-bezier(.4,0,.2,1), opacity .3s, content-visibility .38s allow-discrete;
  }

  .tfa-06 details[open]::details-content {
    height: auto;
    opacity: 1;
  }
}

@media (prefers-reduced-motion: reduce) {
  .tfa-06 details::details-content {
    transition-duration: 1ms;
  }
}
(function(){
  var root=document.querySelector('.tfa-06');if(!root||root.dataset.tfa06)return;root.dataset.tfa06='1';
  var cb=root.querySelector('#tfa06-mode'),mq=window.matchMedia('(prefers-color-scheme: dark)'),touched=false;
  cb.checked=mq.matches;
  var calm=window.matchMedia('(prefers-reduced-motion: reduce)');
  cb.addEventListener('click',function(e){
    touched=true;
    if(!document.startViewTransition||calm.matches)return;
    var next=cb.checked;e.preventDefault();
    document.startViewTransition(function(){cb.checked=next;});
  });
  mq.addEventListener('change',function(e){if(!touched)cb.checked=e.matches;});
})();

Setup — do this once per project

Skip this if your project already runs Tailwind v4.

1Load Tailwind
<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.

2Add the stylesheetRequiredthe @theme block is what makes the custom utilities exist
@import "tailwindcss";

@theme {
  /* Every entry: a plain light value first (pre-2024 floor), then the light-dark() pair. */
  --color-tfa06-bg: #f8f9fc;
  --color-tfa06-bg: light-dark(oklch(0.975 0.008 250), oklch(0.17 0.02 258));
  --color-tfa06-panel: #ffffff;
  --color-tfa06-panel: light-dark(oklch(1 0 0), oklch(0.22 0.022 258));
  --color-tfa06-ink: #2b2f3a;
  --color-tfa06-ink: light-dark(oklch(0.22 0.025 258), oklch(0.96 0.006 250));
  --color-tfa06-mut: #5f6472;
  --color-tfa06-mut: light-dark(oklch(0.47 0.02 255), oklch(0.79 0.015 252));
  --color-tfa06-edge: #e4e6ee;
  --color-tfa06-edge: light-dark(oklch(0.91 0.01 250), oklch(0.32 0.025 256));
  --color-tfa06-accedge: #8ba6e8;
  --color-tfa06-accedge: light-dark(oklch(0.72 0.1 255), oklch(0.55 0.11 250));
  --color-tfa06-tint: #eaeffc;
  --color-tfa06-tint: light-dark(oklch(0.95 0.025 255), oklch(0.27 0.045 252));
  --color-tfa06-acc: #3f5fd0;
  --color-tfa06-acc: light-dark(oklch(0.48 0.19 260), oklch(0.8 0.12 250));
  --color-tfa06-sun: #d99b2b;
  --color-tfa06-sun: light-dark(oklch(0.68 0.14 75), oklch(0.78 0.14 75));
  --color-tfa06-shadow: #d7dae6;
  --color-tfa06-shadow: light-dark(oklch(0.3 0.05 255 / 0.1), oklch(0 0 0 / 0.4));
  --font-tfa06-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
  --font-tfa06-mono: 'JetBrains Mono', ui-monospace, monospace;
}
/* Scheme default is the [color-scheme:light] utility on the root, NOT a rule here:
   unlayered CSS beats Tailwind's utilities layer, so declaring it here would silently defeat
   has-checked:[color-scheme:dark]. */

.tfa-06 {
  width: 100%;
  min-height: 100svh;
}
/* light-dark() tokens do not change computed value when color-scheme flips, so a CSS colour
   transition on them freezes at the start colour in Chromium. The cross-fade is a View
   Transition instead; these two lines just set its timing. */

::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: .45s;
  animation-timing-function: cubic-bezier(.4,0,.2,1);
}

.tfa-06 [data-panel] {
  interpolate-size: allow-keywords;
}

@supports (interpolate-size: allow-keywords) {
  .tfa-06 details::details-content {
    height: 0;
    overflow: clip;
    opacity: 0;
    transition: height .38s cubic-bezier(.4,0,.2,1), opacity .3s, content-visibility .38s allow-discrete;
  }

  .tfa-06 details[open]::details-content {
    height: auto;
    opacity: 1;
  }
}

@media (prefers-reduced-motion: reduce) {
  .tfa-06 details::details-content {
    transition-duration: 1ms;
  }
}
3Fonts used by this demooptional — skip if you have your own type stack
<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=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap">

What changed in the port

Which parts became utilities, which stayed CSS, and why.

  • The whole demo hinges on one decision: light-dark() pairs go inside @theme, not :root. In @theme they still mint bg-*/text-*/border-* utilities, so the markup carries no dark: prefixes at all — the inverse of demo 05's approach, and the honest comparison between the two.
  • .faq:has(#mode:checked){color-scheme:dark} becomes has-[#tfa06-mode:checked]:[color-scheme:dark] on the root. The default light dark scheme is also a utility, deliberately: an unlayered CSS rule outranks Tailwind's utilities layer and would make the toggle a no-op.
  • Two-way by design: a checkbox over light dark can only ever force dark, which is a no-op for every visitor already on a dark OS. Defaulting to light and seeding the checkbox from matchMedia keeps OS-first behaviour while letting the switch move both ways, and a change listener keeps following the OS until the user takes over.
  • Switch internals use a named group (group/tfa06 + group-has-[#tfa06-mode:checked]/tfa06:): peer-checked: only styles the peer's following siblings, never their children, so the track, knob and moon inside the label need the root-level relationship.
  • The transition trap: the first build faded the whole page with transition-[background-color,color]. Because a light-dark() token's computed value is the same function in both schemes, Chromium never saw a change to animate toward and froze every transitioned surface on its light colour. Colour transitions are gone from every scheme-dependent surface; a View Transition cross-fades the snapshot instead, skipped under reduced motion.
  • The knob animation is the one place a spring curve earned its keep: ease-[cubic-bezier(0.34,1.56,0.64,1)] overshoots about 4%, which is what makes a toggle feel switched rather than moved.
  • UI upgrade from the CSS original: light-mode muted ink moved oklch(0.5) → 0.47 and dark-mode 0.68 → 0.79; both schemes now clear 4.5:1, where the dark one previously sat near 3.6:1.
  • UI upgrade: the sun glyph got its own light-dark token instead of a hardcoded warm yellow that washed out on the light panel, and the switch track now tints on peer-checked: so the control state does not rest on knob position alone.
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
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 FAQ Accordion with light-dark() and No .dark Class
Source: https://codefronts.com/snippets/tailwind-css-faq-accordion/tailwind-dark-mode-faq-accordion-light-dark/

The counter-argument to dark:. Every colour is declared once as a light-dark() pair inside @theme, and a single color-scheme flip re-skins the whole component — including native scrollbars, form controls and selection colours that a .dark class never reaches. A true two-way switch that starts from the visitor's OS preference, with zero duplicated utilities, a small seed script, and a View Transition cross-fade.
## HTML
```html
<section class="tfa-06 group/tfa06 w-full min-h-svh grid place-items-center bg-tfa06-bg font-tfa06-sans text-tfa06-ink px-5 py-12 [color-scheme:light] has-[#tfa06-mode:checked]:[color-scheme:dark]">
  <div class="w-[min(660px,100%)]">
    <div class="flex items-center justify-between gap-4">
      <span class="text-[14px] font-extrabold tracking-[-0.01em]">Nightshift Docs</span>
      <input id="tfa06-mode" type="checkbox" role="switch" class="peer absolute opacity-0 pointer-events-none">
      <label for="tfa06-mode" title="Dark theme" class="flex min-h-11 cursor-pointer items-center gap-2 rounded-full border border-tfa06-edge bg-tfa06-panel px-2.5 py-1.5 text-tfa06-mut hover:border-tfa06-accedge peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-tfa06-acc">
        <span aria-hidden="true" class="text-tfa06-sun"><svg viewBox="0 0 24 24" width="13" height="13"><circle cx="12" cy="12" r="4.4" fill="currentColor"/><g stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M12 2.5v3M12 18.5v3M2.5 12h3M18.5 12h3M5 5l2.1 2.1M16.9 16.9L19 19M19 5l-2.1 2.1M7.1 16.9L5 19"/></g></svg></span>
        <span aria-hidden="true" class="relative h-5 w-9 rounded-full bg-tfa06-edge group-has-[#tfa06-mode:checked]/tfa06:bg-tfa06-acc after:absolute after:left-0.5 after:top-0.5 after:size-4 after:rounded-full after:bg-tfa06-panel after:shadow-[0_1px_4px_var(--color-tfa06-shadow)] after:transition-transform after:duration-300 after:ease-[cubic-bezier(0.34,1.56,0.64,1)] group-has-[#tfa06-mode:checked]/tfa06:after:translate-x-4 motion-reduce:after:transition-none"></span>
        <span aria-hidden="true" class="group-has-[#tfa06-mode:checked]/tfa06:text-tfa06-acc"><svg viewBox="0 0 24 24" width="13" height="13"><path d="M20 14.5A8.5 8.5 0 019.5 4a8.5 8.5 0 1010.5 10.5z" fill="currentColor"/></svg></span>
        <span class="sr-only">Dark theme</span>
      </label>
    </div>

    <header class="mt-7">
      <h1 class="text-[clamp(28px,5vw,38px)] font-extrabold leading-[1.08] tracking-[-0.025em]">Frequently asked questions</h1>
      <p class="mt-3 max-w-[54ch] text-[14.5px] leading-[1.7] text-tfa06-mut">Every colour below is one <code class="rounded bg-tfa06-tint px-1.5 py-0.5 font-tfa06-mono text-[0.87em] text-tfa06-acc">light-dark()</code> token. Flip the switch — or your OS theme — and the whole component re-skins.</p>
    </header>

    <div data-panel class="mt-6 flex flex-col gap-2.5">
      <details class="group rounded-2xl border border-tfa06-edge bg-tfa06-panel shadow-[0_4px_16px_var(--color-tfa06-shadow)] open:border-tfa06-accedge" open>
        <summary class="flex min-h-11 cursor-pointer list-none items-center justify-between gap-4 px-5 py-4 text-[15px] font-semibold hover:text-tfa06-acc focus-visible:rounded-2xl focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tfa06-acc [&::-webkit-details-marker]:hidden">Does dark mode save battery?
          <i aria-hidden="true" class="ml-auto size-2.5 shrink-0 -translate-y-0.5 rotate-45 border-b-2 border-r-2 border-tfa06-mut transition-[rotate,translate] duration-350 group-hover:border-tfa06-acc group-open:translate-y-0.5 group-open:rotate-[225deg] group-open:border-tfa06-acc motion-reduce:transition-none"></i>
        </summary>
        <p class="max-w-[58ch] px-5 pb-4 text-[14px] leading-[1.7] text-tfa06-mut">On OLED screens, meaningfully — black pixels are unlit. On LCD panels the backlight runs regardless, so the gain is comfort, not power.</p>
      </details>
      <details class="group rounded-2xl border border-tfa06-edge bg-tfa06-panel shadow-[0_4px_16px_var(--color-tfa06-shadow)] open:border-tfa06-accedge">
        <summary class="flex min-h-11 cursor-pointer list-none items-center justify-between gap-4 px-5 py-4 text-[15px] font-semibold hover:text-tfa06-acc focus-visible:rounded-2xl focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tfa06-acc [&::-webkit-details-marker]:hidden">Why not just add a .dark class?
          <i aria-hidden="true" class="ml-auto size-2.5 shrink-0 -translate-y-0.5 rotate-45 border-b-2 border-r-2 border-tfa06-mut transition-[rotate,translate] duration-350 group-hover:border-tfa06-acc group-open:translate-y-0.5 group-open:rotate-[225deg] group-open:border-tfa06-acc motion-reduce:transition-none"></i>
        </summary>
        <p class="max-w-[58ch] px-5 pb-4 text-[14px] leading-[1.7] text-tfa06-mut">Class-swapping duplicates every colour rule and misses native UI: scrollbars, form controls and selection colours stay light. <code class="rounded bg-tfa06-tint px-1.5 py-0.5 font-tfa06-mono text-[0.87em] text-tfa06-acc">color-scheme</code> re-skins those for free, and <code class="rounded bg-tfa06-tint px-1.5 py-0.5 font-tfa06-mono text-[0.87em] text-tfa06-acc">light-dark()</code> keeps each token in one place.</p>
      </details>
      <details class="group rounded-2xl border border-tfa06-edge bg-tfa06-panel shadow-[0_4px_16px_var(--color-tfa06-shadow)] open:border-tfa06-accedge">
        <summary class="flex min-h-11 cursor-pointer list-none items-center justify-between gap-4 px-5 py-4 text-[15px] font-semibold hover:text-tfa06-acc focus-visible:rounded-2xl focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tfa06-acc [&::-webkit-details-marker]:hidden">Should dark mode be pure black?
          <i aria-hidden="true" class="ml-auto size-2.5 shrink-0 -translate-y-0.5 rotate-45 border-b-2 border-r-2 border-tfa06-mut transition-[rotate,translate] duration-350 group-hover:border-tfa06-acc group-open:translate-y-0.5 group-open:rotate-[225deg] group-open:border-tfa06-acc motion-reduce:transition-none"></i>
        </summary>
        <p class="max-w-[58ch] px-5 pb-4 text-[14px] leading-[1.7] text-tfa06-mut">Usually no — near-black surfaces (this panel is oklch 0.22) reduce smearing on OLED and let shadows still read. Reserve true black for media canvases.</p>
      </details>
      <details class="group rounded-2xl border border-tfa06-edge bg-tfa06-panel shadow-[0_4px_16px_var(--color-tfa06-shadow)] open:border-tfa06-accedge">
        <summary class="flex min-h-11 cursor-pointer list-none items-center justify-between gap-4 px-5 py-4 text-[15px] font-semibold hover:text-tfa06-acc focus-visible:rounded-2xl focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tfa06-acc [&::-webkit-details-marker]:hidden">How do I persist the user's choice?
          <i aria-hidden="true" class="ml-auto size-2.5 shrink-0 -translate-y-0.5 rotate-45 border-b-2 border-r-2 border-tfa06-mut transition-[rotate,translate] duration-350 group-hover:border-tfa06-acc group-open:translate-y-0.5 group-open:rotate-[225deg] group-open:border-tfa06-acc motion-reduce:transition-none"></i>
        </summary>
        <p class="max-w-[58ch] px-5 pb-4 text-[14px] leading-[1.7] text-tfa06-mut">Store one value ('light' / 'dark' / 'auto') in localStorage and restore it before first paint in a tiny inline script. The utilities need zero changes — you are only setting the checkbox.</p>
      </details>
    </div>

    <p class="mt-6 text-[12.5px] text-tfa06-mut">Starts from your OS preference, then the switch wins both ways. Checked = <code class="rounded bg-tfa06-tint px-1.5 py-0.5 font-tfa06-mono text-[0.87em] text-tfa06-acc">has-[#tfa06-mode:checked]:[color-scheme:dark]</code>.</p>
  </div>
</section>
```
## CSS
```css
@import "tailwindcss";

@theme {
  /* Every entry: a plain light value first (pre-2024 floor), then the light-dark() pair. */
  --color-tfa06-bg: #f8f9fc;
  --color-tfa06-bg: light-dark(oklch(0.975 0.008 250), oklch(0.17 0.02 258));
  --color-tfa06-panel: #ffffff;
  --color-tfa06-panel: light-dark(oklch(1 0 0), oklch(0.22 0.022 258));
  --color-tfa06-ink: #2b2f3a;
  --color-tfa06-ink: light-dark(oklch(0.22 0.025 258), oklch(0.96 0.006 250));
  --color-tfa06-mut: #5f6472;
  --color-tfa06-mut: light-dark(oklch(0.47 0.02 255), oklch(0.79 0.015 252));
  --color-tfa06-edge: #e4e6ee;
  --color-tfa06-edge: light-dark(oklch(0.91 0.01 250), oklch(0.32 0.025 256));
  --color-tfa06-accedge: #8ba6e8;
  --color-tfa06-accedge: light-dark(oklch(0.72 0.1 255), oklch(0.55 0.11 250));
  --color-tfa06-tint: #eaeffc;
  --color-tfa06-tint: light-dark(oklch(0.95 0.025 255), oklch(0.27 0.045 252));
  --color-tfa06-acc: #3f5fd0;
  --color-tfa06-acc: light-dark(oklch(0.48 0.19 260), oklch(0.8 0.12 250));
  --color-tfa06-sun: #d99b2b;
  --color-tfa06-sun: light-dark(oklch(0.68 0.14 75), oklch(0.78 0.14 75));
  --color-tfa06-shadow: #d7dae6;
  --color-tfa06-shadow: light-dark(oklch(0.3 0.05 255 / 0.1), oklch(0 0 0 / 0.4));
  --font-tfa06-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
  --font-tfa06-mono: 'JetBrains Mono', ui-monospace, monospace;
}
/* Scheme default is the [color-scheme:light] utility on the root, NOT a rule here:
   unlayered CSS beats Tailwind's utilities layer, so declaring it here would silently defeat
   has-checked:[color-scheme:dark]. */

.tfa-06 {
  width: 100%;
  min-height: 100svh;
}
/* light-dark() tokens do not change computed value when color-scheme flips, so a CSS colour
   transition on them freezes at the start colour in Chromium. The cross-fade is a View
   Transition instead; these two lines just set its timing. */

::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: .45s;
  animation-timing-function: cubic-bezier(.4,0,.2,1);
}

.tfa-06 [data-panel] {
  interpolate-size: allow-keywords;
}

@supports (interpolate-size: allow-keywords) {
  .tfa-06 details::details-content {
    height: 0;
    overflow: clip;
    opacity: 0;
    transition: height .38s cubic-bezier(.4,0,.2,1), opacity .3s, content-visibility .38s allow-discrete;
  }

  .tfa-06 details[open]::details-content {
    height: auto;
    opacity: 1;
  }
}

@media (prefers-reduced-motion: reduce) {
  .tfa-06 details::details-content {
    transition-duration: 1ms;
  }
}
```

## JavaScript
```js
(function(){
  var root=document.querySelector('.tfa-06');if(!root||root.dataset.tfa06)return;root.dataset.tfa06='1';
  var cb=root.querySelector('#tfa06-mode'),mq=window.matchMedia('(prefers-color-scheme: dark)'),touched=false;
  cb.checked=mq.matches;
  var calm=window.matchMedia('(prefers-reduced-motion: reduce)');
  cb.addEventListener('click',function(e){
    touched=true;
    if(!document.startViewTransition||calm.matches)return;
    var next=cb.checked;e.preventDefault();
    document.startViewTransition(function(){cb.checked=next;});
  });
  mq.addEventListener('change',function(e){if(!touched)cb.checked=e.matches;});
})();
```

How this works

Tokens once, scheme flips free. Each token carries both values; color-scheme decides which is live. In v4 this belongs in @theme, where it still mints utilities:

@theme {
  --color-tfa06-panel: light-dark(oklch(1 0 0), oklch(0.22 0.022 258));
  --color-tfa06-ink:   light-dark(oklch(0.22 0.025 258), oklch(0.96 0.006 250));
}

So bg-tfa06-panel is a single class that is white in light mode and near-black in dark — no dark:bg-* twin anywhere in the markup. Compare demo 05, which spends eleven dark: prefixes to do the same job: both are correct, and this is the one that scales as the palette grows.

The override is one variant. A hidden checkbox and two classes on the root — [color-scheme:light] and has-[#tfa06-mode:checked]:[color-scheme:dark]. A five-line script pre-checks the box from prefers-color-scheme, so the page opens in the OS theme and the switch can then force either direction. (Leaving the default at light dark is the trap: on a dark OS the unchecked state is already dark, so the switch appears to do nothing and light can never be forced.) Both must be utilities: a plain .tfa-06{color-scheme:…} rule is unlayered and would beat the variant, which is the classic reason a Tailwind v4 theme toggle silently does nothing.

Because the switch changes color-scheme rather than swapping classes, everything native follows automatically — scrollbars, the <details> internals, text selection, form controls. That is the part a class-based dark mode structurally cannot do.

Why the pairs live in @theme and not :root: a plain custom property would work for var() use, but it would not generate bg-*, text-* and border-* utilities. Tokens that want to be utilities go in @theme; tokens that must not (the counters in demo 04) stay out.

Make it yours

  • Follow-OS only: delete the toggle, the script and both scheme classes, and use [color-scheme:light_dark] — the pairs resolve from the OS with no control at all.
  • Three-way switch: swap the checkbox for radios and add a second has-[...]:[color-scheme:light] rule alongside the dark one.
  • Persist the choice: write cb.checked to localStorage on change and prefer it over the OS value in the seed script. No CSS or utility changes.
  • Accent per scheme: the accent is a light-dark() pair too — dark grounds need roughly +0.2 oklch lightness to hold 4.5:1.

Gotchas — read before shipping

  • light-dark() only works for COLOUR values. Shadows and gradients must compose a colour token via var(), which is exactly what the shadow utility here does.
  • Without color-scheme set, light-dark() always resolves to the light value — declaring the pairs is not enough; the scheme property is the switch.
  • [color-scheme:dark] as an arbitrary property is version-proof; Tailwind's own scheme-* utilities arrived later in the v4 line.
  • Never put transition-colors (or any colour transition) on a surface painted with light-dark() tokens. The computed value does not change when color-scheme flips, so Chromium keeps the start colour and you get light text on a stuck light background. Use a View Transition for the fade.
  • Test forced-dark on a light OS and vice versa. The classic bug is a hardcoded white shadow that only shows in one combination.

Browser support

ChromeSafariFirefoxEdge
129+not yetnot yet129+

Floor set by oklch(), interpolate-size as this demo is written. The core technique itself goes back further — Chrome 123+.

light-dark() shipped across all engines during 2024. Older browsers ignore the declaration and render the light theme, which stays fully legible — the @theme block declares a plain light value first for exactly that reason.

Techniques used in this demo

Utilitiesfirst-class utility or variant

Search CodeFronts

Loading…