How to build a Dynamic Island in React and Tailwind

A black pill at the top of the page that grows into a notice and shrinks back on its own, or opens into a search field with Ctrl/Cmd+K. The shape change is plain CSS transitions on width, height and border radius, so there is no animation library to install.

By Teodor Avadani · Updated October 25, 2026

The idea

The island has a few states, and each state has one size. Switching state changes the pill's inline width, height and border radius, and a CSS transition with a slightly overshooting curve animates between them. The content inside is remounted with key, so each state fades in with a short blur while the shape is still moving. That is most of the effect.

A notify function you can call from anywhere

A window event keeps it small: no context provider, and it works from any client component or plain function.

island.ts
/** Show a short message in the island from anywhere: notify("Saved") */
export function notify(text: string) {
  window.dispatchEvent(new CustomEvent<string>("island:notify", { detail: text }));
}

The island component

DynamicIsland.tsx
"use client";

import { useEffect, useRef, useState } from "react";

type Mode = "idle" | "notice" | "search";

// One size per state. The pill animates between them with CSS transitions.
const SIZE: Record<Mode, { width: number; height: number; radius: number }> = {
  idle: { width: 180, height: 40, radius: 20 },
  notice: { width: 320, height: 64, radius: 28 },
  search: { width: 380, height: 56, radius: 28 },
};

export function DynamicIsland({ onSearch }: { onSearch?: (query: string) => void }) {
  const [mode, setMode] = useState<Mode>("idle");
  const [notice, setNotice] = useState("");
  const ref = useRef<HTMLDivElement>(null);
  const timer = useRef<number | undefined>(undefined);

  // A notice grows the island, then it shrinks back on its own.
  useEffect(() => {
    const onNotify = (e: Event) => {
      setNotice((e as CustomEvent<string>).detail);
      setMode("notice");
      window.clearTimeout(timer.current);
      timer.current = window.setTimeout(() => setMode((m) => (m === "notice" ? "idle" : m)), 2500);
    };
    window.addEventListener("island:notify", onNotify);
    return () => {
      window.removeEventListener("island:notify", onNotify);
      window.clearTimeout(timer.current);
    };
  }, []);

  // Ctrl+K / Cmd+K opens search. Escape or a tap outside closes it.
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if (e.key.toLowerCase() === "k" && (e.metaKey || e.ctrlKey)) {
        e.preventDefault();
        setMode("search");
      } else if (e.key === "Escape") {
        setMode((m) => (m === "search" ? "idle" : m));
      }
    };
    const onDown = (e: PointerEvent) => {
      if (!ref.current?.contains(e.target as Node)) setMode((m) => (m === "search" ? "idle" : m));
    };
    window.addEventListener("keydown", onKey);
    document.addEventListener("pointerdown", onDown);
    return () => {
      window.removeEventListener("keydown", onKey);
      document.removeEventListener("pointerdown", onDown);
    };
  }, []);

  const size = SIZE[mode];

  return (
    <div className="pointer-events-none fixed inset-x-0 top-3 z-50 flex justify-center px-3">
      <div
        ref={ref}
        style={{ width: size.width, height: size.height, borderRadius: size.radius }}
        className="pointer-events-auto max-w-full overflow-hidden bg-black text-white shadow-2xl shadow-black/40 transition-[width,height,border-radius] duration-500 ease-[cubic-bezier(.3,1.35,.5,1)] motion-reduce:transition-none"
      >
        {/* key={mode} remounts the content, so each state fades in after the shape starts moving */}
        <div key={mode} className="size-full motion-safe:animate-[island-in_.35s_.08s_ease-out_both]">
          {mode === "idle" && (
            <button
              type="button"
              onClick={() => setMode("search")}
              className="flex size-full items-center justify-between px-4 text-sm text-zinc-300"
            >
              <span>Search</span>
              <kbd className="font-sans text-xs text-zinc-500">⌘K</kbd>
            </button>
          )}
          {mode === "notice" && (
            <div className="flex size-full items-center gap-3 px-5">
              <span className="grid size-8 shrink-0 place-items-center rounded-full bg-emerald-400 text-sm text-black" aria-hidden="true">
                ✓
              </span>
              <span className="truncate text-sm font-medium">{notice}</span>
            </div>
          )}
          {mode === "search" && (
            <form
              role="search"
              className="flex size-full items-center px-5"
              onSubmit={(e) => {
                e.preventDefault();
                const query = new FormData(e.currentTarget).get("q");
                onSearch?.(String(query ?? ""));
                setMode("idle");
              }}
            >
              <label htmlFor="island-q" className="sr-only">Search</label>
              <input
                id="island-q"
                name="q"
                autoFocus
                placeholder="Search"
                className="w-full bg-transparent text-sm outline-none placeholder:text-zinc-500"
              />
            </form>
          )}
        </div>
      </div>
      {/* Screen readers hear notices from a region that is always in the page */}
      <p aria-live="polite" className="sr-only">{mode === "notice" ? notice : ""}</p>
    </div>
  );
}

cubic-bezier(.3,1.35,.5,1) goes a little past the target size and settles back, which is what makes it feel springy. max-w-full with px-3 on the wrapper keeps the wide states inside a phone screen. The notice timer only closes a notice, so a notice that ends while search is open does not close the search.

The fade-in and where to put it

Add the keyframe to your global CSS. motion-safe: on the content and motion-reduce:transition-none on the pill turn both off for people who asked for less motion.

app/globals.css
@keyframes island-in {
  from { opacity: 0; filter: blur(4px); transform: scale(0.96); }
  to { opacity: 1; filter: none; transform: none; }
}

Render the island once in your root layout. It is a client component, and the layout can stay a server component.

app/layout.tsx
import { DynamicIsland } from "@/components/island/DynamicIsland";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <DynamicIsland />
        {children}
      </body>
    </html>
  );
}

Then call notify after something finishes, for example a save:

SaveButton.tsx
"use client";

import { notify } from "./island";

export function SaveButton() {
  return (
    <button type="button" onClick={() => notify("Changes saved")} className="rounded-lg bg-zinc-800 px-4 py-2 text-sm">
      Save
    </button>
  );
}

What we checked

In Chrome, in a Next.js 15.5 app with React 19 and Tailwind v4, at 1440px and 390px: the pill rests at 180 by 40px, a notice grows it to 320 by 64px and it returns to 180 by 40px about 2.5 seconds later. A click or Ctrl+K opens search at 380px (366px on the 390px screen) with the input focused. Enter passed the query to onSearch and closed it, and Escape and a click outside closed it too. The live region read "Changes saved". With reduced motion on, the pill jumped straight to 320px.

Using Cursor, v0 or Lovable

Cursor: paste the three files and ask it to call notify() where your app already shows toasts. Keep the size table; asking it to animate with auto widths will not work, because CSS cannot transition to auto.

v0: generated islands usually use Framer Motion's layout prop. That works too; if you want no extra package, paste DynamicIsland.tsx and ask v0 to keep the CSS transition.

Lovable: React with Vite works the same. Put <DynamicIsland /> in App.tsx instead of a layout and the keyframe in index.css.

Or use the ready-made piece

If you want the full navbar, Dynamic Island Navbar holds four icons and an avatar at rest, widens into a notification that closes itself, grows into a music player with artwork, an equaliser and controls, and turns into a search field that grows downward as matching results drop in, with the typed part highlighted.

Everything above is free to copy. The piece is for when you want the finished, animated version with its source and a PROMPT.md for Cursor, v0 and Lovable.

Questions

How do I make a Dynamic Island in React?
Give the pill one width, height and border radius per state, set them as inline styles, and animate them with a CSS transition. Remount the content with key so each state fades in.
Can I animate the island's width without Framer Motion?
Yes. Use fixed sizes per state and transition-[width,height,border-radius] in Tailwind. CSS cannot transition to width auto, which is why the sizes are numbers.
How do I make the Dynamic Island animation feel springy?
Use a cubic-bezier that overshoots, like cubic-bezier(.3,1.35,.5,1), over about 500 ms. The pill goes slightly past its size and settles back.
How do I show a notification in the island from another component?
Dispatch a window CustomEvent from a small notify function and listen for it in the island. A polite live region reads the message to screen readers.

Read next

Dynamic Island in React and Tailwind: a Morphing Pill · qodesign