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.
<LaunchDock />The demo follows as you drag.
Research / Field notes
Edited just now by Inês Moreau
Six interviewsTomás 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.
Nobody trusts the copy they're looking at to be the latest one, so they paste it into chat to be sure. Two of them keep a printed version on the wall.
Can we lead the readout with this line?
Left to spend in November
$2,481.20
Daily spending
Nov 1 – 24
Salary · Halden Lab
Nov 1
+$4,200.00
Rent · Calle da Luna
Nov 1
−$1,350.00
Bakehouse on Rua Nova
Today
−$8.40
Metro pass
Yesterday
−$42.00
Livraria Sá da Costa
Nov 10
−$27.90
Album · 2026
Saint Juniper · 11 songs, 41 min
Open questions
- · Do we show pricing before the invite step?
- · What does an empty workspace look like?
# launch
14 members
Priya Raman9:12
Build 412 is on staging. Can everyone give the new invite flow a spin before standup?
Jonas Weber9:20
Invites from a phone work now. The email preview still shows the old logo though.
Mei Tanaka9:24
Fixed in 413, deploying now. Attaching the before and after.
invite-email-before-after.png
418 KB
Priya is typing…
Plume · Docs that research teams write together, offline on the train.
"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'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>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content. Launch Dock: 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. This demo, 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. 1. Install it: npx shadcn@latest add @keyframeui/launch-dock-demo (If the CLI can't find @keyframeui, use https://keyframeui.com/r/launch-dock-demo.json instead.) It adds the demo, components/launch-dock-demo.tsx, and the component, components/ui/launch-dock.tsx. 2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <LaunchDock> on its own. 3. These props set its feel. Change them before you change the file: - frame (default "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 (default 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 (default 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 (default 1.1): The scale an icon grows to as it rises under the pointer. `1` keeps its size. 4. Don't change, in components/ui/launch-dock.tsx: - 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 is all in the TIMING block at the top of that file. Docs: https://keyframeui.com/components/launch-dock.md
Installation
Add the component on its own with the CLI, or copy the file in by hand.
The CLI writes components/ui/launch-dock.tsx and installs motion, clsx and tailwind-merge. It needs the @keyframeui registry in components.json; add it once per project.
npx shadcn@latest add @keyframeui/launch-dockInstall the dependencies
npm i motion clsx tailwind-mergeAdd the cn helper
Skip this if you already use shadcn/ui.
lib/utils.tsimport { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }Copy the source into your project
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> ); }Update the import paths
The file imports
cnfrom@/lib/utils. Change the path if your project uses a different alias.
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.
Okto for teams
Okto writes up our client calls before I'm back at my desk. I haven't typed a meeting note since March.
Inês Moreau
Partner, Atelier Sund
Follow-ups used to cost us a day a week. Now they go out while the call is still fresh for everyone.
Rafael Santos
Head of Sales, Kvarter
I search last month's calls like my inbox. “What did the client say about the ending?” Found in a second.
Mei Tanaka
Producer, Northwind Films
Our standups are eight minutes now. Okto posts the decisions to the right channel, so nobody retells them.
Luca Bianchi
Engineering Lead, Harbor
The first tool my whole team adopted without a training session. It just shows up in the calls we already have.
Amara Okafor
Founder, Field & Fold
It caught a budget number we'd all misheard on the call. That one note paid for the year.
Jonas Weber
Brand Director, Tinta
"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>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content. Launch Dock: 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. This demo, 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. 1. Install it: npx shadcn@latest add @keyframeui/launch-dock-quotes-demo (If the CLI can't find @keyframeui, use https://keyframeui.com/r/launch-dock-quotes-demo.json instead.) It adds the demo, components/launch-dock-quotes-demo.tsx, and the component, components/ui/launch-dock.tsx. 2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <LaunchDock> on its own. 3. These props set its feel. Change them before you change the file: - frame (default "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 (default 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 (default 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 (default 1.1): The scale an icon grows to as it rises under the pointer. `1` keeps its size. 4. Don't change, in components/ui/launch-dock.tsx: - 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 is all in the TIMING block at the top of that file. Docs: https://keyframeui.com/components/launch-dock.md
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.
A small practice on the west coast, building eight homes a year in timber, stone and glass.
warm.
The Model 01 has no logo on its face and no second hand. Just the hour, and enough of the minute.
New · The Shell Chair
18oz canvas, double-stitched handles, and free repairs for as long as you own one.
Halden House · Website and identity, 2026
"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>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content. Launch Dock: 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. This demo, 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. 1. Install it: npx shadcn@latest add @keyframeui/launch-dock-studio-demo (If the CLI can't find @keyframeui, use https://keyframeui.com/r/launch-dock-studio-demo.json instead.) It adds the demo, components/launch-dock-studio-demo.tsx, and the component, components/ui/launch-dock.tsx. 2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <LaunchDock> on its own. 3. These props set its feel. Change them before you change the file: - frame (default "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 (default 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 (default 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 (default 1.1): The scale an icon grows to as it rises under the pointer. `1` keeps its size. 4. Don't change, in components/ui/launch-dock.tsx: - 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 is all in the TIMING block at the top of that file. Docs: https://keyframeui.com/components/launch-dock.md
Props
| Prop | Type | Default | Description |
|---|---|---|---|
items Required | LaunchDockItem[] | None | 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 | None | The open app's id, when you control it. Pair it with onValueChange. |
defaultValue | string | None | The app that's open at first. Defaults to the first one. |
onValueChange | (id: string, reason: "pick" | "autoplay") => void | None | 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 | None | Classes for the dock's surface: its fill, ring and shadow. |
windowClassName | string | None | Classes for each window's surface: its fill and ring. |
className | string | None | 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. |
itemsRequiredType
LaunchDockItem[]The apps, in dock order. Each one is
{ id, label, icon, content, title? }.labelnames it in the gliding label, the title bar and for screen readers;iconis clipped to the dock's rounded square, so a full-bleed<img>or SVG tile works;contentis what its window shows, laid out atwindowSize;titlereplaces the label in the title bar (a domain, a file name).valueType
stringThe open app's id, when you control it. Pair it with
onValueChange.defaultValueType
stringThe app that's open at first. Defaults to the first one.
onValueChangeType
(id: string, reason: "pick" | "autoplay") => voidCalled 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.
autoplayType
boolean | numberDefaultfalseLaunch 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.
windowSizeType
[number, number]Default[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.
frameType
"window" | "card" | "none"Default"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.
fadeType
booleanDefaulttrueFade 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.
durationType
numberDefault0.3Seconds 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.
hoverScaleType
numberDefault1.1The scale an icon grows to as it rises under the pointer.
1keeps its size.dockLabelType
stringDefault"Apps"The dock's accessible name, read out when focus enters it.
trayClassNameType
stringClasses for the dock's surface: its fill, ring and shadow.
windowClassNameType
stringClasses for each window's surface: its fill and ring.
classNameType
stringExtra 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.