# Launch Dock: 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.

A shadcn/ui block from the [@keyframeui](https://keyframeui.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/keyframeui/launch-dock-demo).

- Page, with a live preview: https://shadcn.exchange/r/keyframeui/launch-dock-demo
- Screenshot: https://shadcn.exchange/shot/keyframeui/launch-dock-demo/light.png
- Registry item: https://keyframeui.com/r/launch-dock-demo.json
- Categories: showcase, testimonials, motion
- Listed: 2026-11-02, source last changed 2026-11-02

## Install

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

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- Registry items: `https://keyframeui.com/r/launch-dock.json`

## Notes from the registry

Live preview, examples and props: https://keyframeui.com/components/launch-dock#launch-dock-demo

## Files

### `registry/demos/launch-dock-demo.tsx`

```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>
  );
}
```
