Tailwind v4MIT licensed

Tailwind Dynamic Island Morphing Nav

A glass pill that morphs between a slim capsule and a full navigation panel — and the clearest demonstration of why data-* variants beat class toggling. The script sets data-state to idle or expanded and manages ARIA; every geometric consequence, including the panel's delayed fade, is declared in the markup next to the resting value.

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-06 relative flex flex-col items-center w-full min-h-svh pt-15 px-5 font-nb06-sans bg-[radial-gradient(circle_at_30%_30%,#0f3d2e,#05100b_70%),url('https://picsum.photos/seed/nb06/1000/700')] bg-center bg-cover [background-blend-mode:overlay]" aria-label="Dynamic Island navigation demo">
  <p class="mb-5 text-sm text-white/75">Tap the island to morph it open ↑</p>
  <div class="group relative overflow-hidden w-46 h-[46px] rounded-full isolate bg-[color-mix(in_oklab,black_40%,transparent)] border border-white/[0.22] shadow-[inset_0_1px_0_rgba(255,255,255,.4),0_24px_50px_-22px_rgba(0,0,0,.8)] backdrop-blur-[28px] backdrop-saturate-[1.6] transition-[width,height,border-radius] duration-[550ms] ease-nb06 data-[state=expanded]:w-75 data-[state=expanded]:h-38 data-[state=expanded]:rounded-[30px] motion-reduce:transition-none" id="nbtw-06-island" data-state="idle">
    <button class="absolute top-0 left-0 z-[2] flex items-center justify-center gap-2.5 w-full h-[46px] border-0 bg-transparent text-white text-[15px] font-bold leading-none cursor-pointer focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2" id="nbtw-06-trigger" type="button" aria-expanded="false" aria-controls="nbtw-06-panel">
      <span class="flex-none size-[11px] rounded-full bg-nb06-accent shadow-[0_0_12px_var(--color-nb06-accent)] animate-nb06-pulse motion-reduce:animate-none" aria-hidden="true"></span>
      <span class="inline-block leading-none">Lumen</span>
    </button>
    <div class="absolute inset-x-0 top-[46px] bottom-0 grid grid-cols-2 gap-2 px-4 pb-4 opacity-0 pointer-events-none transition-opacity duration-200 group-data-[state=expanded]:opacity-100 group-data-[state=expanded]:pointer-events-auto group-data-[state=expanded]:duration-300 group-data-[state=expanded]:delay-200 motion-reduce:transition-none" id="nbtw-06-panel" role="group" aria-label="Quick navigation">
      <a class="flex items-center justify-center min-h-10 rounded-xl no-underline text-white text-sm font-semibold bg-white/10 border border-white/[0.16] transition-colors duration-200 hover:bg-white/20 focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2 aria-[current=page]:bg-nb06-accent aria-[current=page]:text-[#04120b]" href="#nbtw-06" aria-current="page">Home</a>
      <a class="flex items-center justify-center min-h-10 rounded-xl no-underline text-white text-sm font-semibold bg-white/10 border border-white/[0.16] transition-colors duration-200 hover:bg-white/20 focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2" href="#nbtw-06">Search</a>
      <a class="flex items-center justify-center min-h-10 rounded-xl no-underline text-white text-sm font-semibold bg-white/10 border border-white/[0.16] transition-colors duration-200 hover:bg-white/20 focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2" href="#nbtw-06">Alerts</a>
      <a class="flex items-center justify-center min-h-10 rounded-xl no-underline text-white text-sm font-semibold bg-white/10 border border-white/[0.16] transition-colors duration-200 hover:bg-white/20 focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2" href="#nbtw-06">You</a>
    </div>
  </div>
</section>
@import "tailwindcss";

@theme {
  --color-nb06-accent: #3fd18b;
  --color-nb06-accent: oklch(0.7 0.18 150);
  --font-nb06-sans: system-ui, 'Segoe UI', sans-serif;
  --ease-nb06: cubic-bezier(0.34, 1.4, 0.5, 1);
  --animate-nb06-pulse: nb06-pulse 2.4s ease-in-out infinite;
}

@keyframes nb06-pulse {
  50% {
    transform: scale(1.35);
    opacity: 0.6;
  }
}
(() => {
  const island = document.querySelector('#nbtw-06-island');
  const trigger = document.querySelector('#nbtw-06-trigger');
  const panel = document.querySelector('#nbtw-06-panel');
  if (!island || !trigger || !panel) return;
  const set = (open) => {
    island.dataset.state = open ? 'expanded' : 'idle';
    trigger.setAttribute('aria-expanded', String(open));
    if (open) panel.querySelector('a').focus();
  };
  trigger.addEventListener('click', () => set(island.dataset.state !== 'expanded'));
  island.addEventListener('keydown', (e) => {
    if (e.key === 'Escape') { set(false); trigger.focus(); }
  });
})();

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 {
  --color-nb06-accent: #3fd18b;
  --color-nb06-accent: oklch(0.7 0.18 150);
  --font-nb06-sans: system-ui, 'Segoe UI', sans-serif;
  --ease-nb06: cubic-bezier(0.34, 1.4, 0.5, 1);
  --animate-nb06-pulse: nb06-pulse 2.4s ease-in-out infinite;
}

@keyframes nb06-pulse {
  50% {
    transform: scale(1.35);
    opacity: 0.6;
  }
}

What changed in the port

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

  • The script sets one attribute and the ARIA that goes with it. Every pixel of the morph — width, height, radius, the panel's fade and its delay — is declared in the markup as a variant of that attribute.
  • Both geometries sit in one class list, which is what makes the morph reviewable: you can see the resting and expanded numbers side by side instead of diffing two rule blocks.
  • group-data-[state=expanded]:delay-200 is the whole trick behind the shape reading as liquid: the container moves first, the contents arrive after.
  • The expanded size is hard-coded because transitions need resolvable numbers at both ends. auto would snap, and that is a CSS constraint the port cannot remove.
  • pointer-events-none at rest, group-data-[state=expanded]:pointer-events-auto when open — otherwise the collapsed panel intercepts clicks aimed at the pill.
  • Adding a third state costs an attribute value and one data-[state=...] set. No new selectors, which is the practical argument for state-as-data over state-as-class.
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 Dynamic Island Morphing Nav
Source: https://codefronts.com/navigation/tailwind-liquid-glass-navbars/tailwind-dynamic-island-morphing-nav/

A glass pill that morphs between a slim capsule and a full navigation panel &mdash; and the clearest demonstration of why data-* variants beat class toggling. The script sets data-state to idle or expanded and manages ARIA; every geometric consequence, including the panel's delayed fade, is declared in the markup next to the resting value.
## HTML
```html
<section class="nbtw-06 relative flex flex-col items-center w-full min-h-svh pt-15 px-5 font-nb06-sans bg-[radial-gradient(circle_at_30%_30%,#0f3d2e,#05100b_70%),url('https://picsum.photos/seed/nb06/1000/700')] bg-center bg-cover [background-blend-mode:overlay]" aria-label="Dynamic Island navigation demo">
  <p class="mb-5 text-sm text-white/75">Tap the island to morph it open ↑</p>
  <div class="group relative overflow-hidden w-46 h-[46px] rounded-full isolate bg-[color-mix(in_oklab,black_40%,transparent)] border border-white/[0.22] shadow-[inset_0_1px_0_rgba(255,255,255,.4),0_24px_50px_-22px_rgba(0,0,0,.8)] backdrop-blur-[28px] backdrop-saturate-[1.6] transition-[width,height,border-radius] duration-[550ms] ease-nb06 data-[state=expanded]:w-75 data-[state=expanded]:h-38 data-[state=expanded]:rounded-[30px] motion-reduce:transition-none" id="nbtw-06-island" data-state="idle">
    <button class="absolute top-0 left-0 z-[2] flex items-center justify-center gap-2.5 w-full h-[46px] border-0 bg-transparent text-white text-[15px] font-bold leading-none cursor-pointer focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2" id="nbtw-06-trigger" type="button" aria-expanded="false" aria-controls="nbtw-06-panel">
      <span class="flex-none size-[11px] rounded-full bg-nb06-accent shadow-[0_0_12px_var(--color-nb06-accent)] animate-nb06-pulse motion-reduce:animate-none" aria-hidden="true"></span>
      <span class="inline-block leading-none">Lumen</span>
    </button>
    <div class="absolute inset-x-0 top-[46px] bottom-0 grid grid-cols-2 gap-2 px-4 pb-4 opacity-0 pointer-events-none transition-opacity duration-200 group-data-[state=expanded]:opacity-100 group-data-[state=expanded]:pointer-events-auto group-data-[state=expanded]:duration-300 group-data-[state=expanded]:delay-200 motion-reduce:transition-none" id="nbtw-06-panel" role="group" aria-label="Quick navigation">
      <a class="flex items-center justify-center min-h-10 rounded-xl no-underline text-white text-sm font-semibold bg-white/10 border border-white/[0.16] transition-colors duration-200 hover:bg-white/20 focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2 aria-[current=page]:bg-nb06-accent aria-[current=page]:text-[#04120b]" href="#nbtw-06" aria-current="page">Home</a>
      <a class="flex items-center justify-center min-h-10 rounded-xl no-underline text-white text-sm font-semibold bg-white/10 border border-white/[0.16] transition-colors duration-200 hover:bg-white/20 focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2" href="#nbtw-06">Search</a>
      <a class="flex items-center justify-center min-h-10 rounded-xl no-underline text-white text-sm font-semibold bg-white/10 border border-white/[0.16] transition-colors duration-200 hover:bg-white/20 focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2" href="#nbtw-06">Alerts</a>
      <a class="flex items-center justify-center min-h-10 rounded-xl no-underline text-white text-sm font-semibold bg-white/10 border border-white/[0.16] transition-colors duration-200 hover:bg-white/20 focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2" href="#nbtw-06">You</a>
    </div>
  </div>
</section>
```
## CSS
```css
@import "tailwindcss";

@theme {
  --color-nb06-accent: #3fd18b;
  --color-nb06-accent: oklch(0.7 0.18 150);
  --font-nb06-sans: system-ui, 'Segoe UI', sans-serif;
  --ease-nb06: cubic-bezier(0.34, 1.4, 0.5, 1);
  --animate-nb06-pulse: nb06-pulse 2.4s ease-in-out infinite;
}

@keyframes nb06-pulse {
  50% {
    transform: scale(1.35);
    opacity: 0.6;
  }
}
```

## JavaScript
```js
(() => {
  const island = document.querySelector('#nbtw-06-island');
  const trigger = document.querySelector('#nbtw-06-trigger');
  const panel = document.querySelector('#nbtw-06-panel');
  if (!island || !trigger || !panel) return;
  const set = (open) => {
    island.dataset.state = open ? 'expanded' : 'idle';
    trigger.setAttribute('aria-expanded', String(open));
    if (open) panel.querySelector('a').focus();
  };
  trigger.addEventListener('click', () => set(island.dataset.state !== 'expanded'));
  island.addEventListener('keydown', (e) => {
    if (e.key === 'Escape') { set(false); trigger.focus(); }
  });
})();
```

How this works

The state is an attribute, and the design is a variant of it. Both geometries are typed on the same element, so the morph is readable as a diff:

class="group w-46 h-[46px] rounded-full
       transition-[width,height,border-radius] duration-[550ms] ease-nb06
       data-[state=expanded]:w-75
       data-[state=expanded]:h-38
       data-[state=expanded]:rounded-[30px]"

Fixed values, not auto. A transition needs two resolvable numbers at both ends. This is why both states carry explicit width and height utilities — w-auto or h-auto would simply snap.

The panel is deliberately late. group-data-[state=expanded]:delay-200 holds the contents back until the container has largely finished travelling, which is the difference between a shape that morphs and a box that grows with text sliding around inside it.

Keyboard users get the same morph. The trigger is a button carrying aria-expanded; opening moves focus to the first action, and Escape collapses and returns focus to the trigger. The collapsed pill is fully operable on its own, so nothing depends on the animation.

Make it yours

  • Add states by extending the attribute (data-state="call") and typing a matching data-[state=call]: set — no new selectors, no new stylesheet.
  • Tune the morph feel with duration-[550ms] and --ease-nb06; a lower overshoot in the curve reads as glass, a higher one as rubber.
  • Anchor it at the top centre (top-2 left-1/2 -translate-x-1/2 fixed) for a true notch-island position.
  • Change the expanded footprint by editing the two data-[state=expanded] size utilities; the panel grid adapts on its own.
  • Give the panel its own entrance by adding group-data-[state=expanded]:translate-y-0 against a resting translate-y-1.

Gotchas — read before shipping

  • Animate fixed dimensions, never auto. This is the single reason the expanded size is hard-coded rather than content-derived.
  • Keep the collapsed pill a real labelled button. If the only way in is a hover-revealed control, the component is broken before the animation even runs.
  • Return focus to the trigger on collapse, or a keyboard user is left focused on a pointer-events-none panel.
  • group-data-* needs group on the island, not on the section — a mis-scoped group is the usual cause of a panel that never appears.
  • Width and height are layout properties. This morph is cheap because the island is small; do not scale the same pattern to a full-screen sheet.
  • The pulse is an infinite loop on resting chrome. It stays small and slow for that reason, and motion-reduce:animate-none stops it outright.

Browser support

ChromeSafariFirefoxEdge
111+16.4+128+111+

Tailwind v4 sets the floor. The morph is progressive enhancement: with no JS the island stays in its resting state as a labelled button, and the panel links remain reachable in the DOM order.

Techniques used in this demo

Plain CSSno Tailwind surface

HTML & JSno Tailwind involved

Search CodeFronts

Loading…