# Launch Dock: Mac-Style App Dock for React

> A dock of app icons under a window. Pick one and its window grows up out of the icon in 0.3s, over the open one, which dissolves where it is. One label glides over the dock as you point, and with autoplay the dot under the open app fills like a timer before the next one launches.

Mac-Style App Dock for React: pick an icon and its window grows up out of the dock. Built with Tailwind CSS and Motion; install it with the shadcn CLI. It's a KeyframeUI component: the file lands in your project as source, yours to edit.

- Docs and live preview: https://keyframeui.com/components/launch-dock
- Tags: Showcase, Testimonials, Motion
- npm dependencies: motion, clsx, tailwind-merge
- Inspired by: [ToDesktop](https://www.todesktop.com/)

## Installation

Register the @keyframeui registry in `components.json` once per project:

```json
{
  "registries": {
    "@keyframeui": "https://keyframeui.com/r/{name}.json"
  }
}
```

Then add the component. It lands in `components/ui/launch-dock.tsx`, and the CLI also adds `lib/utils.ts` (the `cn` helper) and installs the npm dependencies:

```bash
npx shadcn@latest add @keyframeui/launch-dock
```

Or install by URL, without registering the namespace:

```bash
npx shadcn@latest add https://keyframeui.com/r/launch-dock.json
```

## Usage

The main demo shows typical usage.

### Customer Apps

Six apps built on one sync platform, each with its own window. Autoplay walks through them until you pick one, and the line under the dock follows the open app.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/launch-dock-demo
```

```tsx title="components/launch-dock-demo.tsx"
"use client";

import * as React from "react";

import { LaunchDock, type LaunchDockItem } from "@/components/ui/launch-dock";

const photo = (id: string, w: number, h: number) =>
  `https://images.unsplash.com/photo-${id}?auto=format&fit=crop&w=${w}&h=${h}&q=80`;

/* ── Icons ─────────────────────────────────────────────── */

function Tile({ from, to, children }: { from: string; to: string; children: React.ReactNode }) {
  const id = React.useId();
  return (
    <svg viewBox="0 0 64 64" aria-hidden>
      <defs>
        <linearGradient id={`${id}-bg`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor={from} />
          <stop offset="1" stopColor={to} />
        </linearGradient>
        <linearGradient id={`${id}-sheen`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="#fff" stopOpacity="0.28" />
          <stop offset="0.5" stopColor="#fff" stopOpacity="0" />
        </linearGradient>
      </defs>
      <rect width="64" height="64" fill={`url(#${id}-bg)`} />
      <rect width="64" height="64" fill={`url(#${id}-sheen)`} />
      {children}
    </svg>
  );
}

const icons = {
  plume: (
    <Tile from="#7c83ff" to="#4338ca">
      <path d="M44 15c-11 1-21 9-24 21l-3 12 3 1 4-9c9-1 17-7 20-16-4 2-7 3-11 3 5-3 9-7 11-12Z" fill="#fff" />
    </Tile>
  ),
  cadence: (
    <Tile from="#ff8a73" to="#e8433f">
      <rect x="16" y="18" width="32" height="30" rx="6" fill="#fff" />
      <rect x="16" y="18" width="32" height="8" rx="4" fill="#ffd6cf" />
      <g fill="#e8433f">
        <rect x="21" y="31" width="5" height="5" rx="1.5" />
        <rect x="29.5" y="31" width="5" height="5" rx="1.5" opacity="0.35" />
        <rect x="38" y="31" width="5" height="5" rx="1.5" opacity="0.35" />
        <rect x="21" y="39" width="5" height="5" rx="1.5" opacity="0.35" />
        <rect x="29.5" y="39" width="5" height="5" rx="1.5" opacity="0.35" />
      </g>
    </Tile>
  ),
  tally: (
    <Tile from="#34d399" to="#047857">
      <g fill="#fff">
        <rect x="17" y="34" width="7" height="13" rx="2" opacity="0.7" />
        <rect x="28.5" y="26" width="7" height="21" rx="2" opacity="0.85" />
        <rect x="40" y="17" width="7" height="30" rx="2" />
      </g>
    </Tile>
  ),
  loop: (
    <Tile from="#fb7185" to="#f97316">
      <circle cx="32" cy="32" r="15" fill="none" stroke="#fff" strokeWidth="5" />
      <circle cx="32" cy="32" r="4.5" fill="#fff" />
    </Tile>
  ),
  tessera: (
    <Tile from="#fde047" to="#f59e0b">
      <rect x="15" y="15" width="20" height="20" rx="5" fill="#fff" />
      <circle cx="39" cy="39" r="11" fill="#fff" fillOpacity="0.7" />
    </Tile>
  ),
  pigeon: (
    <Tile from="#38bdf8" to="#0369a1">
      <path d="M17 22a7 7 0 0 1 7-7h16a7 7 0 0 1 7 7v12a7 7 0 0 1-7 7h-9l-8 7v-7a6 6 0 0 1-6-6Z" fill="#fff" />
      <g fill="#0ea5e9">
        <circle cx="25" cy="28" r="2.5" />
        <circle cx="32" cy="28" r="2.5" />
        <circle cx="39" cy="28" r="2.5" />
      </g>
    </Tile>
  ),
};

/* ── Shared bits for the app windows ───────────────────── */

const ink = "text-zinc-900 dark:text-zinc-100";
const muted = "text-zinc-500 dark:text-zinc-400";
const hairline = "border-zinc-950/[0.07] dark:border-white/[0.07]";

function Face({ initials, className }: { initials: string; className: string }) {
  return (
    <span
      className={`grid size-7 shrink-0 place-items-center rounded-full text-[11px] font-semibold text-white ring-2 ring-white dark:ring-zinc-900 ${className}`}
    >
      {initials}
    </span>
  );
}

function Side({ title, children }: { title: string; children: React.ReactNode }) {
  return (
    <aside className={`flex w-[212px] shrink-0 flex-col gap-6 border-r bg-zinc-50/70 px-4 py-5 dark:bg-white/[0.02] ${hairline}`}>
      <div className={`flex items-center gap-2 text-[14px] font-semibold ${ink}`}>{title}</div>
      {children}
    </aside>
  );
}

function SideRow({ children, on, count }: { children: React.ReactNode; on?: boolean; count?: number }) {
  return (
    <div
      className={`flex items-center justify-between rounded-md px-2 py-1.5 text-[13px] ${
        on ? `bg-zinc-950/[0.05] font-medium dark:bg-white/[0.07] ${ink}` : muted
      }`}
    >
      <span className="truncate">{children}</span>
      {count ? <span className="text-[11px] tabular-nums opacity-70">{count}</span> : null}
    </div>
  );
}

/* ── The apps ──────────────────────────────────────────── */

function PlumeApp() {
  return (
    <div className="flex h-full bg-white dark:bg-zinc-900">
      <Side title="Halden Lab">
        <div className="flex flex-col gap-0.5">
          <SideRow count={4}>Inbox</SideRow>
          <SideRow>Drafts</SideRow>
          <SideRow>Shared with me</SideRow>
        </div>
        <div className="flex flex-col gap-0.5">
          <p className={`px-2 pb-1 text-[11px] font-medium uppercase tracking-wide ${muted}`}>Research</p>
          <SideRow on>Field notes: Lisbon</SideRow>
          <SideRow>Interview guide v3</SideRow>
          <SideRow>Survey synthesis</SideRow>
          <SideRow>Q4 research plan</SideRow>
        </div>
      </Side>
      <main className="relative flex-1 px-14 py-9">
        <div className="flex items-center justify-between">
          <p className={`text-[12px] ${muted}`}>Research / Field notes</p>
          <div className="flex items-center gap-2">
            <div className="flex -space-x-2">
              <Face initials="IM" className="bg-indigo-500" />
              <Face initials="TS" className="bg-amber-500" />
              <Face initials="MC" className="bg-emerald-500" />
            </div>
            <span className={`text-[12px] ${muted}`}>3 editing</span>
          </div>
        </div>
        <h1 className={`mt-8 text-[30px] font-semibold tracking-[-0.02em] ${ink}`}>Field notes: Lisbon offsite</h1>
        <p className={`mt-2 text-[12px] ${muted}`}>Edited just now by Inês Moreau</p>
        <div className={`mt-7 max-w-[456px] space-y-4 text-[14.5px] leading-[1.7] ${ink}`}>
          <p>
            Six interviews<span className="relative mx-px inline-block h-[18px] w-0.5 translate-y-[3px] bg-amber-500">
              <span className="absolute -top-5 left-0 rounded-sm bg-amber-500 px-1 py-px text-[10px] font-semibold whitespace-nowrap text-white">
                Tomás
              </span>
            </span>{" "}
            over two days, all with people who run small studios. The pattern was the same
            every time: the work happens in a shared doc, and the doc is never where the decision ends up.
          </p>
          <p>
            <span className="rounded-sm bg-indigo-500/15 px-0.5">
              Nobody trusts the copy they&apos;re looking at to be the latest one
            </span>
            , so they paste it into chat to be sure. Two of them keep a printed version
 on the wall.
          </p>
        </div>
        <div className={`absolute top-[236px] right-7 w-[176px] rounded-lg border bg-white p-3 shadow-sm dark:bg-zinc-800 ${hairline}`}>
          <div className="flex items-center gap-2">
            <Face initials="IM" className="size-5 bg-indigo-500 text-[9px] ring-0" />
            <span className={`text-[12px] font-medium ${ink}`}>Inês</span>
          </div>
          <p className={`mt-1.5 text-[12px] leading-snug ${muted}`}>Can we lead the readout with this line?</p>
        </div>
      </main>
    </div>
  );
}

const week = ["Mon 9", "Tue 10", "Wed 11", "Thu 12", "Fri 13"];
const events = [
  { day: 0, start: 10, end: 11, title: "Design crit", tone: "bg-rose-500/15 text-rose-700 dark:text-rose-300 border-rose-500" },
  { day: 1, start: 9.5, end: 10, title: "1:1 Priya", tone: "bg-sky-500/15 text-sky-700 dark:text-sky-300 border-sky-500" },
  { day: 1, start: 13, end: 15, title: "Pricing workshop", tone: "bg-amber-500/15 text-amber-700 dark:text-amber-300 border-amber-500" },
  { day: 2, start: 11, end: 12.5, title: "Launch review", tone: "bg-rose-500/15 text-rose-700 dark:text-rose-300 border-rose-500" },
  { day: 3, start: 9, end: 12, title: "Focus: Q4 plan", tone: "bg-zinc-500/10 text-zinc-600 dark:text-zinc-300 border-zinc-400" },
  { day: 3, start: 14, end: 15, title: "Hiring sync", tone: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border-emerald-500" },
  { day: 4, start: 15, end: 16.5, title: "Offsite planning", tone: "bg-sky-500/15 text-sky-700 dark:text-sky-300 border-sky-500" },
];

function CadenceApp() {
  const hour = 52;
  return (
    <div className="flex h-full flex-col bg-white dark:bg-zinc-900">
      <header className={`flex items-center justify-between border-b px-6 py-4 ${hairline}`}>
        <div className="flex items-baseline gap-3">
          <h1 className={`text-[20px] font-semibold tracking-[-0.01em] ${ink}`}>November 2026</h1>
          <span className={`text-[13px] ${muted}`}>Week 46</span>
        </div>
        <div className="flex items-center gap-2">
          <span className={`rounded-md border px-2.5 py-1 text-[12px] font-medium ${hairline} ${ink}`}>Today</span>
          <div className="flex rounded-md bg-zinc-950/[0.05] p-0.5 text-[12px] dark:bg-white/[0.06]">
            <span className={`rounded px-2.5 py-0.5 ${muted}`}>Day</span>
            <span className={`rounded bg-white px-2.5 py-0.5 font-medium shadow-sm dark:bg-zinc-700 ${ink}`}>Week</span>
            <span className={`rounded px-2.5 py-0.5 ${muted}`}>Month</span>
          </div>
        </div>
      </header>
      <div className="flex flex-1 overflow-hidden">
        <div className="w-14 shrink-0 pt-10">
          {[9, 10, 11, 12, 13, 14, 15, 16].map((h) => (
            <div key={h} style={{ height: hour }} className={`-mt-2 pr-2 text-right text-[11px] tabular-nums ${muted}`}>
              {h > 12 ? h - 12 : h} {h >= 12 ? "PM" : "AM"}
            </div>
          ))}
        </div>
        {week.map((day, d) => (
          <div key={day} className={`relative flex-1 border-l ${hairline}`}>
            <div className={`flex h-10 items-center gap-1.5 px-3 text-[12px] ${d === 3 ? "font-semibold text-rose-600 dark:text-rose-400" : muted}`}>
              {day}
            </div>
            {[0, 1, 2, 3, 4, 5, 6, 7].map((h) => (
              <div key={h} style={{ height: hour }} className={`border-t ${hairline}`} />
            ))}
            {events
              .filter((event) => event.day === d)
              .map((event) => (
                <div
                  key={event.title}
                  style={{ top: 40 + (event.start - 9) * hour + 2, height: (event.end - event.start) * hour - 4 }}
                  className={`absolute inset-x-1.5 overflow-hidden rounded-md border-l-[3px] px-2 py-1 text-[11.5px] leading-tight font-medium ${event.tone}`}
                >
                  {event.title}
                </div>
              ))}
            {d === 3 ? (
              <div style={{ top: 40 + 1.6 * hour }} className="absolute -left-1 right-0 flex items-center">
                <span className="size-2 rounded-full bg-rose-500" />
                <span className="h-px flex-1 bg-rose-500" />
              </div>
            ) : null}
          </div>
        ))}
      </div>
    </div>
  );
}

const spend = [32, 18, 44, 26, 12, 58, 22, 30, 16, 40, 24, 64, 20, 28, 36, 14, 48, 26, 18, 34, 52, 20, 30, 12];
const ledger = [
  { name: "Salary · Halden Lab", when: "Nov 1", amount: "+$4,200.00", plus: true },
  { name: "Rent · Calle da Luna", when: "Nov 1", amount: "−$1,350.00" },
  { name: "Bakehouse on Rua Nova", when: "Today", amount: "−$8.40" },
  { name: "Metro pass", when: "Yesterday", amount: "−$42.00" },
  { name: "Livraria Sá da Costa", when: "Nov 10", amount: "−$27.90" },
];

function TallyApp() {
  return (
    <div className="flex h-full bg-zinc-50 dark:bg-zinc-950">
      <nav className={`flex w-16 shrink-0 flex-col items-center gap-4 border-r bg-white py-5 dark:bg-zinc-900 ${hairline}`}>
        <span className="size-8 rounded-lg bg-emerald-600" />
        {[0, 1, 2, 3].map((i) => (
          <span key={i} className={`size-7 rounded-md ${i === 0 ? "bg-emerald-500/15" : "bg-zinc-950/[0.05] dark:bg-white/[0.06]"}`} />
        ))}
      </nav>
      <main className="flex flex-1 flex-col gap-5 p-7">
        <div className="flex items-end justify-between">
          <div>
            <p className={`text-[13px] ${muted}`}>Left to spend in November</p>
            <p className={`mt-1 text-[38px] font-semibold tracking-[-0.03em] tabular-nums ${ink}`}>$2,481.20</p>
          </div>
          <span className="rounded-full bg-emerald-500/15 px-3 py-1 text-[12px] font-medium text-emerald-700 dark:text-emerald-300">
            $312 under plan
          </span>
        </div>
        <div className={`rounded-xl border bg-white p-5 dark:bg-zinc-900 ${hairline}`}>
          <div className="flex items-center justify-between">
            <p className={`text-[13px] font-medium ${ink}`}>Daily spending</p>
            <p className={`text-[12px] ${muted}`}>Nov 1 – 24</p>
          </div>
          <div className="mt-4 flex h-[92px] items-end gap-[7px]">
            {spend.map((value, i) => (
              <span
                key={i}
                style={{ height: `${value * 1.4}px` }}
                className={`flex-1 rounded-t-[3px] ${i === spend.length - 1 ? "bg-emerald-500" : "bg-emerald-500/30"}`}
              />
            ))}
          </div>
        </div>
        <div className={`flex-1 rounded-xl border bg-white px-5 py-2 dark:bg-zinc-900 ${hairline}`}>
          {ledger.map((row) => (
            <div key={row.name} className={`flex items-center justify-between border-b py-3 last:border-0 ${hairline}`}>
              <div>
                <p className={`text-[13.5px] font-medium ${ink}`}>{row.name}</p>
                <p className={`text-[12px] ${muted}`}>{row.when}</p>
              </div>
              <p className={`text-[13.5px] font-medium tabular-nums ${row.plus ? "text-emerald-600 dark:text-emerald-400" : ink}`}>
                {row.amount}
              </p>
            </div>
          ))}
        </div>
      </main>
    </div>
  );
}

const tracks = [
  { name: "Low Sun", time: "3:41" },
  { name: "Yellow Hours", time: "4:12" },
  { name: "Seawall", time: "2:58" },
  { name: "Tram 28", time: "3:33" },
  { name: "Almost Home", time: "5:06" },
];

function LoopApp() {
  return (
    <div className="flex h-full flex-col bg-zinc-950 text-white">
      <div className="flex flex-1 overflow-hidden">
        <Side title="Loop">
          <div className="flex flex-col gap-0.5 [&>div]:text-zinc-400">
            <SideRow>Listen now</SideRow>
            <SideRow>Radio</SideRow>
            <SideRow on>Library</SideRow>
          </div>
        </Side>
        <main className="flex-1 overflow-hidden p-8">
          <div className="flex items-end gap-6">
            {/* eslint-disable-next-line @next/next/no-img-element */}
            <img
              src={photo("1505740420928-5e560c06d30e", 360, 360)}
              alt="Album cover: yellow headphones on a yellow wall"
              className="size-[172px] rounded-xl object-cover shadow-2xl shadow-black/50"
            />
            <div>
              <p className="text-[12px] font-medium tracking-wide text-orange-300 uppercase">Album · 2026</p>
              <h1 className="mt-2 text-[34px] font-semibold tracking-[-0.03em]">Yellow Hours</h1>
              <p className="mt-1 text-[14px] text-zinc-400">Saint Juniper · 11 songs, 41 min</p>
              <div className="mt-4 flex gap-2">
                <span className="rounded-full bg-gradient-to-r from-rose-500 to-orange-500 px-5 py-2 text-[13px] font-semibold">
                  Play
                </span>
                <span className="rounded-full bg-white/10 px-5 py-2 text-[13px] font-medium">Shuffle</span>
              </div>
            </div>
          </div>
          <div className="mt-7">
            {tracks.map((track, i) => (
              <div
                key={track.name}
                className={`flex items-center gap-4 rounded-lg px-3 py-2.5 text-[13.5px] ${i === 1 ? "bg-white/[0.07]" : ""}`}
              >
                <span className={`w-4 text-right tabular-nums ${i === 1 ? "text-orange-400" : "text-zinc-500"}`}>{i + 1}</span>
                <span className={`flex-1 ${i === 1 ? "font-medium text-orange-300" : "text-zinc-200"}`}>{track.name}</span>
                <span className="text-zinc-500 tabular-nums">{track.time}</span>
              </div>
            ))}
          </div>
        </main>
      </div>
      <footer className="flex items-center gap-4 border-t border-white/[0.07] bg-zinc-900 px-6 py-3.5">
        <span className="text-[13px] font-medium">Yellow Hours</span>
        <span className="text-[12px] text-zinc-500 tabular-nums">1:48</span>
        <span className="h-1 flex-1 rounded-full bg-white/10">
          <span className="block h-full w-[43%] rounded-full bg-gradient-to-r from-rose-500 to-orange-400" />
        </span>
        <span className="text-[12px] text-zinc-500 tabular-nums">4:12</span>
      </footer>
    </div>
  );
}

const notes = [
  { x: 96, y: 118, r: -3, tone: "bg-yellow-200 text-yellow-950", text: "Ask for the team size before the plan" },
  { x: 290, y: 96, r: 2, tone: "bg-pink-200 text-pink-950", text: "Skip the tour if they've imported" },
  { x: 484, y: 132, r: -1.5, tone: "bg-sky-200 text-sky-950", text: "First doc opens with their own data" },
  { x: 678, y: 104, r: 3, tone: "bg-lime-200 text-lime-950", text: "Invite step: suggest from email domain" },
];

function TesseraApp() {
  return (
    <div className="relative h-full overflow-hidden bg-zinc-50 [background-image:radial-gradient(rgb(0_0_0/0.12)_1px,transparent_1px)] [background-size:22px_22px] dark:bg-zinc-900 dark:[background-image:radial-gradient(rgb(255_255_255/0.1)_1px,transparent_1px)]">
      <div className={`absolute top-[56px] left-[64px] h-[300px] w-[832px] rounded-2xl border-2 border-dashed border-amber-500/50`}>
        <span className="absolute -top-7 left-0 text-[13px] font-semibold text-amber-600 dark:text-amber-400">
          Onboarding flow v2
        </span>
      </div>
      {notes.map((note, i) => (
        <div
          key={note.text}
          style={{ left: note.x, top: note.y, rotate: `${note.r}deg` }}
          className={`absolute flex size-[150px] flex-col justify-between rounded-sm p-3.5 text-[15px] leading-snug font-medium shadow-md ${note.tone}`}
        >
          {note.text}
          <span className="text-[11px] font-normal opacity-60">Step {i + 1}</span>
        </div>
      ))}
      <svg className="absolute inset-0 size-full" aria-hidden>
        <g fill="none" stroke="currentColor" strokeWidth="2" strokeDasharray="5 6" className="text-amber-500/70">
          <path d="M246 196 C 262 196, 274 186, 290 180" />
          <path d="M440 176 C 456 186, 468 200, 484 206" />
          <path d="M634 210 C 650 200, 662 188, 678 182" />
        </g>
      </svg>
      <div className="absolute top-[392px] left-[96px] w-[360px] rounded-xl bg-white p-4 shadow-md ring-1 ring-zinc-950/5 dark:bg-zinc-800 dark:ring-white/10">
        <p className={`text-[13px] font-semibold ${ink}`}>Open questions</p>
        <ul className={`mt-2 space-y-1.5 text-[13px] ${muted}`}>
          <li>· Do we show pricing before the invite step?</li>
          <li>· What does an empty workspace look like?</li>
        </ul>
      </div>
      <div className="absolute top-[300px] left-[560px] flex flex-col items-start">
        <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden>
          <path d="M2 2l13 5.5-5.5 1.6L7.8 15Z" className="fill-pink-500" />
        </svg>
        <span className="ml-3 rounded-full bg-pink-500 px-2 py-0.5 text-[11px] font-semibold text-white">Mei</span>
      </div>
      <div className="absolute top-[420px] left-[700px] flex flex-col items-start">
        <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden>
          <path d="M2 2l13 5.5-5.5 1.6L7.8 15Z" className="fill-sky-500" />
        </svg>
        <span className="ml-3 rounded-full bg-sky-500 px-2 py-0.5 text-[11px] font-semibold text-white">Jonas</span>
      </div>
      <div className="absolute bottom-6 left-1/2 flex -translate-x-1/2 gap-1 rounded-xl bg-white p-1.5 shadow-lg ring-1 ring-zinc-950/5 dark:bg-zinc-800 dark:ring-white/10">
        {["Select", "Note", "Shape", "Line", "Text"].map((tool, i) => (
          <span
            key={tool}
            className={`rounded-lg px-3 py-1.5 text-[12px] font-medium ${i === 1 ? "bg-amber-400 text-amber-950" : muted}`}
          >
            {tool}
          </span>
        ))}
      </div>
    </div>
  );
}

const messages = [
  { who: "Priya Raman", initials: "PR", tone: "bg-violet-500", time: "9:12", text: "Build 412 is on staging. Can everyone give the new invite flow a spin before standup?" },
  { who: "Jonas Weber", initials: "JW", tone: "bg-sky-500", time: "9:20", text: "Invites from a phone work now. The email preview still shows the old logo though." },
  { who: "Mei Tanaka", initials: "MT", tone: "bg-pink-500", time: "9:24", text: "Fixed in 413, deploying now. Attaching the before and after." },
];

function PigeonApp() {
  return (
    <div className="flex h-full bg-white dark:bg-zinc-900">
      <Side title="Northwind">
        <div className="flex flex-col gap-0.5">
          <p className={`px-2 pb-1 text-[11px] font-medium uppercase tracking-wide ${muted}`}>Channels</p>
          <SideRow># general</SideRow>
          <SideRow on># launch</SideRow>
          <SideRow count={3}># design</SideRow>
          <SideRow># support</SideRow>
        </div>
        <div className="flex flex-col gap-0.5">
          <p className={`px-2 pb-1 text-[11px] font-medium uppercase tracking-wide ${muted}`}>Direct</p>
          <SideRow>Priya Raman</SideRow>
          <SideRow>Mei Tanaka</SideRow>
        </div>
      </Side>
      <main className="flex flex-1 flex-col">
        <header className={`flex items-center justify-between border-b px-6 py-4 ${hairline}`}>
          <p className={`text-[15px] font-semibold ${ink}`}># launch</p>
          <p className={`text-[12px] ${muted}`}>14 members</p>
        </header>
        <div className="flex flex-1 flex-col gap-5 px-6 py-5">
          {messages.map((message) => (
            <div key={message.time} className="flex gap-3">
              <Face initials={message.initials} className={`size-9 rounded-lg text-[12px] ring-0 ${message.tone}`} />
              <div className="min-w-0">
                <p className="text-[13px]">
                  <span className={`font-semibold ${ink}`}>{message.who}</span>
                  <span className={`ml-2 text-[11.5px] ${muted}`}>{message.time}</span>
                </p>
                <p className={`mt-0.5 text-[14px] leading-relaxed ${ink}`}>{message.text}</p>
              </div>
            </div>
          ))}
          <div className={`ml-12 flex w-[300px] items-center gap-3 rounded-lg border p-2.5 ${hairline}`}>
            <span className="grid size-10 place-items-center rounded-md bg-sky-500/15 text-[11px] font-semibold text-sky-700 dark:text-sky-300">
              PNG
            </span>
            <div>
              <p className={`text-[13px] font-medium ${ink}`}>invite-email-before-after.png</p>
              <p className={`text-[11.5px] ${muted}`}>418 KB</p>
            </div>
          </div>
        </div>
        <div className="px-6 pb-5">
          <p className={`mb-2 text-[12px] ${muted}`}>Priya is typing…</p>
          <div className={`rounded-xl border px-4 py-3 text-[13.5px] ${hairline} ${muted}`}>Message # launch</div>
        </div>
      </main>
    </div>
  );
}

/* ── Demo ──────────────────────────────────────────────── */

const apps: (LaunchDockItem & { line: string })[] = [
  { id: "plume", label: "Plume", title: "Field notes: Lisbon offsite", icon: icons.plume, content: <PlumeApp />, line: "Docs that research teams write together, offline on the train." },
  { id: "cadence", label: "Cadence", title: "Cadence: week 46", icon: icons.cadence, content: <CadenceApp />, line: "A shared calendar where a moved meeting moves for everyone at once." },
  { id: "tally", label: "Tally", title: "Tally: November budget", icon: icons.tally, content: <TallyApp />, line: "A household budget that two phones and a laptop keep honest." },
  { id: "loop", label: "Loop", title: "Loop", icon: icons.loop, content: <LoopApp />, line: "A music library that picks up on the laptop where the phone stopped." },
  { id: "tessera", label: "Tessera", title: "Onboarding flow v2: Tessera", icon: icons.tessera, content: <TesseraApp />, line: "A whiteboard where every sticky lands for everyone in the room." },
  { id: "pigeon", label: "Pigeon", title: "# launch: Northwind", icon: icons.pigeon, content: <PigeonApp />, line: "Team chat that never shows a message twice, even after a dead zone." },
];

export function LaunchDockDemo() {
  const [open, setOpen] = React.useState(apps[0].id);
  const app = apps.find((item) => item.id === open) ?? apps[0];

  return (
    <div className="flex w-full max-w-3xl flex-col items-center gap-5">
      <LaunchDock items={apps} value={open} onValueChange={setOpen} autoplay dockLabel="Apps built on Shoal" />
      <p aria-live="polite" className="max-w-md text-center text-sm text-balance text-zinc-500 dark:text-zinc-400">
        <span className="font-medium text-zinc-900 dark:text-zinc-100">{app.label}</span> · {app.line}
      </p>
    </div>
  );
}
```

## Examples

Finished sections built with Launch Dock. Each one installs whole, with the component included.

### Testimonials

Faces in a round dock. Each quote card grows out of the person who said it, and shrinks back into them when the next one opens.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/launch-dock-quotes-demo
```

```tsx title="components/launch-dock-quotes-demo.tsx"
"use client";

import { LaunchDock, type LaunchDockItem } from "@/components/ui/launch-dock";

const face = (id: string, size: number) =>
  `https://images.unsplash.com/photo-${id}?auto=format&fit=crop&crop=faces&w=${size}&h=${size}&q=80`;

const people = [
  {
    id: "ines",
    name: "Inês Moreau",
    role: "Partner, Atelier Sund",
    photo: "1438761681033-6461ffad8d80",
    quote: "Okto writes up our client calls before I'm back at my desk. I haven't typed a meeting note since March.",
  },
  {
    id: "rafael",
    name: "Rafael Santos",
    role: "Head of Sales, Kvarter",
    photo: "1507003211169-0a1dd7228f2d",
    quote: "Follow-ups used to cost us a day a week. Now they go out while the call is still fresh for everyone.",
  },
  {
    id: "mei",
    name: "Mei Tanaka",
    role: "Producer, Northwind Films",
    photo: "1534528741775-53994a69daeb",
    quote: "I search last month's calls like my inbox. “What did the client say about the ending?” Found in a second.",
  },
  {
    id: "luca",
    name: "Luca Bianchi",
    role: "Engineering Lead, Harbor",
    photo: "1500648767791-00dcc994a43e",
    quote: "Our standups are eight minutes now. Okto posts the decisions to the right channel, so nobody retells them.",
  },
  {
    id: "amara",
    name: "Amara Okafor",
    role: "Founder, Field & Fold",
    photo: "1531123897727-8f129e1688ce",
    quote: "The first tool my whole team adopted without a training session. It just shows up in the calls we already have.",
  },
  {
    id: "jonas",
    name: "Jonas Weber",
    role: "Brand Director, Tinta",
    photo: "1539571696357-5a69c17a67c6",
    quote: "It caught a budget number we'd all misheard on the call. That one note paid for the year.",
  },
];

function Quote({ person }: { person: (typeof people)[number] }) {
  return (
    <figure className="flex h-full flex-col justify-between p-11">
      <div>
        <svg viewBox="0 0 32 24" aria-hidden className="h-6 w-8 text-violet-500">
          <path
            fill="currentColor"
            d="M0 24V14.4C0 6.2 4.6 1.4 12.4 0l1.4 3.6C9.5 5 7.4 7.8 7.2 12H13v12H0Zm18.6 0V14.4C18.6 6.2 23.2 1.4 31 0l1 3.6C27.8 5 25.9 7.8 25.7 12h5.7v12H18.6Z"
          />
        </svg>
        <blockquote className="mt-6 text-[28px] leading-[1.36] font-medium tracking-[-0.018em] text-balance text-zinc-900 dark:text-zinc-50">
          {person.quote}
        </blockquote>
      </div>
      <figcaption className="flex items-center gap-3.5">
        {/* eslint-disable-next-line @next/next/no-img-element */}
        <img src={face(person.photo, 96)} alt="" className="size-11 rounded-full object-cover" />
        <div>
          <p className="text-[16px] font-semibold text-zinc-900 dark:text-zinc-50">{person.name}</p>
          <p className="text-[14px] text-zinc-500 dark:text-zinc-400">{person.role}</p>
        </div>
      </figcaption>
    </figure>
  );
}

const items: LaunchDockItem[] = people.map((person) => ({
  id: person.id,
  label: person.name,
  // eslint-disable-next-line @next/next/no-img-element
  icon: <img src={face(person.photo, 120)} alt="" />,
  content: <Quote person={person} />,
}));

export function LaunchDockQuotesDemo() {
  return (
    <div className="flex w-full max-w-xl flex-col items-center gap-8">
      <div className="text-center">
        <p className="text-sm font-medium text-violet-600 dark:text-violet-400">Okto for teams</p>
        <h2 className="mt-2 text-3xl font-semibold tracking-tight text-balance text-zinc-950 dark:text-white">
          Teams that stopped taking notes
        </h2>
      </div>
      <LaunchDock
        items={items}
        frame="card"
        fade={false}
        autoplay
        windowSize={[720, 360]}
        dockLabel="People quoted"
        className="[--launch-dock-roundness:0.5]"
      />
    </div>
  );
}
```

### Studio Work

A design studio's clients as marks in the dock, each launching the site the studio built for them, address bar and all.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/launch-dock-studio-demo
```

```tsx title="components/launch-dock-studio-demo.tsx"
"use client";

import * as React from "react";

import { LaunchDock, type LaunchDockItem } from "@/components/ui/launch-dock";

const photo = (id: string) => `https://images.unsplash.com/photo-${id}?auto=format&fit=crop&w=1400&h=880&q=80`;

type Project = {
  id: string;
  client: string;
  work: string;
  domain: string;
  mark: React.ReactNode;
};

function Mark({ bg, children }: { bg: string; children: React.ReactNode }) {
  return <span className={`grid size-full place-items-center ${bg}`}>{children}</span>;
}

function Url({ children }: { children: React.ReactNode }) {
  return (
    <span className="inline-flex min-w-[46%] items-center justify-center gap-1.5 rounded-md bg-zinc-950/[0.05] px-3 py-0.5 dark:bg-white/[0.07]">
      <svg viewBox="0 0 12 12" aria-hidden className="size-2.5 opacity-60">
        <path d="M3.5 5.5V4a2.5 2.5 0 0 1 5 0v1.5M2.5 5.5h7v5h-7z" fill="none" stroke="currentColor" strokeWidth="1.2" />
      </svg>
      {children}
    </span>
  );
}

// A client's site as the studio shipped it: one photo, their type, their nav.
function Site({
  src,
  alt,
  tone,
  nav,
  brand,
  children,
  shade = "bg-gradient-to-b from-black/55 via-black/10 to-transparent",
}: {
  src: string;
  alt: string;
  tone: "light" | "dark";
  nav: string[];
  brand: React.ReactNode;
  children: React.ReactNode;
  shade?: string;
}) {
  return (
    <div className={`relative h-full overflow-hidden ${tone === "light" ? "text-white" : "text-zinc-950"}`}>
      {/* eslint-disable-next-line @next/next/no-img-element */}
      <img src={src} alt={alt} className="absolute inset-0 size-full object-cover" />
      <div className={`absolute inset-0 ${shade}`} />
      <header className="relative flex items-center justify-between px-10 py-7">
        {brand}
        <nav className="flex gap-7 text-[13px] font-medium opacity-90">
          {nav.map((link) => (
            <span key={link}>{link}</span>
          ))}
        </nav>
      </header>
      <div className="relative px-10 pt-14">{children}</div>
    </div>
  );
}

const projects: Project[] = [
  {
    id: "halden",
    client: "Halden House",
    work: "Website and identity, 2026",
    domain: "halden.house",
    mark: (
      <Mark bg="bg-stone-200">
        <span className="font-serif text-[1.65em] text-stone-800 italic">H</span>
      </Mark>
    ),
  },
  {
    id: "ember",
    client: "Ember",
    work: "Launch campaign, 2026",
    domain: "ember.run",
    mark: (
      <Mark bg="bg-red-600">
        <span className="text-[1.5em] font-black text-white italic">E</span>
      </Mark>
    ),
  },
  {
    id: "tempo",
    client: "Tempo",
    work: "Product launch site, 2025",
    domain: "tempo.watch",
    mark: (
      <Mark bg="bg-slate-900">
        <span className="text-[1.35em] font-light tracking-widest text-white">T</span>
      </Mark>
    ),
  },
  {
    id: "sable",
    client: "Sable",
    work: "Shop and art direction, 2025",
    domain: "sable.store",
    mark: (
      <Mark bg="bg-zinc-950">
        <span className="font-serif text-[1.55em] text-amber-100">S</span>
      </Mark>
    ),
  },
  {
    id: "nordlys",
    client: "Nordlys",
    work: "Booking site, 2024",
    domain: "nordlys.no",
    mark: (
      <Mark bg="bg-teal-700">
        <span className="text-[1.4em] font-semibold text-teal-50">N</span>
      </Mark>
    ),
  },
  {
    id: "carry",
    client: "Carry Co.",
    work: "Packaging and shop, 2024",
    domain: "carry.co",
    mark: (
      <Mark bg="bg-amber-200">
        <span className="font-mono text-[1.35em] font-bold text-amber-900">C</span>
      </Mark>
    ),
  },
];

const sites: Record<string, React.ReactNode> = {
  halden: (
    <Site
      src={photo("1600585154340-be6161a56a0c")}
      alt="A timber house at dusk with its windows lit"
      tone="light"
      nav={["Projects", "Practice", "Journal", "Contact"]}
      brand={<span className="font-serif text-[22px] italic">Halden House</span>}
      shade="bg-gradient-to-br from-black/75 via-black/30 to-black/0"
    >
      <h1 className="max-w-[560px] font-serif text-[54px] leading-[1.04] tracking-[-0.01em]">
        Houses that hold the evening light.
      </h1>
      <p className="mt-5 max-w-[400px] text-[15px] leading-relaxed text-white/80">
        A small practice on the west coast, building eight homes a year in timber, stone and glass.
      </p>
    </Site>
  ),
  ember: (
    <Site
      src={photo("1542291026-7eec264c27ff")}
      alt="A red running shoe on a red background"
      tone="light"
      nav={["Run", "Train", "Stories", "Bag (0)"]}
      brand={<span className="text-[22px] font-black tracking-tight italic">EMBER</span>}
      shade="bg-gradient-to-b from-black/45 via-transparent to-transparent"
    >
      <h1 className="text-[92px] leading-[0.86] font-black tracking-[-0.04em] uppercase italic">
        Run
        <br />
        warm.
      </h1>
      <span className="mt-7 inline-block rounded-full bg-white px-6 py-3 text-[14px] font-bold text-red-600">
        Shop the Ember Runner · $140
      </span>
    </Site>
  ),
  tempo: (
    <Site
      src={photo("1523275335684-37898b6baf30")}
      alt="A white watch on a pale background"
      tone="dark"
      nav={["Collection", "Craft", "Stores"]}
      brand={<span className="text-[18px] font-light tracking-[0.5em]">TEMPO</span>}
      shade="bg-gradient-to-b from-white/70 via-white/20 to-transparent"
    >
      <h1 className="max-w-[420px] text-[46px] leading-[1.1] font-light tracking-[-0.02em]">Time, kept quietly.</h1>
      <p className="mt-4 max-w-[340px] text-[15px] leading-relaxed text-zinc-700">
        The Model 01 has no logo on its face and no second hand. Just the hour, and enough of the minute.
      </p>
    </Site>
  ),
  sable: (
    <Site
      src={photo("1592078615290-033ee584e267")}
      alt="A black shell chair against a light wall"
      tone="dark"
      nav={["Chairs", "Tables", "Lighting", "Cart"]}
      brand={<span className="font-serif text-[24px]">Sable</span>}
      shade="bg-gradient-to-b from-white/80 via-white/10 to-transparent"
    >
      <p className="text-[12px] font-semibold tracking-[0.2em] uppercase text-zinc-600">New · The Shell Chair</p>
      <h1 className="mt-3 max-w-[460px] font-serif text-[58px] leading-[1.02] tracking-[-0.02em]">Sit with it a while.</h1>
    </Site>
  ),
  nordlys: (
    <Site
      src={photo("1600607687939-ce8a6c25118c")}
      alt="A bright open living room with large windows"
      tone="light"
      nav={["Homes", "Regions", "About"]}
      brand={<span className="text-[20px] font-semibold tracking-tight">nordlys</span>}
      shade="bg-gradient-to-b from-black/50 via-black/15 to-transparent"
    >
      <h1 className="max-w-[520px] text-[48px] leading-[1.06] font-semibold tracking-[-0.03em]">
        Stay somewhere that feels like yours.
      </h1>
      <div className="mt-7 flex w-[560px] items-center gap-1 rounded-2xl bg-white p-1.5 text-[13px] text-zinc-500 shadow-xl">
        <span className="flex-1 rounded-xl px-4 py-2.5">Lofoten, Norway</span>
        <span className="h-6 w-px bg-zinc-200" />
        <span className="flex-1 rounded-xl px-4 py-2.5">Feb 14 – 18</span>
        <span className="h-6 w-px bg-zinc-200" />
        <span className="flex-1 rounded-xl px-4 py-2.5">2 guests</span>
        <span className="rounded-xl bg-teal-700 px-5 py-2.5 font-semibold text-white">Search</span>
      </div>
    </Site>
  ),
  carry: (
    <Site
      src={photo("1544816155-12df9643f363")}
      alt="A canvas tote bag hanging on a wall"
      tone="dark"
      nav={["Totes", "Duffels", "Repairs", "Bag"]}
      brand={<span className="font-mono text-[18px] font-bold">CARRY CO.</span>}
      shade="bg-gradient-to-b from-amber-50/85 via-amber-50/20 to-transparent"
    >
      <h1 className="max-w-[460px] font-mono text-[44px] leading-[1.08] font-bold tracking-[-0.03em]">
        Made to be overfilled.
      </h1>
      <p className="mt-4 max-w-[360px] text-[15px] leading-relaxed text-zinc-700">
        18oz canvas, double-stitched handles, and free repairs for as long as you own one.
      </p>
    </Site>
  ),
};

const items: (LaunchDockItem & { work: string })[] = projects.map((project) => ({
  id: project.id,
  label: project.client,
  title: <Url>{project.domain}</Url>,
  icon: project.mark,
  content: sites[project.id],
  work: project.work,
}));

export function LaunchDockStudioDemo() {
  const [open, setOpen] = React.useState(items[0].id);
  const project = items.find((item) => item.id === open) ?? items[0];

  return (
    <div className="flex w-full max-w-3xl flex-col items-center gap-5">
      <LaunchDock items={items} value={open} onValueChange={setOpen} autoplay dockLabel="Clients" />
      <p aria-live="polite" className="text-sm text-zinc-500 dark:text-zinc-400">
        <span className="font-medium text-zinc-900 dark:text-zinc-100">{project.label}</span> · {project.work}
      </p>
    </div>
  );
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `items` (required) | `LaunchDockItem[]` |  | The apps, in dock order. Each one is `{ id, label, icon, content, title? }`. `label` names it in the gliding label, the title bar and for screen readers; `icon` is clipped to the dock's rounded square, so a full-bleed `<img>` or SVG tile works; `content` is what its window shows, laid out at `windowSize`; `title` replaces the label in the title bar (a domain, a file name). |
| `value` | `string` |  | The open app's id, when you control it. Pair it with `onValueChange`. |
| `defaultValue` | `string` |  | The app that's open at first. Defaults to the first one. |
| `onValueChange` | `(id: string, reason: "pick" \| "autoplay") => void` |  | Called with an app's id whenever it launches, and why: the visitor picked it (a click, a tap or a key), or autoplay moved on. |
| `autoplay` | `boolean \| number` | `false` | Launch the next app on its own every 4.5s until the visitor picks one. A number sets the time in ms. It pauses while a pointer is over the dock, focus is inside it, the tab is hidden or the dock is out of view, and stays off with reduced motion. |
| `windowSize` | `[number, number]` | `[960, 600]` | The size each window's content is laid out at, in px. Narrower containers scale it down to fit, like a screenshot, so a layout built at this size keeps its proportions on a phone. |
| `frame` | `"window" \| "card" \| "none"` | `"window"` | What the content sits in: a desktop window with three lights and a centered title, a plain card, or nothing, for content that brings its own frame. |
| `fade` | `boolean` | `true` | Fade the window's lower edge out behind the dock, the way the reference desktop sinks into the page. Turn it off to keep all of the content in view; the dock then sits under the window. |
| `duration` | `number` | `0.3` | Seconds a window takes to grow up out of its icon. The open one dissolves in step, so it's gone just before the new one lands. |
| `hoverScale` | `number` | `1.1` | The scale an icon grows to as it rises under the pointer. `1` keeps its size. |
| `dockLabel` | `string` | `"Apps"` | The dock's accessible name, read out when focus enters it. |
| `trayClassName` | `string` |  | Classes for the dock's surface: its fill, ring and shadow. |
| `windowClassName` | `string` |  | Classes for each window's surface: its fill and ring. |
| `className` | `string` |  | Extra classes for the root. Set `[--launch-dock-roundness:0.5]` for round icons, like faces; the dock's corners follow, and windows launch out of the circle. |

## What to keep

To adapt it, change its props first. When you do edit `components/ui/launch-dock.tsx`, keep these, which make it feel and behave right:

- Windows animate only transform and opacity, as hardware animations on their own layers, and closing animates opacity alone; nothing repaints mid-flight.
- The open window never moves: it dissolves in place under the new one (z-index 2 over 1), which is opaque from its first frame behind the icon, so two windows never cross or blend.
- A pick measures its icon before any state changes, so the launch's first frame never waits on layout.
- A launch turns round from wherever it is: a window still dissolving comes back in place instead of growing from its icon again.
- Closed windows stay mounted, `hidden` and `inert`, as tabpanels of a tablist with a roving `tabIndex`, so their content and state survive a switch.

Its timing (durations, delays, springs and easings) is all in the `TIMING` block at the top of the file; the timing props default to it.

## Source

```tsx title="components/ui/launch-dock.tsx"
"use client";

import * as React from "react";
import {
  animate,
  motion,
  useInView,
  useMotionValue,
  useReducedMotion,
  type AnimationPlaybackControls,
} from "motion/react";

import { cn } from "@/lib/utils";

/**
 * Keep:
 * - Windows animate only transform and opacity, as hardware animations on their own layers, and
 *   closing animates opacity alone; nothing repaints mid-flight.
 * - The open window never moves: it dissolves in place under the new one (z-index 2 over 1), which
 *   is opaque from its first frame behind the icon, so two windows never cross or blend.
 * - A pick measures its icon before any state changes, so the launch's first frame never waits on
 *   layout.
 * - A launch turns round from wherever it is: a window still dissolving comes back in place instead
 *   of growing from its icon again.
 * - Closed windows stay mounted, `hidden` and `inert`, as tabpanels of a tablist with a roving
 *   `tabIndex`, so their content and state survive a switch.
 */
const TIMING = {
  /** A window growing up out of its icon, in seconds (the `duration` prop) and its curve. */
  launch: { duration: 0.3, ease: [0.22, 1, 0.36, 1] },
  /** The open one fading where it is, under the new one, at the default `duration`. */
  dissolve: { duration: 0.22, ease: "linear" },
  /** An icon rising under the pointer. */
  lift: { type: "spring", visualDuration: 0.25, bounce: 0.2 },
  /** And dipping while it's pressed. */
  press: { type: "spring", visualDuration: 0.12, bounce: 0 },
  /** The label and the dot moving between icons. */
  glide: { type: "spring", visualDuration: 0.26, bounce: 0 },
  /** The label appearing, in seconds. */
  show: { duration: 0.15, ease: [0.22, 1, 0.36, 1] },
  /** And going, in seconds. */
  hide: { duration: 0.12, ease: [0.22, 1, 0.36, 1] },
  /** Windows crossfading under reduced motion, in seconds. */
  fade: { duration: 0.2, ease: [0.22, 1, 0.36, 1] },
  /** The label waiting after the pointer leaves an icon, in ms. */
  leave: 120,
  /** The label staying over a tapped icon, in ms. */
  touchLabel: 1200,
  /** Each app staying up when `autoplay` is `true`, in ms. */
  autoplay: 4500,
} as const;

/* ─────────────────────────────────────────────────────────
 * LAUNCH DOCK STORYBOARD
 *
 *  rest      a window over a dock of rounded icons. The open app's
 *            window sits under the dock with its lower edge fading out
 *            behind it, and a 4px dot marks its icon
 *  hover     under a mouse the icon you're on rises 6px and grows to
 *            1.1 on a quick spring with a little give. One label hangs
 *            over the dock and glides from icon to icon, its width
 *            following the name; it shows at once and waits 120ms
 *            before it goes
 *  launch    pick an icon and, on the same frame, its window starts
 *            growing up out of it: from a copy small enough to hide
 *            behind the icon to full size in 0.3s on a strong ease-out,
 *            so most of the growth is over in the first 0.1s. It's
 *            opaque from the first frame (it starts hidden behind the
 *            icon), so two apps never blend, and the dot slides across
 *  dissolve  the open window doesn't move: it dissolves where it is
 *            (0.22s) while the new one grows over it, so nothing crosses
 *            and the stage is never empty
 *  smooth    windows move by transform and opacity only, as hardware
 *            animations on their own layers: nothing repaints while
 *            they fly, and a busy page can't stall them. The icons are
 *            measured at the click, before anything else happens
 *  autoplay  with autoplay the dot stretches into a 14px bar that fills
 *            while an app is up (4.5s each), then the next one
 *            launches. A pointer over the dock, focus inside it, a
 *            hidden tab or the dock out of view pauses it; picking an
 *            app stops it for good
 *  again     a launch turns round from wherever it is: click back fast
 *            and a dissolving window comes back in place, never queues
 *  touch     a tap launches, and the label shows over the tapped icon
 *            for 1.2s, since there's no hover to show it
 *  keys      the dock is a tablist. Tab lands on the open app, the
 *            arrows move along and launch, Home and End jump to the
 *            ends, and Tab carries on into the window, its tabpanel
 *  reduced   nothing flies or rises: windows crossfade in 0.2s, the
 *            label fades, and autoplay stays off
 * ───────────────────────────────────────────────────────── */

const DOCK = {
  tuck: 0.9, // share of the icon a window's smallest copy fills, so it hides behind it
  rise: 6, // px an icon rises under the pointer
  grow: 1.1, // scale it grows to
  dip: 0.92, // scale while it's pressed
  feather: 0.28, // share of the window's height that fades out behind the dock
  dot: 4, // px; the open app's dot
  bar: 14, // px; the dot while autoplay counts down
  labelPad: 20, // px of padding around the label's text
} as const;

// The window's corners at rest, per frame.
const RADIUS = { window: 12, card: 20, none: 0 } as const;
const AT_REST = "translate(0px, 0px) scale(1)";
const DEFAULT_SIZE: [number, number] = [960, 600];

const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));

export interface LaunchDockItem {
  /** A unique id. `value` and `onValueChange` use it. */
  id: string;
  /** The app's name: the label over the dock, the window's title and the icon's accessible name. */
  label: string;
  /** The dock icon. It's clipped to a rounded square, so a full-bleed `<img>` or SVG tile both work. */
  icon: React.ReactNode;
  /** What the window shows. It's laid out at `windowSize` and scaled down to fit, like a screenshot. */
  content: React.ReactNode;
  /** The window's title bar text, when it should say more than the label: a domain, a file name. */
  title?: React.ReactNode;
}

export interface LaunchDockProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "defaultValue" | "onChange" | "children"> {
  /** The apps, in dock order. */
  items: LaunchDockItem[];
  /** The open app's id, when you control it. */
  value?: string;
  /** The app that's open at first. Defaults to the first one. */
  defaultValue?: string;
  /** Called with an app's id whenever it launches, and why: the visitor picked it, or autoplay moved on. */
  onValueChange?: (id: string, reason: "pick" | "autoplay") => void;
  /** Launch the next app on its own every 4.5s until the visitor picks one. A number sets the time in ms. */
  autoplay?: boolean | number;
  /** The size each window's content is laid out at, in px. Narrower containers scale it down to fit. */
  windowSize?: [number, number];
  /** What the content sits in: a desktop window with a title bar, a plain card, or nothing at all. */
  frame?: "window" | "card" | "none";
  /** Fade the window's lower edge out behind the dock. Turn it off to keep all of the content in view; the dock then sits under the window. */
  fade?: boolean;
  /** Seconds a window takes to grow up out of its icon. The open one dissolves in proportion, so it's gone just before the new one lands. */
  duration?: number;
  /** The scale an icon grows to under the pointer. `1` keeps it still but for the rise. */
  hoverScale?: number;
  /** The dock's accessible name. */
  dockLabel?: string;
  /** Classes for the dock's surface: its fill, ring and shadow. */
  trayClassName?: string;
  /** Classes for each window's surface: its fill, ring and corners. */
  windowClassName?: string;
}

/** Where a window hides behind its icon, as the transform that puts it there (origin top left). */
type Docked = string;

/* ── TitleBar ───────────────────────────────────────────── */

// The window's chrome: three lights and a centered title. Purely drawn; the lights don't act.
const BAR = "h-7 @[30rem]/launch-dock:h-9";

function TitleBar({ title }: { title: React.ReactNode }) {
  return (
    <div
      className={cn(
        BAR,
        "relative flex shrink-0 items-center border-b border-zinc-950/[0.06] bg-zinc-50/90 px-3 dark:border-white/[0.06] dark:bg-white/[0.04] @[30rem]/launch-dock:px-3.5",
      )}
    >
      <div aria-hidden className="flex gap-1.5 @[30rem]/launch-dock:gap-2">
        <span className="size-2.5 rounded-full bg-[#ff5f57] ring-1 ring-black/10 ring-inset @[30rem]/launch-dock:size-3" />
        <span className="size-2.5 rounded-full bg-[#febc2e] ring-1 ring-black/10 ring-inset @[30rem]/launch-dock:size-3" />
        <span className="size-2.5 rounded-full bg-[#28c840] ring-1 ring-black/10 ring-inset @[30rem]/launch-dock:size-3" />
      </div>
      <div className="pointer-events-none absolute inset-x-16 truncate text-center text-[11px] font-medium text-zinc-500 @[30rem]/launch-dock:inset-x-20 @[30rem]/launch-dock:text-xs dark:text-zinc-400">
        {title}
      </div>
    </div>
  );
}

/* ── DockWindow ─────────────────────────────────────────── */

// One app's window. It stays mounted, so its images load early and its state survives, and is
// hidden while closed. Opening animates only its transform and opacity, closing only its opacity,
// and both run as hardware animations: the window is rastered once, never repainted mid-flight.
const DockWindow = React.memo(function DockWindow({
  item,
  open,
  frame,
  fade,
  reduced,
  duration,
  docked,
  width,
  height,
  tabId,
  panelId,
  className,
}: {
  item: LaunchDockItem;
  open: boolean;
  frame: "window" | "card" | "none";
  fade: boolean;
  reduced: boolean;
  duration: number;
  docked: (id: string) => Docked | null;
  width: number;
  height: number;
  tabId: string;
  panelId: string;
  className?: string;
}) {
  const [present, setPresent] = React.useState(open);
  if (open && !present) setPresent(true);

  const outerRef = React.useRef<HTMLDivElement>(null);
  const flight = React.useRef<AnimationPlaybackControls | null>(null);
  // Settled ("open"/"closed") or on its way; a window still on its way turns round from where it is.
  const phase = React.useRef<"open" | "closed" | "opening" | "closing">(open ? "open" : "closed");
  // The `open` the window last moved for. Strict Mode replays effects on mount; this keeps that
  // replay (and any re-render that doesn't change `open`) from starting a flight.
  const shown = React.useRef(open);
  const rest = RADIUS[frame];

  React.useLayoutEffect(() => {
    if (shown.current === open) return;
    shown.current = open;
    const outer = outerRef.current;
    if (!outer) return;
    flight.current?.stop();
    const settled = phase.current === "open" || phase.current === "closed";
    phase.current = open ? "opening" : "closing";
    const done = () => {
      phase.current = open ? "open" : "closed";
      if (!open) setPresent(false);
    };

    const target = reduced || !open ? null : docked(item.id);
    if (!target && (open || reduced)) {
      flight.current = animate(
        outer,
        { transform: AT_REST, opacity: open ? [0, 1] : 0 },
        { ...TIMING.fade, transform: { duration: 0 }, onComplete: done },
      );
    } else if (open) {
      // From behind the icon on a fresh launch; from wherever it is if it was still leaving.
      flight.current = animate(
        outer,
        settled ? { transform: [target, AT_REST], opacity: 1 } : { transform: AT_REST, opacity: 1 },
        { ...TIMING.launch, duration, opacity: { duration: settled ? 0 : TIMING.fade.duration }, onComplete: done },
      );
    } else {
      // It stays put and dissolves under the window growing over it, so nothing crosses.
      const dissolve = TIMING.dissolve.duration * (duration / TIMING.launch.duration);
      flight.current = animate(outer, { opacity: 0 }, { ...TIMING.dissolve, duration: dissolve, onComplete: done });
    }
  }, [open, reduced, docked, item.id, duration]);

  React.useEffect(() => () => flight.current?.stop(), []);

  return (
    <div
      ref={outerRef}
      id={panelId}
      role="tabpanel"
      aria-labelledby={tabId}
      hidden={!present}
      inert={!open}
      data-launch-dock-window={item.id}
      data-state={open ? "open" : "closed"}
      style={{ zIndex: open ? 2 : 1 }}
      className="absolute inset-x-0 top-0 origin-top-left [will-change:transform,opacity]"
    >
      {frame !== "none" ? (
        <div
          aria-hidden
          style={{ bottom: fade ? `${DOCK.feather * 100 + 8}%` : 0, borderRadius: rest }}
          className="pointer-events-none absolute inset-x-0 top-0 shadow-[0_24px_60px_-18px_rgb(0_0_0/0.3),0_8px_20px_-10px_rgb(0_0_0/0.18)] dark:shadow-[0_24px_60px_-18px_rgb(0_0_0/0.75),0_8px_20px_-10px_rgb(0_0_0/0.5)]"
        />
      ) : null}
      <div
        style={{ borderRadius: rest }}
        className={cn(
          "relative flex flex-col overflow-hidden",
          fade && "[mask-image:linear-gradient(to_bottom,#000_72%,transparent)]",
          frame === "window" && "bg-white ring-1 ring-zinc-950/10 ring-inset dark:bg-zinc-900 dark:ring-white/10",
          frame === "card" && "bg-white ring-1 ring-zinc-950/[0.07] ring-inset dark:bg-zinc-900 dark:ring-white/10",
          className,
        )}
      >
        {frame === "window" ? <TitleBar title={item.title ?? item.label} /> : null}
        <div className="relative w-full overflow-hidden" style={{ aspectRatio: `${width} / ${height}` }}>
          <div
            className="invisible absolute top-0 left-0 origin-top-left group-data-[measured]/launch-dock-stage:visible"
            style={{ width, height, transform: "scale(var(--launch-dock-scale, 1))" }}
          >
            {item.content}
          </div>
        </div>
      </div>
    </div>
  );
});

/* ── DockTab ────────────────────────────────────────────── */

type TabEvent = "down" | "up" | "enter" | "leave" | "focus" | "blur" | "click";

// One icon in the dock. It's memoized and hands every event to one stable callback, so a switch
// re-renders only the icons whose state changed, not the whole dock.
const DockTab = React.memo(function DockTab({
  item,
  active,
  lifted,
  dipped,
  grow,
  tabId,
  panelId,
  register,
  onTabEvent,
}: {
  item: LaunchDockItem;
  active: boolean;
  lifted: boolean;
  dipped: boolean;
  grow: number;
  tabId: string;
  panelId: string;
  register: (kind: "tab" | "tile", id: string, node: HTMLElement | null) => void;
  onTabEvent: (type: TabEvent, id: string, event: React.SyntheticEvent<HTMLElement>) => void;
}) {
  return (
    <button
      ref={(node) => register("tab", item.id, node)}
      type="button"
      role="tab"
      id={tabId}
      aria-selected={active}
      aria-controls={panelId}
      aria-label={item.label}
      tabIndex={active ? 0 : -1}
      data-launch-dock-item={item.id}
      data-active={active ? "" : undefined}
      className="group/launch-dock-tab relative block shrink-0 cursor-pointer rounded-[var(--launch-dock-corner)] outline-none select-none [-webkit-tap-highlight-color:transparent] after:absolute after:-inset-1 after:content-['']"
      onPointerDown={(event) => onTabEvent("down", item.id, event)}
      onPointerUp={(event) => onTabEvent("up", item.id, event)}
      onPointerCancel={(event) => onTabEvent("up", item.id, event)}
      onPointerEnter={(event) => onTabEvent("enter", item.id, event)}
      onPointerLeave={(event) => onTabEvent("leave", item.id, event)}
      onFocus={(event) => onTabEvent("focus", item.id, event)}
      onBlur={(event) => onTabEvent("blur", item.id, event)}
      onClick={(event) => onTabEvent("click", item.id, event)}
    >
      <motion.span
        ref={(node) => register("tile", item.id, node)}
        initial={false}
        animate={{
          transform: `translateY(${lifted ? -DOCK.rise : 0}px) scale(${dipped ? DOCK.dip : lifted ? grow : 1})`,
        }}
        transition={dipped ? TIMING.press : TIMING.lift}
        className="block size-[var(--launch-dock-icon)] origin-bottom overflow-hidden rounded-[var(--launch-dock-corner)] shadow-[0_1px_2px_rgb(0_0_0/0.12),0_4px_10px_-4px_rgb(0_0_0/0.25)] outline-offset-2 group-focus-visible/launch-dock-tab:outline-2 group-focus-visible/launch-dock-tab:outline-zinc-950 dark:group-focus-visible/launch-dock-tab:outline-white [&>img]:size-full [&>img]:object-cover [&>svg]:size-full"
      >
        {item.icon}
      </motion.span>
    </button>
  );
});

/* ── LaunchDock ─────────────────────────────────────────── */

export function LaunchDock({
  items,
  value,
  defaultValue,
  onValueChange,
  autoplay = false,
  windowSize = DEFAULT_SIZE,
  frame = "window",
  fade = true,
  duration = TIMING.launch.duration,
  hoverScale = DOCK.grow,
  dockLabel = "Apps",
  trayClassName,
  windowClassName,
  className,
  style,
  onPointerEnter,
  onPointerLeave,
  onFocus,
  onBlur,
  ...props
}: LaunchDockProps) {
  const reduced = useReducedMotion() ?? false;
  const uid = React.useId();
  const [width, height] = windowSize;

  const [inner, setInner] = React.useState(defaultValue);
  const requested = value ?? inner;
  const active = items.some((item) => item.id === requested) ? requested : items[0]?.id;

  const rootRef = React.useRef<HTMLDivElement>(null);
  const stageRef = React.useRef<HTMLDivElement>(null);
  const trayRef = React.useRef<HTMLDivElement>(null);
  const tabs = React.useRef(new Map<string, HTMLElement>());
  const tiles = React.useRef(new Map<string, HTMLElement>());
  const labelWidths = React.useRef(new Map<string, number>());
  const measurer = React.useRef<HTMLDivElement>(null);
  const register = React.useCallback((kind: "tab" | "tile", id: string, node: HTMLElement | null) => {
    const map = kind === "tab" ? tabs.current : tiles.current;
    if (node) map.set(id, node);
    else map.delete(id);
  }, []);

  // Hover lifts the icon under a mouse; the label follows hover, keyboard focus or a tap.
  const [hover, setHover] = React.useState<string | null>(null);
  const [pressed, setPressed] = React.useState<string | null>(null);
  const [chip, setChip] = React.useState<{ id: string; on: boolean } | null>(null);
  const leaveTimer = React.useRef<number | undefined>(undefined);
  const flashTimer = React.useRef<number | undefined>(undefined);
  const pointerType = React.useRef<string>("mouse");

  // Autoplay runs until the visitor picks something, and holds while they're looking closely.
  const interval = autoplay === true ? TIMING.autoplay : typeof autoplay === "number" ? autoplay : 0;
  const [stopped, setStopped] = React.useState(false);
  const running = interval > 0 && !stopped && !reduced && items.length > 1;
  const [hovering, setHovering] = React.useState(false);
  const [focusWithin, setFocusWithin] = React.useState(false);
  const [pageHidden, setPageHidden] = React.useState(false);
  const inView = useInView(rootRef, { amount: 0.4 });
  const paused = hovering || focusWithin || pageHidden || !inView;

  const labelX = useMotionValue(0);
  const labelW = useMotionValue(0);
  const dotX = useMotionValue(0);
  const dotW = useMotionValue<number>(DOCK.dot);
  const fill = useMotionValue(1);
  const elapsed = React.useRef(0);
  const activeRef = React.useRef(active);
  const placed = React.useRef({ label: false, dot: false });

  React.useLayoutEffect(() => {
    activeRef.current = active;
  }, [active]);

  // Where a window hides behind its icon, worked out from the icon as it sits right now (lifted,
  // pressed). A pick measures it before anything changes, so the frame that starts the launch
  // never waits on layout.
  const snapshots = React.useRef(new Map<string, Docked>());
  const dockedNow = React.useCallback((id: string): Docked | null => {
    const stage = stageRef.current;
    const tile = tiles.current.get(id);
    if (!stage || !tile || !stage.offsetWidth || !tile.offsetWidth) return null;
    const box = stage.getBoundingClientRect();
    const rect = tile.getBoundingClientRect();
    const k = box.width / stage.offsetWidth || 1; // a scaled ancestor, in or out
    const W = stage.offsetWidth;
    const H = stage.offsetHeight;
    const scale = (Math.min(rect.width / k / W, rect.height / k / H) || 0.05) * DOCK.tuck;
    const cx = (rect.left - box.left + rect.width / 2) / k;
    const cy = (rect.top - box.top + rect.height / 2) / k;
    return `translate(${cx - (W / 2) * scale}px, ${cy - (H / 2) * scale}px) scale(${scale})`;
  }, []);
  const docked = React.useCallback(
    (id: string) => {
      const taken = snapshots.current.get(id);
      snapshots.current.delete(id);
      return taken ?? dockedNow(id);
    },
    [dockedNow],
  );

  const select = React.useCallback(
    (id: string, reason: "pick" | "autoplay") => {
      if (id === activeRef.current) return;
      const snapshot = dockedNow(id);
      if (snapshot) snapshots.current.set(id, snapshot);
      activeRef.current = id;
      elapsed.current = 0;
      setInner(id);
      onValueChange?.(id, reason);
    },
    [onValueChange, dockedNow],
  );

  const choose = (id: string) => {
    setStopped(true);
    select(id, "pick");
  };

  // The stage scales every window's content from its design size to the width it gets.
  React.useLayoutEffect(() => {
    const stage = stageRef.current;
    if (!stage) return;
    const fit = () => {
      stage.style.setProperty("--launch-dock-scale", String(stage.offsetWidth / width));
      stage.dataset.measured = "";
    };
    fit();
    const observer = new ResizeObserver(fit);
    observer.observe(stage);
    return () => observer.disconnect();
  }, [width]);

  // Where the label and the dot sit over an icon, in the tray's px.
  const centerOf = (id: string) => {
    const tab = tabs.current.get(id);
    return tab ? tab.offsetLeft + tab.offsetWidth / 2 : 0;
  };

  const placeLabel = React.useCallback((id: string, glide: boolean) => {
    const w = (labelWidths.current.get(id) ?? 48) + DOCK.labelPad;
    const x = centerOf(id) - w / 2;
    if (glide) {
      animate(labelX, x, TIMING.glide);
      animate(labelW, w, TIMING.glide);
    } else {
      labelX.jump(x);
      labelW.jump(w);
    }
  }, [labelX, labelW]);

  const placeDot = React.useCallback(
    (id: string | undefined, glide: boolean) => {
      if (!id) return;
      const w = running ? DOCK.bar : DOCK.dot;
      const x = centerOf(id) - w / 2;
      if (glide) {
        animate(dotX, x, TIMING.glide);
        animate(dotW, w, TIMING.glide);
      } else {
        dotX.jump(x);
        dotW.jump(w);
      }
    },
    [running, dotX, dotW],
  );

  // Measure every label once (and again when fonts land), so the label's width can glide.
  React.useLayoutEffect(() => {
    const box = measurer.current;
    if (!box) return;
    const read = () => {
      box.querySelectorAll<HTMLElement>("[data-label]").forEach((node) => {
        labelWidths.current.set(node.dataset.label!, node.offsetWidth);
      });
    };
    read();
    let live = true;
    document.fonts?.ready.then(() => {
      if (live) read();
    });
    return () => {
      live = false;
    };
  }, [items]);

  React.useLayoutEffect(() => {
    placeDot(active, placed.current.dot);
    placed.current.dot = true;
  }, [active, placeDot]);

  React.useLayoutEffect(() => {
    if (!chip?.on) return;
    placeLabel(chip.id, placed.current.label);
    placed.current.label = true;
  }, [chip, placeLabel]);

  // Icons resize with the container (cqi), so keep the label and dot over theirs.
  React.useEffect(() => {
    const tray = trayRef.current;
    if (!tray) return;
    const observer = new ResizeObserver(() => {
      placeDot(activeRef.current, false);
      if (chip) placeLabel(chip.id, false);
    });
    observer.observe(tray);
    return () => observer.disconnect();
  }, [chip, placeDot, placeLabel]);

  React.useEffect(() => {
    const update = () => setPageHidden(document.visibilityState === "hidden");
    document.addEventListener("visibilitychange", update);
    return () => document.removeEventListener("visibilitychange", update);
  }, []);

  // The countdown: a plain rAF clock that only runs while nothing pauses it, so the bar holds
  // where it is and carries on from there.
  React.useEffect(() => {
    if (!running) {
      fill.set(1);
      return;
    }
    fill.set(clamp(elapsed.current / interval, 0, 1));
    if (paused) return;
    let frame = 0;
    let last = performance.now();
    const tick = (now: number) => {
      elapsed.current += now - last;
      last = now;
      if (elapsed.current >= interval) {
        const index = items.findIndex((item) => item.id === activeRef.current);
        select(items[(index + 1) % items.length].id, "autoplay");
      }
      fill.set(clamp(elapsed.current / interval, 0, 1));
      frame = requestAnimationFrame(tick);
    };
    frame = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(frame);
  }, [running, paused, interval, items, select, fill]);

  React.useEffect(
    () => () => {
      window.clearTimeout(leaveTimer.current);
      window.clearTimeout(flashTimer.current);
    },
    [],
  );

  const showChip = (id: string) => {
    window.clearTimeout(leaveTimer.current);
    window.clearTimeout(flashTimer.current);
    setChip({ id, on: true });
  };
  const hideChip = (delay: number) => {
    window.clearTimeout(leaveTimer.current);
    leaveTimer.current = window.setTimeout(() => {
      setChip((current) => (current ? { ...current, on: false } : current));
      setHover(null);
    }, delay);
  };

  const handleTab = (type: TabEvent, id: string, event: React.SyntheticEvent<HTMLElement>) => {
    const pointer = (event as React.PointerEvent<HTMLElement>).pointerType;
    switch (type) {
      case "down":
        pointerType.current = pointer;
        if ((event as React.PointerEvent<HTMLElement>).button === 0) setPressed(id);
        break;
      case "up":
        setPressed(null);
        break;
      case "enter":
        if (pointer !== "mouse") break;
        setHover(id);
        showChip(id);
        break;
      case "leave":
        setPressed(null);
        if (pointer === "mouse") hideChip(TIMING.leave);
        break;
      case "focus":
        if (event.currentTarget.matches(":focus-visible")) showChip(id);
        break;
      case "blur":
        hideChip(0);
        break;
      case "click":
        if (pointerType.current !== "mouse" && pointerType.current !== "keyboard") {
          showChip(id);
          flashTimer.current = window.setTimeout(
            () => setChip((current) => (current ? { ...current, on: false } : current)),
            TIMING.touchLabel,
          );
        }
        pointerType.current = "mouse";
        choose(id);
        break;
    }
  };
  const tabHandler = React.useRef(handleTab);
  React.useLayoutEffect(() => {
    tabHandler.current = handleTab;
  });
  const onTabEvent = React.useCallback(
    (type: TabEvent, id: string, event: React.SyntheticEvent<HTMLElement>) => tabHandler.current(type, id, event),
    [],
  );

  const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    const index = items.findIndex((item) => item.id === (event.target as HTMLElement).dataset.launchDockItem);
    if (index < 0) return;
    const last = items.length - 1;
    const next =
      event.key === "ArrowRight" || event.key === "ArrowDown"
        ? (index + 1) % items.length
        : event.key === "ArrowLeft" || event.key === "ArrowUp"
          ? (index - 1 + items.length) % items.length
          : event.key === "Home"
            ? 0
            : event.key === "End"
              ? last
              : -1;
    if (next < 0) return;
    event.preventDefault();
    pointerType.current = "keyboard";
    const id = items[next].id;
    tabs.current.get(id)?.focus();
    choose(id);
  };

  return (
    <div
      ref={rootRef}
      data-launch-dock=""
      className={cn("@container/launch-dock relative flex w-full flex-col items-center", className)}
      style={
        {
          "--launch-dock-n": Math.max(items.length, 1),
          "--launch-dock-icon": "clamp(1.75rem, min(calc((100cqi - 2.5rem) / var(--launch-dock-n) - 0.625rem), 9cqi), 3.5rem)",
          "--launch-dock-corner": "calc(var(--launch-dock-icon) * var(--launch-dock-roundness, 0.225))",
          "--launch-dock-gap": "clamp(0.375rem, calc(var(--launch-dock-icon) * 0.18), 0.625rem)",
          ...style,
        } as React.CSSProperties
      }
      onPointerEnter={(event) => {
        if (event.pointerType === "mouse") setHovering(true);
        onPointerEnter?.(event);
      }}
      onPointerLeave={(event) => {
        setHovering(false);
        onPointerLeave?.(event);
      }}
      onFocus={(event) => {
        setFocusWithin(true);
        onFocus?.(event);
      }}
      onBlur={(event) => {
        if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setFocusWithin(false);
        onBlur?.(event);
      }}
      {...props}
    >
      <div
        ref={stageRef}
        data-launch-dock-stage=""
        className="group/launch-dock-stage relative z-0 w-full"
        style={{ maxWidth: width }}
      >
        {/* Holds the stage open at the window's size; the windows themselves are stacked over it. */}
        <div aria-hidden className="pointer-events-none invisible flex flex-col">
          {frame === "window" ? <div className={cn(BAR, "shrink-0")} /> : null}
          <div style={{ aspectRatio: `${width} / ${height}` }} />
        </div>
        {items.map((item) => (
          <DockWindow
            key={item.id}
            item={item}
            open={item.id === active}
            frame={frame}
            fade={fade}
            reduced={reduced}
            duration={Math.max(0.01, duration)}
            docked={docked}
            width={width}
            height={height}
            tabId={`${uid}-tab-${item.id}`}
            panelId={`${uid}-panel-${item.id}`}
            className={windowClassName}
          />
        ))}
      </div>

      <div
        className={cn(
          "relative z-10 flex max-w-full justify-center px-2",
          fade ? "-mt-[calc(var(--launch-dock-icon)*0.5+1.25rem)]" : "mt-4",
        )}
      >
        <div
          ref={trayRef}
          role="tablist"
          aria-label={dockLabel}
          aria-orientation="horizontal"
          data-launch-dock-tray=""
          onKeyDown={onKeyDown}
          className={cn(
            "relative flex items-end gap-[var(--launch-dock-gap)] rounded-[calc(var(--launch-dock-corner)+0.5rem)] bg-white/90 p-2 shadow-[0_12px_32px_-12px_rgb(0_0_0/0.28),0_2px_6px_-2px_rgb(0_0_0/0.08)] ring-1 ring-zinc-950/[0.07] dark:bg-zinc-900/90 dark:shadow-[0_12px_32px_-12px_rgb(0_0_0/0.7)] dark:ring-white/10",
            trayClassName,
          )}
        >
          {items.map((item) => (
            <DockTab
              key={item.id}
              item={item}
              active={item.id === active}
              lifted={!reduced && hover === item.id}
              dipped={!reduced && pressed === item.id}
              grow={hoverScale}
              tabId={`${uid}-tab-${item.id}`}
              panelId={`${uid}-panel-${item.id}`}
              register={register}
              onTabEvent={onTabEvent}
            />
          ))}

          {/* The open app's dot, which counts down as a bar while autoplay runs. */}
          <motion.span
            aria-hidden
            style={{ x: dotX, width: dotW }}
            className="pointer-events-none absolute bottom-[3px] left-0 h-1 overflow-hidden rounded-full bg-zinc-950/20 dark:bg-white/25"
          >
            <motion.span
              style={{ scaleX: fill }}
              className="absolute inset-0 origin-left rounded-full bg-zinc-950/60 dark:bg-white/75"
            />
          </motion.span>

          {/* One label for the whole dock: it glides from icon to icon instead of blinking. */}
          <motion.div
            aria-hidden
            style={{ x: labelX, width: labelW }}
            className="pointer-events-none absolute bottom-full left-0 mb-3 flex justify-center"
          >
            <motion.div
              initial={false}
              animate={
                chip?.on
                  ? { opacity: 1, transform: "translateY(0px) scale(1)" }
                  : { opacity: 0, transform: "translateY(4px) scale(0.96)" }
              }
              transition={{
                ...(chip?.on ? TIMING.show : TIMING.hide),
                transform: reduced ? { duration: 0 } : chip?.on ? TIMING.show : TIMING.hide,
              }}
              className="w-full origin-bottom rounded-lg bg-zinc-900 py-1 text-center text-xs font-medium whitespace-nowrap text-white shadow-[0_4px_12px_-4px_rgb(0_0_0/0.35)] ring-1 ring-white/10 dark:bg-zinc-100/95 dark:text-zinc-900 dark:ring-zinc-950/10"
            >
              {items.find((item) => item.id === chip?.id)?.label ?? ""}
            </motion.div>
          </motion.div>

          {/* Hidden copies of every label, measured once so the label's width can glide. */}
          <div ref={measurer} aria-hidden className="pointer-events-none invisible absolute top-0 left-0 flex">
            {items.map((item) => (
              <span key={item.id} data-label={item.id} className="text-xs font-medium whitespace-nowrap">
                {item.label}
              </span>
            ))}
          </div>
        </div>
      </div>
    </div>
  );
}
```
