Tailwind v4MIT licensed

Tailwind Sliding Capsule Tab Bar (group-has)

A segmented glass tab bar where the capsule slides behind the selected item with no JavaScript — and the port that best justifies itself. The CSS version needs four :has() rules in a stylesheet; here the whole state table lives on the capsule as group-has-[#nbtw-17-2:checked]:translate-x-[86px], one utility per tab, readable in place.

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-17 grid place-items-center w-full min-h-svh p-5 font-nb17-sans bg-[conic-gradient(from_200deg_at_50%_50%,#00c2ff,#8e2de2,#ff6a88,#00c2ff)] bg-[length:200%_200%] animate-nb17-bg motion-reduce:animate-none" aria-label="Sliding capsule active tab demo">
  <div class="group relative flex p-[7px] rounded-full isolate bg-[color-mix(in_oklab,white_14%,transparent)] border border-white/40 shadow-[inset_0_1px_0_rgba(255,255,255,.55),0_20px_44px_-24px_rgba(0,0,0,.55)] backdrop-blur-[22px] backdrop-saturate-[1.8]">
    <span class="absolute inset-y-[7px] left-[7px] z-0 w-[86px] rounded-full bg-white/[0.26] border border-white/[0.55] shadow-[inset_0_1px_0_rgba(255,255,255,.7)] transition-transform duration-[450ms] ease-nb17 group-has-[#nbtw-17-2:checked]:translate-x-[86px] group-has-[#nbtw-17-3:checked]:translate-x-[172px] group-has-[#nbtw-17-4:checked]:translate-x-[258px] motion-reduce:transition-none" aria-hidden="true"></span>
    <input class="peer sr-only" type="radio" name="nbtw-17" id="nbtw-17-1" checked>
    <label class="relative z-[1] flex items-center justify-center w-[86px] min-h-10 rounded-full cursor-pointer text-white/85 text-[14.5px] font-semibold transition-colors duration-250 peer-checked:text-white peer-focus-visible:outline-2 peer-focus-visible:outline-white peer-focus-visible:outline-offset-2" for="nbtw-17-1">Day</label>
    <input class="peer sr-only" type="radio" name="nbtw-17" id="nbtw-17-2">
    <label class="relative z-[1] flex items-center justify-center w-[86px] min-h-10 rounded-full cursor-pointer text-white/85 text-[14.5px] font-semibold transition-colors duration-250 peer-checked:text-white peer-focus-visible:outline-2 peer-focus-visible:outline-white peer-focus-visible:outline-offset-2" for="nbtw-17-2">Week</label>
    <input class="peer sr-only" type="radio" name="nbtw-17" id="nbtw-17-3">
    <label class="relative z-[1] flex items-center justify-center w-[86px] min-h-10 rounded-full cursor-pointer text-white/85 text-[14.5px] font-semibold transition-colors duration-250 peer-checked:text-white peer-focus-visible:outline-2 peer-focus-visible:outline-white peer-focus-visible:outline-offset-2" for="nbtw-17-3">Month</label>
    <input class="peer sr-only" type="radio" name="nbtw-17" id="nbtw-17-4">
    <label class="relative z-[1] flex items-center justify-center w-[86px] min-h-10 rounded-full cursor-pointer text-white/85 text-[14.5px] font-semibold transition-colors duration-250 peer-checked:text-white peer-focus-visible:outline-2 peer-focus-visible:outline-white peer-focus-visible:outline-offset-2" for="nbtw-17-4">Year</label>
  </div>
</section>
@import "tailwindcss";

@theme {
  --font-nb17-sans: system-ui, 'Segoe UI', sans-serif;
  --ease-nb17: cubic-bezier(0.34, 1.56, 0.5, 1);
  --animate-nb17-bg: nb17-bg 20s ease-in-out infinite;
}

@keyframes nb17-bg {
  50% {
    background-position: 100% 100%;
  }
}

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-nb17-sans: system-ui, 'Segoe UI', sans-serif;
  --ease-nb17: cubic-bezier(0.34, 1.56, 0.5, 1);
  --animate-nb17-bg: nb17-bg 20s ease-in-out infinite;
}

@keyframes nb17-bg {
  50% {
    background-position: 100% 100%;
  }
}

What changed in the port

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

  • This is the port that most clearly pays for itself: four :has() rules become four utilities on the capsule, and the whole state table is visible at once instead of living in a stylesheet.
  • group-has-[#id:checked] is the key — group on the bar lets the capsule ask about a sibling input's state, which no plain child selector can do.
  • The radios are sr-only, not hidden, so they keep their place in the tab order and native arrow-key movement between tabs survives the port.
  • peer-focus-visible: paints the focus ring on the visible label instead of the invisible input — the pattern that makes visually-hidden controls usable.
  • peer-checked:text-white replaces the worst selector in the hand-written version, a #id:checked ~ label[for=id] chain repeated four times.
  • The literal offsets are the trade-off: changing the tab width means editing the width and all three translate values, which is the price of having them in one readable place.
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 Sliding Capsule Tab Bar (group-has)
Source: https://codefronts.com/navigation/tailwind-liquid-glass-navbars/tailwind-sliding-capsule-tab-bar-group-has/

A segmented glass tab bar where the capsule slides behind the selected item with no JavaScript &mdash; and the port that best justifies itself. The CSS version needs four :has() rules in a stylesheet; here the whole state table lives on the capsule as group-has-[#nbtw-17-2:checked]:translate-x-[86px], one utility per tab, readable in place.
## HTML
```html
<section class="nbtw-17 grid place-items-center w-full min-h-svh p-5 font-nb17-sans bg-[conic-gradient(from_200deg_at_50%_50%,#00c2ff,#8e2de2,#ff6a88,#00c2ff)] bg-[length:200%_200%] animate-nb17-bg motion-reduce:animate-none" aria-label="Sliding capsule active tab demo">
  <div class="group relative flex p-[7px] rounded-full isolate bg-[color-mix(in_oklab,white_14%,transparent)] border border-white/40 shadow-[inset_0_1px_0_rgba(255,255,255,.55),0_20px_44px_-24px_rgba(0,0,0,.55)] backdrop-blur-[22px] backdrop-saturate-[1.8]">
    <span class="absolute inset-y-[7px] left-[7px] z-0 w-[86px] rounded-full bg-white/[0.26] border border-white/[0.55] shadow-[inset_0_1px_0_rgba(255,255,255,.7)] transition-transform duration-[450ms] ease-nb17 group-has-[#nbtw-17-2:checked]:translate-x-[86px] group-has-[#nbtw-17-3:checked]:translate-x-[172px] group-has-[#nbtw-17-4:checked]:translate-x-[258px] motion-reduce:transition-none" aria-hidden="true"></span>
    <input class="peer sr-only" type="radio" name="nbtw-17" id="nbtw-17-1" checked>
    <label class="relative z-[1] flex items-center justify-center w-[86px] min-h-10 rounded-full cursor-pointer text-white/85 text-[14.5px] font-semibold transition-colors duration-250 peer-checked:text-white peer-focus-visible:outline-2 peer-focus-visible:outline-white peer-focus-visible:outline-offset-2" for="nbtw-17-1">Day</label>
    <input class="peer sr-only" type="radio" name="nbtw-17" id="nbtw-17-2">
    <label class="relative z-[1] flex items-center justify-center w-[86px] min-h-10 rounded-full cursor-pointer text-white/85 text-[14.5px] font-semibold transition-colors duration-250 peer-checked:text-white peer-focus-visible:outline-2 peer-focus-visible:outline-white peer-focus-visible:outline-offset-2" for="nbtw-17-2">Week</label>
    <input class="peer sr-only" type="radio" name="nbtw-17" id="nbtw-17-3">
    <label class="relative z-[1] flex items-center justify-center w-[86px] min-h-10 rounded-full cursor-pointer text-white/85 text-[14.5px] font-semibold transition-colors duration-250 peer-checked:text-white peer-focus-visible:outline-2 peer-focus-visible:outline-white peer-focus-visible:outline-offset-2" for="nbtw-17-3">Month</label>
    <input class="peer sr-only" type="radio" name="nbtw-17" id="nbtw-17-4">
    <label class="relative z-[1] flex items-center justify-center w-[86px] min-h-10 rounded-full cursor-pointer text-white/85 text-[14.5px] font-semibold transition-colors duration-250 peer-checked:text-white peer-focus-visible:outline-2 peer-focus-visible:outline-white peer-focus-visible:outline-offset-2" for="nbtw-17-4">Year</label>
  </div>
</section>
```
## CSS
```css
@import "tailwindcss";

@theme {
  --font-nb17-sans: system-ui, 'Segoe UI', sans-serif;
  --ease-nb17: cubic-bezier(0.34, 1.56, 0.5, 1);
  --animate-nb17-bg: nb17-bg 20s ease-in-out infinite;
}

@keyframes nb17-bg {
  50% {
    background-position: 100% 100%;
  }
}
```

How this works

The state table moves into the markup. Four stylesheet rules become four utilities on one element:

class="absolute inset-y-[7px] left-[7px] w-[86px] rounded-full
       transition-transform duration-[450ms] ease-nb17
       group-has-[#nbtw-17-2:checked]:translate-x-[86px]
       group-has-[#nbtw-17-3:checked]:translate-x-[172px]
       group-has-[#nbtw-17-4:checked]:translate-x-[258px]"

Adding a fifth tab is now one more utility next to the others, rather than a new rule in a file somewhere else — and the whole table is visible at once, which is what makes an off-by-one in the offsets obvious.

Radios must stay focusable. sr-only clips them; hidden would remove them from the tab order and delete native arrow-key movement between options, which is the accessibility win of using radios in the first place.

The ring goes on the label. The input is invisible, so peer-focus-visible: on each label paints the ring where the user is actually looking — the sibling-variant pattern that makes visually-hidden form controls viable.

peer-checked: replaces the checked-sibling selector. The selected label brightens through a variant rather than a #id:checked ~ label[for=id] chain, which is the single ugliest selector in the hand-written version.

Make it yours

  • Add tabs by appending an input/label pair and one more group-has-[#id:checked]:translate-x-* utility; keep the offsets multiples of the tab width.
  • Change the capsule feel by editing --ease-nb17 — more overshoot reads springier, less reads mechanical.
  • Go vertical by swapping the translate axis to translate-y-* and stacking the bar with flex-col.
  • Widen the tabs by changing both the label width and the capsule width plus every offset — they are one measurement in three places.
  • Drop the capsule and use peer-checked:bg-white/25 on the labels for a simpler version with no offsets to maintain.

Gotchas — read before shipping

  • Every offset must equal the tab width times its index. Because they are literal values, a width change means editing all of them — the honest cost of moving the table into the markup.
  • Use sr-only, never hidden or display:none, on the radios. Hidden inputs are unfocusable and you lose native arrow-key movement.
  • group-has-[...] needs group on the ancestor that contains both the capsule and the inputs.
  • Arbitrary variant selectors are matched literally, so the ids inside group-has-[#nbtw-17-2:checked] must be namespaced or two copies of the demo on one page will drive each other.
  • Keep the capsule width equal to the label width or the highlight sits visibly off-centre at one end of the row.
  • :has() is what makes the no-JS version possible; on older engines the tabs still work as radios, they just do not slide.

Browser support

ChromeSafariFirefoxEdge
111+16.4+128+111+

Tailwind v4's floor already includes :has() (Chrome 105+, Safari 15.4+, Firefox 121+), so the no-JS slide is inside the baseline. Older engines get a static checked highlight and a fully usable tab bar.

Techniques used in this demo

Plain CSSno Tailwind surface

Search CodeFronts

Loading…