Tailwind v4MIT licensed

Tailwind Light / Dark Liquid Glass Navbar

A glass navbar that retunes its whole recipe — fill, rim, saturation, field colour — for light or dark, automatically from the OS and manually from a checkbox. The port makes a real architectural choice: rather than doubling every utility with a dark: twin, the bar reads four tokens and the tokens flip in two small CSS rules.

Live Demo
Plain CSSTailwind v4
Try it

Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time

Untitled UI React · Affiliate link

Get Untitled UI Pro

The code

<section class="nbtw-21 grid place-items-center w-full min-h-svh p-5 font-nb21-sans" aria-label="Light and dark mode glass navbar demo">
  <div class="w-[min(720px,100%)] px-5.5 py-10 rounded-[22px] bg-[var(--nb21-stage)] transition-[background] duration-400 motion-reduce:transition-none">
    <input class="peer sr-only" type="checkbox" id="nbtw-21-toggle">
    <header class="flex flex-wrap items-center gap-x-4.5 gap-y-3 px-4 py-3.5 rounded-2xl text-[var(--nb21-fg)] bg-[var(--nb21-glass)] border border-[var(--nb21-rim)] shadow-[inset_0_1px_0_var(--nb21-rim),0_16px_38px_-22px_rgba(0,0,0,.5)] backdrop-blur-[20px] backdrop-saturate-(--nb21-sat) transition-[background-color,color,border-color] duration-400 peer-checked:[&_[data-knob]]:translate-x-[22px] peer-focus-visible:[&_label]:outline-2 peer-focus-visible:[&_label]:outline-current peer-focus-visible:[&_label]:outline-offset-2 motion-reduce:transition-none">
      <span class="text-base font-extrabold">◑ Adaptive</span>
      <nav class="flex flex-wrap gap-1 ms-auto" aria-label="Primary">
        <a class="flex items-center min-h-10 px-3.5 rounded-[10px] no-underline text-current opacity-80 text-[14.5px] font-semibold transition-[background-color,opacity] duration-200 hover:opacity-100 hover:bg-[var(--nb21-field)] focus-visible:outline-2 focus-visible:outline-current focus-visible:outline-offset-2 aria-[current=page]:opacity-100 aria-[current=page]:bg-[var(--nb21-field)]" href="#nbtw-21" aria-current="page">Home</a>
        <a class="flex items-center min-h-10 px-3.5 rounded-[10px] no-underline text-current opacity-80 text-[14.5px] font-semibold transition-[background-color,opacity] duration-200 hover:opacity-100 hover:bg-[var(--nb21-field)] focus-visible:outline-2 focus-visible:outline-current focus-visible:outline-offset-2" href="#nbtw-21">Notes</a>
        <a class="flex items-center min-h-10 px-3.5 rounded-[10px] no-underline text-current opacity-80 text-[14.5px] font-semibold transition-[background-color,opacity] duration-200 hover:opacity-100 hover:bg-[var(--nb21-field)] focus-visible:outline-2 focus-visible:outline-current focus-visible:outline-offset-2" href="#nbtw-21">Files</a>
      </nav>
      <label class="flex items-center w-13 h-7.5 p-[3px] rounded-full cursor-pointer bg-[var(--nb21-field)] border border-[var(--nb21-rim)] transition-colors duration-300" for="nbtw-21-toggle" title="Toggle theme">
        <span class="size-5.5 rounded-full bg-current transition-transform duration-300 ease-nb21 motion-reduce:transition-none" data-knob aria-hidden="true"></span>
        <span class="sr-only">Toggle light / dark theme</span>
      </label>
    </header>
  </div>
</section>
@import "tailwindcss";

@theme {
  --font-nb21-sans: system-ui, 'Segoe UI', sans-serif;
  --ease-nb21: cubic-bezier(0.34, 1.4, 0.5, 1);
}
/* The light token set, and the reason this demo reads tokens instead of
   doubling every utility with a dark: twin. */

.nbtw-21 {
  --nb21-glass: rgba(255, 255, 255, 0.5);
  --nb21-rim: rgba(255, 255, 255, 0.8);
  --nb21-fg: #12131a;
  --nb21-field: rgba(0, 0, 0, 0.08);
  --nb21-sat: 1.7;
  --nb21-stage: linear-gradient(120deg, #dfe9f3, #ffffff);
}
/* Automatic: the OS setting repoints the same five tokens. */

@media (prefers-color-scheme: dark) {
  .nbtw-21 {
    --nb21-glass: rgba(20, 22, 34, 0.5);
    --nb21-rim: rgba(255, 255, 255, 0.4);
    --nb21-fg: #f2f3fb;
    --nb21-field: rgba(255, 255, 255, 0.1);
    --nb21-sat: 1.5;
    --nb21-stage: linear-gradient(120deg, #12141f, #242a44);
  }
}
/* Manual override, last so it wins over the media query. A checkbox read with
   :has() is the whole mechanism — no JavaScript, no flash. */

.nbtw-21:has(#nbtw-21-toggle:checked) {
  --nb21-glass: rgba(20, 22, 34, 0.5);
  --nb21-rim: rgba(255, 255, 255, 0.4);
  --nb21-fg: #f2f3fb;
  --nb21-field: rgba(255, 255, 255, 0.1);
  --nb21-sat: 1.5;
  --nb21-stage: linear-gradient(120deg, #12141f, #242a44);
}

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 {
  --font-nb21-sans: system-ui, 'Segoe UI', sans-serif;
  --ease-nb21: cubic-bezier(0.34, 1.4, 0.5, 1);
}
/* The light token set, and the reason this demo reads tokens instead of
   doubling every utility with a dark: twin. */

.nbtw-21 {
  --nb21-glass: rgba(255, 255, 255, 0.5);
  --nb21-rim: rgba(255, 255, 255, 0.8);
  --nb21-fg: #12131a;
  --nb21-field: rgba(0, 0, 0, 0.08);
  --nb21-sat: 1.7;
  --nb21-stage: linear-gradient(120deg, #dfe9f3, #ffffff);
}
/* Automatic: the OS setting repoints the same five tokens. */

@media (prefers-color-scheme: dark) {
  .nbtw-21 {
    --nb21-glass: rgba(20, 22, 34, 0.5);
    --nb21-rim: rgba(255, 255, 255, 0.4);
    --nb21-fg: #f2f3fb;
    --nb21-field: rgba(255, 255, 255, 0.1);
    --nb21-sat: 1.5;
    --nb21-stage: linear-gradient(120deg, #12141f, #242a44);
  }
}
/* Manual override, last so it wins over the media query. A checkbox read with
   :has() is the whole mechanism — no JavaScript, no flash. */

.nbtw-21:has(#nbtw-21-toggle:checked) {
  --nb21-glass: rgba(20, 22, 34, 0.5);
  --nb21-rim: rgba(255, 255, 255, 0.4);
  --nb21-fg: #f2f3fb;
  --nb21-field: rgba(255, 255, 255, 0.1);
  --nb21-sat: 1.5;
  --nb21-stage: linear-gradient(120deg, #12141f, #242a44);
}

What changed in the port

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

  • The architectural call: read tokens rather than doubling every utility with a dark: twin. One design, two value sets, and a manual override that has something to hook into.
  • The token flips are the deliberate exception to keeping everything in the markup — a theme is exactly the kind of thing that belongs in one place.
  • Saturation is a token because the correct value genuinely differs between light and dark glass; a fixed backdrop-saturate-* looks wrong in one of the two.
  • backdrop-saturate-(--nb21-sat) reads a unitless multiplier, which is why the token is 1.7 and not 170%.
  • The manual override is :has(#toggle:checked) on the root, ordered after the media query so a user's explicit choice beats the OS.
  • The switch is peer mechanics with the sibling caveat handled: both variants sit on the header (the checkbox's actual sibling) and reach the knob and the track with child selectors. Put them on the knob and they compile, never match, and leave a dead-looking control on a working theme.
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 Light / Dark Liquid Glass Navbar
Source: https://codefronts.com/navigation/tailwind-liquid-glass-navbars/tailwind-light-dark-liquid-glass-navbar/

A glass navbar that retunes its whole recipe &mdash; fill, rim, saturation, field colour &mdash; for light or dark, automatically from the OS and manually from a checkbox. The port makes a real architectural choice: rather than doubling every utility with a dark: twin, the bar reads four tokens and the tokens flip in two small CSS rules.
## HTML
```html
<section class="nbtw-21 grid place-items-center w-full min-h-svh p-5 font-nb21-sans" aria-label="Light and dark mode glass navbar demo">
  <div class="w-[min(720px,100%)] px-5.5 py-10 rounded-[22px] bg-[var(--nb21-stage)] transition-[background] duration-400 motion-reduce:transition-none">
    <input class="peer sr-only" type="checkbox" id="nbtw-21-toggle">
    <header class="flex flex-wrap items-center gap-x-4.5 gap-y-3 px-4 py-3.5 rounded-2xl text-[var(--nb21-fg)] bg-[var(--nb21-glass)] border border-[var(--nb21-rim)] shadow-[inset_0_1px_0_var(--nb21-rim),0_16px_38px_-22px_rgba(0,0,0,.5)] backdrop-blur-[20px] backdrop-saturate-(--nb21-sat) transition-[background-color,color,border-color] duration-400 peer-checked:[&_[data-knob]]:translate-x-[22px] peer-focus-visible:[&_label]:outline-2 peer-focus-visible:[&_label]:outline-current peer-focus-visible:[&_label]:outline-offset-2 motion-reduce:transition-none">
      <span class="text-base font-extrabold">◑ Adaptive</span>
      <nav class="flex flex-wrap gap-1 ms-auto" aria-label="Primary">
        <a class="flex items-center min-h-10 px-3.5 rounded-[10px] no-underline text-current opacity-80 text-[14.5px] font-semibold transition-[background-color,opacity] duration-200 hover:opacity-100 hover:bg-[var(--nb21-field)] focus-visible:outline-2 focus-visible:outline-current focus-visible:outline-offset-2 aria-[current=page]:opacity-100 aria-[current=page]:bg-[var(--nb21-field)]" href="#nbtw-21" aria-current="page">Home</a>
        <a class="flex items-center min-h-10 px-3.5 rounded-[10px] no-underline text-current opacity-80 text-[14.5px] font-semibold transition-[background-color,opacity] duration-200 hover:opacity-100 hover:bg-[var(--nb21-field)] focus-visible:outline-2 focus-visible:outline-current focus-visible:outline-offset-2" href="#nbtw-21">Notes</a>
        <a class="flex items-center min-h-10 px-3.5 rounded-[10px] no-underline text-current opacity-80 text-[14.5px] font-semibold transition-[background-color,opacity] duration-200 hover:opacity-100 hover:bg-[var(--nb21-field)] focus-visible:outline-2 focus-visible:outline-current focus-visible:outline-offset-2" href="#nbtw-21">Files</a>
      </nav>
      <label class="flex items-center w-13 h-7.5 p-[3px] rounded-full cursor-pointer bg-[var(--nb21-field)] border border-[var(--nb21-rim)] transition-colors duration-300" for="nbtw-21-toggle" title="Toggle theme">
        <span class="size-5.5 rounded-full bg-current transition-transform duration-300 ease-nb21 motion-reduce:transition-none" data-knob aria-hidden="true"></span>
        <span class="sr-only">Toggle light / dark theme</span>
      </label>
    </header>
  </div>
</section>
```
## CSS
```css
@import "tailwindcss";

@theme {
  --font-nb21-sans: system-ui, 'Segoe UI', sans-serif;
  --ease-nb21: cubic-bezier(0.34, 1.4, 0.5, 1);
}
/* The light token set, and the reason this demo reads tokens instead of
   doubling every utility with a dark: twin. */

.nbtw-21 {
  --nb21-glass: rgba(255, 255, 255, 0.5);
  --nb21-rim: rgba(255, 255, 255, 0.8);
  --nb21-fg: #12131a;
  --nb21-field: rgba(0, 0, 0, 0.08);
  --nb21-sat: 1.7;
  --nb21-stage: linear-gradient(120deg, #dfe9f3, #ffffff);
}
/* Automatic: the OS setting repoints the same five tokens. */

@media (prefers-color-scheme: dark) {
  .nbtw-21 {
    --nb21-glass: rgba(20, 22, 34, 0.5);
    --nb21-rim: rgba(255, 255, 255, 0.4);
    --nb21-fg: #f2f3fb;
    --nb21-field: rgba(255, 255, 255, 0.1);
    --nb21-sat: 1.5;
    --nb21-stage: linear-gradient(120deg, #12141f, #242a44);
  }
}
/* Manual override, last so it wins over the media query. A checkbox read with
   :has() is the whole mechanism — no JavaScript, no flash. */

.nbtw-21:has(#nbtw-21-toggle:checked) {
  --nb21-glass: rgba(20, 22, 34, 0.5);
  --nb21-rim: rgba(255, 255, 255, 0.4);
  --nb21-fg: #f2f3fb;
  --nb21-field: rgba(255, 255, 255, 0.1);
  --nb21-sat: 1.5;
  --nb21-stage: linear-gradient(120deg, #12141f, #242a44);
}
```

How this works

The choice is tokens, not dark: twins. A dark: variant on every utility would double the class list and leave the manual override with nothing to hook into. Reading tokens instead means one design and two value sets:

class="bg-[var(--nb21-glass)] border-[var(--nb21-rim)]
       text-[var(--nb21-fg)] backdrop-saturate-(--nb21-sat)"
@media (prefers-color-scheme: dark) { .nbtw-21 { --nb21-glass: rgba(20,22,34,.5); … } }
.nbtw-21:has(#nbtw-21-toggle:checked) { --nb21-glass: rgba(20,22,34,.5); … }

The glass recipe adapts, not just the colours. Light glass wants a brighter fill and more saturation; dark glass wants a translucent slate and less, or the colours behind it turn lurid. That is why saturation is a token here rather than a fixed utility.

The override is :has(), and it is pure CSS. A native checkbox holds the manual choice, :has() reads it on the root, and the token block flips regardless of the OS setting. No JavaScript, no flash, and the control stays keyboard-operable because it is sr-only rather than hidden.

The switch is peer mechanics, with the trap that catches everyone. peer-* compiles to a sibling selector, and the checkbox's only sibling here is the header — not the knob two levels down. So the variant goes on the header and reaches in with a child selector:

<input class="peer sr-only" …>
<header class="peer-checked:[&_[data-knob]]:translate-x-[22px]
               peer-focus-visible:[&_label]:outline-2">

Written on the knob itself, peer-checked:translate-x-[22px] compiles cleanly and silently never matches — the theme still flips through :has() while the control sits dead.

Make it yours

  • Add themed tokens (shadow, accent, field) rather than more variants; every utility that should adapt reads one of them.
  • Invert the priority by making the checkbox mean "force light" instead, or add a third state with a radio group and one more token block.
  • Persist the manual choice with a few lines of JavaScript and localStorage; the CSS does not change.
  • Use dark: variants instead if only two or three utilities differ — the token approach earns its keep when many do.
  • Retune saturation per theme first if the glass looks wrong after a palette change; it is the token people forget.

Gotchas — read before shipping

  • Redefine the same tokens per theme. Forking whole rule sets per theme is how the two versions drift apart over time.
  • Verify contrast in both themes, dark glass especially — a 50% slate fill over a dark stage is the easiest place to fail.
  • peer-* matches previous siblings only. The knob and the label are descendants of the header, so the variants belong on the header with a child selector bolted on (peer-checked:[&_[data-knob]]:…) — on the knob they compile and never match.
  • Keep the checkbox sr-only, not hidden, or the manual override is unreachable by keyboard.
  • In v4 dark: follows the OS by default. If you want a class-based strategy you must configure it — and if you also ship a :has() override, decide which wins.
  • backdrop-saturate-(--nb21-sat) needs a unitless multiplier in the token (1.7), not a percentage.
  • The :has() rule and the media query can disagree. Ordering the :has() rule last is what makes the manual choice authoritative.

Browser support

ChromeSafariFirefoxEdge
111+16.4+128+111+

Tailwind v4 sets the floor and :has() is inside it. prefers-color-scheme is universal; drop the :has() override for an auto-only version that works even further back.

Techniques used in this demo

Utilitiesfirst-class utility or variant

Search CodeFronts

Loading…