A testimonials section where the proof is the customers' own apps. Under the headline a Launch Dock walks through six apps built on one sync platform, each window growing up out of its icon, while the room takes the open app's color and its founder's quote settles in underneath. Handwritten notes in the margins point at the dock, and the one that says to tap an app rubs itself out once the visitor does.
registry/blocks/launch-dock-testimonials.tsx
"use client";
import * as React from "react";
import { Jot } from "@/components/ui/jot";
import { LaunchDock, type LaunchDockItem } from "@/components/ui/launch-dock";
/* ── Content ─────────────────────────────────────────────────
* Everything you'd change to make the section yours: the copy,
* the notes in the margin, and each customer's app, quote and
* tint. The app windows are plain markup further down; swap any
* of them for an <img> of your customer's real product.
* ─────────────────────────────────────────────────────────── */
const BRAND = "Shoal";
const EYEBROW = "Customers";
const HEADLINE = "The apps people keep open all day";
const HEADLINE_QUIET = "stay in sync on Shoal.";
const SUBLINE = "Docs, calendars, budgets and boards that work offline and merge without a conflict.";
/** Handwritten notes beside the dock. The hint rubs itself out once the visitor picks an app. */
const NOTES = { apps: "a few of the apps on Shoal", hint: "tap one to open it" };
const face = (id: string) =>
`https://images.unsplash.com/photo-${id}?auto=format&fit=crop&crop=faces&w=96&h=96&q=80`;
const photo = (id: string, w: number, h: number) =>
`https://images.unsplash.com/photo-${id}?auto=format&fit=crop&w=${w}&h=${h}&q=80`;
type Customer = {
id: string;
app: string;
/** The window's title bar. */
title: string;
/** The room light while this app is open, as an "r g b" triplet. */
tint: string;
quote: string;
person: { name: string; role: string; photo: string };
};
const CUSTOMERS: Customer[] = [
{
id: "plume",
app: "Plume",
title: "Field notes: Lisbon offsite",
tint: "99 102 241",
quote: "We deleted four thousand lines of sync code in a weekend. Offline edits just merge now.",
person: { name: "Inês Moreau", role: "CTO, Plume", photo: "1438761681033-6461ffad8d80" },
},
{
id: "cadence",
app: "Cadence",
title: "Cadence: week 46",
tint: "244 63 94",
quote: "Move a meeting on one laptop and it's moved on forty phones before anyone thinks to refresh.",
person: { name: "Rafael Santos", role: "Founder, Cadence", photo: "1507003211169-0a1dd7228f2d" },
},
{
id: "tally",
app: "Tally",
title: "Tally: November budget",
tint: "16 185 129",
quote: "Two phones, one budget, zero conflicts. Tickets about wrong totals stopped the week we switched.",
person: { name: "Luca Bianchi", role: "Engineering Lead, Tally", photo: "1500648767791-00dcc994a43e" },
},
{
id: "loop",
app: "Loop",
title: "Loop",
tint: "249 115 22",
quote: "Pause on the train, press play at home. The handoff feels like one device now.",
person: { name: "Jonas Weber", role: "iOS Lead, Loop", photo: "1539571696357-5a69c17a67c6" },
},
{
id: "tessera",
app: "Tessera",
title: "Onboarding flow v2: Tessera",
tint: "245 158 11",
quote: "Forty people on one board, and every sticky lands exactly where its owner dropped it.",
person: { name: "Mei Tanaka", role: "Head of Engineering, Tessera", photo: "1534528741775-53994a69daeb" },
},
{
id: "pigeon",
app: "Pigeon",
title: "# launch: Northwind",
tint: "14 165 233",
quote: "A message sent in a dead zone arrives once, in order, the second the signal comes back.",
person: { name: "Amara Okafor", role: "CTO, Pigeon", photo: "1531123897727-8f129e1688ce" },
},
];
/* ── Section ─────────────────────────────────────────────── */
export function LaunchDockTestimonials() {
const [open, setOpen] = React.useState(CUSTOMERS[0].id);
const [picked, setPicked] = React.useState(false);
const items: LaunchDockItem[] = React.useMemo(
() =>
CUSTOMERS.map((customer) => ({
id: customer.id,
label: customer.app,
title: customer.title,
icon: ICONS[customer.id],
content: WINDOWS[customer.id],
})),
[],
);
return (
<section data-jot-scope className="relative isolate overflow-hidden bg-white text-zinc-950 dark:bg-zinc-950 dark:text-white">
{/* The room takes the open app's light, washing down from the top. */}
{CUSTOMERS.map((customer) => (
<div
key={customer.id}
aria-hidden
style={{ "--tint": customer.tint } as React.CSSProperties}
className={`pointer-events-none absolute inset-x-0 top-0 -z-10 h-[75%] bg-[linear-gradient(to_bottom,rgb(var(--tint)/0.13),rgb(var(--tint)/0.04)_55%,transparent)] transition-opacity duration-700 ease-out motion-reduce:transition-none dark:bg-[linear-gradient(to_bottom,rgb(var(--tint)/0.2),rgb(var(--tint)/0.05)_55%,transparent)] ${
customer.id === open ? "opacity-100" : "opacity-0"
}`}
/>
))}
<div className="mx-auto max-w-6xl px-6 pt-16 pb-24 sm:pt-20 sm:pb-28">
<div className="mx-auto max-w-2xl text-center">
<p className="inline-flex items-center gap-1.5 rounded-full bg-zinc-950/[0.04] px-3 py-1 text-xs font-medium text-zinc-600 ring-1 ring-zinc-950/[0.06] dark:bg-white/[0.06] dark:text-zinc-300 dark:ring-white/10">
<SyncIcon className="size-3.5" />
{EYEBROW}
</p>
<h2 className="mt-5 text-3xl font-semibold tracking-[-0.03em] text-balance sm:text-5xl">
{HEADLINE} <span className="text-zinc-400 dark:text-zinc-500">{HEADLINE_QUIET}</span>
</h2>
<p className="mx-auto mt-4 max-w-2xl text-base leading-relaxed text-pretty text-zinc-600 dark:text-zinc-400">
{SUBLINE}
</p>
</div>
{/* The window sizes to the screen's height too, so the dock is in view with the headline. */}
<div className="relative mx-auto mt-12 max-w-5xl">
<LaunchDock
items={items}
value={open}
onValueChange={(id, reason) => {
setOpen(id);
if (reason === "pick") setPicked(true);
}}
autoplay={4000}
windowSize={[960, 560]}
dockLabel={`Apps built on ${BRAND}`}
className="mx-auto max-w-[clamp(36rem,calc((100svh-26rem)*1.7),60rem)]"
/>
{/* Margin notes, on wide screens where the margins have room for them. */}
<Jot
to="[data-launch-dock-tray]"
from="right"
at="left"
bend={0.45}
tilt={-3}
delay={0.3}
className="absolute bottom-3 left-0 hidden max-w-none whitespace-nowrap text-zinc-500 xl:block dark:text-zinc-400"
>
{NOTES.apps}
</Jot>
<Jot
to="[data-launch-dock-tray]"
from="left"
at="right"
loop
bend={-0.35}
tilt={2}
delay={0.9}
show={!picked}
className="absolute right-0 bottom-1 hidden max-w-none whitespace-nowrap text-zinc-500 xl:block dark:text-zinc-400"
>
{NOTES.hint}
</Jot>
</div>
{/* Every quote sits in one cell, so the section keeps the tallest one's height. */}
<div className="mx-auto mt-10 grid max-w-2xl text-center">
{CUSTOMERS.map((customer) => {
const shown = customer.id === open;
return (
<figure
key={customer.id}
aria-hidden={!shown}
className={`[grid-area:1/1] transition-[opacity,translate] duration-500 ease-out motion-reduce:transition-none ${
shown ? "translate-y-0 opacity-100" : "pointer-events-none translate-y-1.5 opacity-0"
}`}
>
<blockquote className="text-lg leading-relaxed font-medium tracking-[-0.01em] text-balance sm:text-xl">
“{customer.quote}”
</blockquote>
<figcaption className="mt-5 flex items-center justify-center gap-3 text-sm">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={face(customer.person.photo)} alt="" className="size-9 rounded-full object-cover" />
<span className="text-left">
<span className="block font-semibold">{customer.person.name}</span>
<span className="block text-zinc-500 dark:text-zinc-400">{customer.person.role}</span>
</span>
</figcaption>
</figure>
);
})}
</div>
</div>
</section>
);
}
/* ── The customers' apps ─────────────────────────────────── */
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={`text-[14px] font-semibold ${ink}`}>{title}</div>
{children}
</aside>
);
}
function Row({ 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>
);
}
function Group({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-0.5">
<p className={`px-2 pb-1 text-[11px] font-medium tracking-wide uppercase ${muted}`}>{label}</p>
{children}
</div>
);
}
const DAYS = ["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" },
];
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" },
];
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" },
];
const STICKIES = [
{ 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" },
];
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." },
];
const WINDOWS: Record<string, React.ReactNode> = {
plume: (
<div className="flex h-full bg-white dark:bg-zinc-900">
<Side title="Halden Lab">
<div className="flex flex-col gap-0.5">
<Row count={4}>Inbox</Row>
<Row>Drafts</Row>
<Row>Shared with me</Row>
</div>
<Group label="Research">
<Row on>Field notes: Lisbon</Row>
<Row>Interview guide v3</Row>
<Row>Survey synthesis</Row>
<Row>Q4 research plan</Row>
</Group>
</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>
),
cadence: (
<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 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>
</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} className={`-mt-2 h-[52px] pr-2 text-right text-[11px] tabular-nums ${muted}`}>
{h > 12 ? h - 12 : h} {h >= 12 ? "PM" : "AM"}
</div>
))}
</div>
{DAYS.map((day, d) => (
<div key={day} className={`relative flex-1 border-l ${hairline}`}>
<div className={`flex h-10 items-center 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} className={`h-[52px] border-t ${hairline}`} />
))}
{EVENTS.filter((event) => event.day === d).map((event) => (
<div
key={event.title}
style={{ top: 40 + (event.start - 9) * 52 + 2, height: (event.end - event.start) * 52 - 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 * 52 }} className="absolute right-0 -left-1 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>
),
tally: (
<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>
),
loop: (
<div className="flex h-full flex-col bg-zinc-950 text-white">
<div className="flex flex-1 overflow-hidden">
<aside className="flex w-[212px] shrink-0 flex-col gap-1 border-r border-white/[0.07] px-4 py-5 text-[13px] text-zinc-400">
<p className="mb-5 text-[14px] font-semibold text-white">Loop</p>
<p className="px-2 py-1.5">Listen now</p>
<p className="px-2 py-1.5">Radio</p>
<p className="rounded-md bg-white/[0.07] px-2 py-1.5 font-medium text-white">Library</p>
</aside>
<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>
),
tessera: (
<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>
{STICKIES.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>
))}
<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>
<Cursor x={560} y={300} name="Mei" className="fill-pink-500" tag="bg-pink-500" />
<Cursor x={700} y={420} name="Jonas" className="fill-sky-500" tag="bg-sky-500" />
</div>
),
pigeon: (
<div className="flex h-full bg-white dark:bg-zinc-900">
<Side title="Northwind">
<Group label="Channels">
<Row># general</Row>
<Row on># launch</Row>
<Row count={3}># design</Row>
<Row># support</Row>
</Group>
<Group label="Direct">
<Row>Priya Raman</Row>
<Row>Mei Tanaka</Row>
</Group>
</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>
<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>
),
};
function Cursor({ x, y, name, className, tag }: { x: number; y: number; name: string; className: string; tag: string }) {
return (
<div style={{ left: x, top: y }} className="absolute 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={className} />
</svg>
<span className={`ml-3 rounded-full px-2 py-0.5 text-[11px] font-semibold text-white ${tag}`}>{name}</span>
</div>
);
}
/* ── Icons ───────────────────────────────────────────────── */
function Tile({ id, from, to, children }: { id: string; from: string; to: string; children: React.ReactNode }) {
return (
<svg viewBox="0 0 64 64" aria-hidden>
<defs>
<linearGradient id={`shoal-${id}-bg`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor={from} />
<stop offset="1" stopColor={to} />
</linearGradient>
<linearGradient id={`shoal-${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(#shoal-${id}-bg)`} />
<rect width="64" height="64" fill={`url(#shoal-${id}-sheen)`} />
{children}
</svg>
);
}
const ICONS: Record<string, React.ReactNode> = {
plume: (
<Tile id="plume" 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 id="cadence" 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 id="tally" 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 id="loop" 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 id="tessera" 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 id="pigeon" 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>
),
};
function SyncIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.5} className={className} aria-hidden>
<path d="M13 6.5A5 5 0 0 0 3.6 4.4M3 9.5a5 5 0 0 0 9.4 2.1" strokeLinecap="round" />
<path d="M13.2 2.6v3.9H9.3M2.8 13.4V9.5h3.9" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
Live preview and the components it's built with: https://keyframeui.com/blocks/testimonials/launch-dock-testimonials