Tabs
An open-ended, closable tab collection whose panel renders either in the layout or in a popover anchored to its own tab.
Getting started
Overview
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
Before you begin
Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo.
"use client";
import { Tabs, useTabs } from "@intentface/chat/tabs";
import { File, Plus, X } from "@keyline-icons/react";
import { useState } from "react";
import { type Document, DocumentBody, LOREM, SEEDED } from "./document";
import { TabStrip } from "./tab-strip";
/*
* Document tabs across the window chrome, with the content in a card beneath.
*
* Both the strip and the viewport take a function, so neither needs a map, a
* key, or a subscription of its own. The viewport is one box, not a panel per
* tab: switching re-renders the same element rather than mounting a new one.
* Close the last tab and nothing is open — an ordinary state here, which is
* why this is a toolbar of disclosures rather than an ARIA tablist.
*/
export const Basic = () => {
const [documents, setDocuments] = useState(SEEDED);
return (
<div className="relative tabs-demo flex h-[32rem] w-full flex-col overflow-hidden rounded-xl bg-[#f5f5f6] shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_4px_8px_-2px_rgb(0_0_0/0.05)] dark:bg-[#131315] dark:shadow-[0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:after:pointer-events-none dark:after:absolute dark:after:inset-0 dark:after:z-50 dark:after:rounded-[inherit] dark:after:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06)]">
<Tabs.Root
defaultItems={Object.keys(SEEDED)}
defaultValue="doc-1"
selectOnClose="adjacent"
className="flex min-h-0 flex-1 flex-col"
>
{/* The list iterates the collection itself, which leaves no room inside
it for chrome — so the add button is its sibling, not its child. The
scroller hugs its content, so the button sits beside the last tab
until the tabs overflow, then holds the strip's end. */}
<div className="flex shrink-0 items-center gap-1 p-2">
<TabStrip>
<Tabs.List aria-label="Open documents" className="flex items-center gap-1">
{(id) => (
<Tabs.Trigger
value={id}
aria-label={documents[id]?.name ?? id}
className={tabClass}
>
<Tabs.Icon className="text-zinc-500 dark:text-zinc-400 [&>svg]:size-[15px] [&>svg]:shrink-0">
<File className="size-[15px]" />
</Tabs.Icon>
<span className="min-w-0 truncate">{documents[id]?.name ?? id}</span>
{/* Positioned with a mask, so the label runs *under* it and
fades out — even a tab squeezed to a few characters keeps a
clean edge instead of colliding with the button. It shows
itself on hover and while the tab is open. */}
<Tabs.Action
className={[
// Inset 1px with a matching corner, so the cover never paints over the
// tab's ring and top highlight; it inherits the face's gradient too,
// not just its colour, so it doesn't read as a flat block.
"absolute inset-y-px right-px flex items-center rounded-r-[5px] bg-inherit [background-image:inherit] pr-1.5 pl-3",
"[mask-image:linear-gradient(to_right,transparent,#000_0.5rem)]",
"opacity-0 transition-opacity group-hover/tab:opacity-100 group-data-[selected]/tab:opacity-100",
].join(" ")}
>
<Tabs.Close
aria-label={`Close ${documents[id]?.name ?? id}`}
className="grid size-5 shrink-0 cursor-pointer select-none place-items-center rounded-full text-zinc-400 transition-colors hover:bg-zinc-950/5 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0169cc]/60 dark:text-zinc-500 dark:hover:bg-white/8 dark:hover:text-zinc-100"
>
<X className="size-3.5" />
</Tabs.Close>
</Tabs.Action>
</Tabs.Trigger>
)}
</Tabs.List>
</TabStrip>
<NewDocument
onCreate={(id, document) => setDocuments((current) => ({ ...current, [id]: document }))}
/>
</div>
{/* Hidden rather than absent when nothing is open, so the card's
place in the layout is held. */}
<Tabs.Viewport className="mx-2 mb-2 min-h-0 flex-1 overflow-auto rounded-lg bg-white shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_4px_8px_-2px_rgb(0_0_0/0.05)] data-[empty]:invisible dark:bg-zinc-900 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)]">
{(id) => <DocumentBody document={documents[id]} />}
</Tabs.Viewport>
</Tabs.Root>
</div>
);
};
/** `open()` adds the tab and selects it in one move — nothing here cleans up. */
const NewDocument = ({ onCreate }: { onCreate: (id: string, document: Document) => void }) => {
const open = useTabs((tabs) => tabs.open);
const [drafts, setDrafts] = useState(0);
return (
<button
type="button"
aria-label="New document"
onClick={() => {
const id = `draft-${drafts + 1}`;
onCreate(id, {
name: `Untitled ${drafts + 1}`,
sections: [{ heading: "Empty", paragraphs: [LOREM.short] }],
});
setDrafts((count) => count + 1);
open(id);
}}
className="grid size-7 shrink-0 cursor-pointer select-none place-items-center rounded-full text-zinc-400 transition-colors hover:bg-zinc-950/5 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0169cc]/60 dark:text-zinc-500 dark:hover:bg-white/8 dark:hover:text-zinc-100"
>
<Plus className="size-[15px]" />
</button>
);
};
/*
* The selected tab is lifted onto a card. `group/tab` is declared here rather
* than on the strip, which is what lets `Action` reveal itself on hover without
* the strip knowing the group's name.
*
* `relative` and `overflow-hidden` are both load-bearing: the action positions
* against this box, and the label has to clip under it.
*/
const tabClass = [
"group/tab relative flex h-[30px] max-w-56 shrink-0 cursor-pointer select-none items-center gap-1.5 overflow-hidden",
"rounded-md px-2.5 font-medium text-[13px] text-zinc-700 transition-[background-color,color] duration-200 dark:text-zinc-300",
// Opaque rather than a translucent wash: the action inherits this colour, and
// a wash painted twice would show as a darker band behind the ×.
"hover:bg-[#e9e9ea] dark:hover:bg-[#262628]",
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0169cc]/60",
// The raised ring is a shadow outside the box, so TabStrip pads the scroller
// to keep it (and the focus outline) from being clipped.
"data-[selected]:bg-white data-[selected]:bg-linear-to-b data-[selected]:from-white data-[selected]:to-[#fdfdfd] data-[selected]:text-zinc-900",
"data-[selected]:shadow-[inset_0_1px_0_#fff,0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.07),0_2px_6px_-2px_rgb(0_0_0/0.05)]",
"dark:data-[selected]:bg-[#2d2d30] dark:data-[selected]:from-[#313134] dark:data-[selected]:to-[#2a2a2d] dark:data-[selected]:text-zinc-100",
"dark:data-[selected]:shadow-[0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)]",
// The inset ring and top highlight go on an overlay above the content, so the
// close button's fading cover can't paint over them.
"dark:data-[selected]:after:pointer-events-none dark:data-[selected]:after:absolute dark:data-[selected]:after:inset-0 dark:data-[selected]:after:rounded-[inherit] dark:data-[selected]:after:shadow-[inset_0_1px_0_rgb(255_255_255/0.1),inset_0_0_0_1px_rgb(255_255_255/0.05)]",
].join(" ");Usage guidelines
- Open-ended and closable — tabs are added and removed at runtime, unlike a fixed set of panels.
- Open-ness is the selection — there is no separate
openflag.valueis a tab id ornull, andnullmeans nothing is showing. A page-tab strip never reachesnull; a chat dock does, every time you close the last one. - Order is data —
itemsis an ordered array on the store, not something derived from the DOM. Drag it with whatever library you like throughrender; the result is a state change like any other. - One viewport, not a panel per tab — switching re-renders the same box, which is what lets a floating surface move rather than tear itself down.
- In the layout or anchored — wrapping the viewport in
Portal›Positioner›Popupis the entire difference between the two. - A toolbar, not a tablist — see Why a toolbar and not a tablist. ARIA's tablist cannot describe a closable, open-ended strip.
- Get started — see Quick start to add the package.
Anatomy
<Tabs.Root>
<Tabs.List>
<Tabs.Trigger>
<Tabs.Icon />
<Tabs.Action>
<Tabs.Close />
</Tabs.Action>
</Tabs.Trigger>
</Tabs.List>
{/* In the layout … */}
<Tabs.Viewport />
{/* … or floating over the open tab. */}
<Tabs.Portal>
<Tabs.Positioner>
<Tabs.Popup>
<Tabs.Viewport />
</Tabs.Popup>
</Tabs.Positioner>
</Tabs.Portal>
</Tabs.Root>One viewport per collection, in one of those two places — the three wrapping parts are the only difference between them.
Both the strip and the viewport take a function, so neither needs a loop, a key, or a subscription of its own:
<Tabs.Root defaultItems={["a", "b"]} defaultValue="a" selectOnClose="adjacent">
<Tabs.List>
{(id) => (
<Tabs.Trigger value={id}>
<Tabs.Icon />
{documents[id].title}
<Tabs.Action>
<Tabs.Close aria-label="Close tab" />
</Tabs.Action>
</Tabs.Trigger>
)}
</Tabs.List>
<Tabs.Viewport>{(id) => <Document id={id} />}</Tabs.Viewport>
</Tabs.Root>The viewport can sit anywhere in the tree — inside the popup for a floating surface, or in a card three components away while the strip stays in the window chrome.
Examples
Anchoring the panel to its tab
Wrap the viewport and the panel floats above the open tab instead of sitting in the layout. Nothing else changes — same Root, same List, same ARIA — because the content portals into the viewport wherever it happens to be:
Getting started
A chat dock is something you work behind: non-modal throughout, with no backdrop, no scroll lock, no focus trap, and no dismissal on outside press. Open a chat below, then keep reading — the page stays yours.
The Agent button is a trigger written outside the list. Its value is never in the collection, so it anchors a draft to itself without creating a tab — send something and a tab appears, titled by what you typed.
"use client";
import { Tabs, useTabs } from "@intentface/chat/tabs";
import { MessageSquare, Minus, Sparkles, X } from "@keyline-icons/react";
import { useRef, useState } from "react";
import { ChatThread, NewChat, REPLIES, SEEDED, type Turn } from "./chat";
/*
* A chat dock in the corner of a page. The same Root, List and Viewport as the
* document strip, with the viewport wrapped in Portal › Positioner › Popup —
* that wrapping is the entire difference between a panel in the layout and one
* floating over the open tab.
*
* What floats is a real chat, built from this package's own parts: a Thread
* with Messages and a docked Composer. One positioner serves the whole
* collection, anchored to whichever tab is open, so switching chats moves one
* surface rather than tearing it down.
*
* The store handle is created outside React. `Tabs.Root` takes it, and so does
* `start` below — which runs in the component that *renders* the Root and so
* is not a descendant of it. That is what the handle is for: state a command
* palette or a keyboard shortcut elsewhere on the page can reach. Anything
* inside the Root reads it through `useTabs` instead.
*
* A reply takes a moment to arrive. Escape anywhere in the chat stops it first,
* and only the next Escape closes the dock.
*/
const dockStore = Tabs.createStore();
/** The draft's value. It is never in `items` — that is the whole point. */
const DRAFT = "new-chat";
let created = 0;
export const Anchored = () => {
const [chats, setChats] = useState(SEEDED);
// The frame stands in for the window. Portaling into it makes it the
// collision boundary — the surface shifts and sizes against the demo rather
// than the browser viewport. A real dock leaves `container` alone.
const [frame, setFrame] = useState<HTMLDivElement | null>(null);
const title = (id: string) => (id === DRAFT ? "New chat" : (chats[id]?.name ?? id));
// Each chat's reply on its way, standing in for a model streaming one.
const [generating, setGenerating] = useState<ReadonlySet<string>>(new Set());
const pendingReplies = useRef(new Map<string, ReturnType<typeof setTimeout>>());
const append = (id: string, turn: Turn) =>
setChats((current) => {
const chat = current[id];
if (!chat) return current;
return { ...current, [id]: { ...chat, turns: [...chat.turns, turn] } };
});
const settle = (id: string) => {
pendingReplies.current.delete(id);
setGenerating((current) => {
const remaining = new Set(current);
remaining.delete(id);
return remaining;
});
};
const reply = (id: string, text: string) => {
const next = chats[id]?.turns.length ?? 0;
append(id, { id: `${next}-u`, role: "user", text });
clearTimeout(pendingReplies.current.get(id));
setGenerating((current) => new Set(current).add(id));
pendingReplies.current.set(
id,
setTimeout(() => {
append(id, {
id: `${next}-a`,
role: "assistant",
text: REPLIES[next % REPLIES.length] as string,
});
settle(id);
}, 2500),
);
};
const stop = (id: string) => {
clearTimeout(pendingReplies.current.get(id));
settle(id);
};
/** A chat the visitor started is titled by what they typed. */
const start = (text: string) => {
created += 1;
const id = `chat-new-${created}`;
setChats((current) => ({
...current,
[id]: {
name: text.length > 34 ? `${text.slice(0, 34)}…` : text,
turns: [
{ id: "1", role: "user", text },
{ id: "2", role: "assistant", text: REPLIES[0] as string },
],
},
}));
// Adds the tab, selects it, and drops the draft in the same move — nothing
// here has to clean anything up.
dockStore.getSnapshot().open(id);
};
return (
<div
ref={setFrame}
className="relative flex h-[36rem] w-full flex-col overflow-hidden rounded-xl bg-[#f5f5f6] shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_4px_8px_-2px_rgb(0_0_0/0.05)] dark:bg-[#131315] dark:shadow-[0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:after:pointer-events-none dark:after:absolute dark:after:inset-0 dark:after:z-50 dark:after:rounded-[inherit] dark:after:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06)]"
>
{/* The page the dock sits over. */}
<article className="mx-2 mt-2 min-h-0 flex-1 overflow-hidden rounded-lg bg-white px-10 py-8 shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_4px_8px_-2px_rgb(0_0_0/0.05)] dark:bg-zinc-900 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)]">
<h1 className="mb-5 font-semibold text-base text-zinc-900 tracking-tight dark:text-zinc-100">
Getting started
</h1>
<p className="mb-4 max-w-2xl text-sm text-zinc-700 leading-[1.7] dark:text-zinc-300">
A chat dock is something you work <em>behind</em>: non-modal throughout, with no backdrop,
no scroll lock, no focus trap, and no dismissal on outside press. Open a chat below, then
keep reading — the page stays yours.
</p>
<p className="max-w-2xl text-sm text-zinc-700 leading-[1.7] dark:text-zinc-300">
The Agent button is a trigger written outside the list. Its value is never in the
collection, so it anchors a draft to itself without creating a tab — send something and a
tab appears, titled by what you typed.
</p>
</article>
{/* A row in the layout rather than a fixed overlay, so it sits beside the
content instead of on top of it. */}
<div className="flex shrink-0 items-center justify-end gap-0.5 overflow-x-auto p-2">
<Tabs.Root
store={dockStore}
defaultItems={Object.keys(SEEDED)}
className="flex items-center"
>
<Tabs.List aria-label="Chats" className="flex items-center gap-0.5">
{(id) => (
<Tabs.Trigger value={id} aria-label={title(id)} className={dockTabClass}>
<Tabs.Icon className="text-zinc-500 dark:text-zinc-400 [&>svg]:size-[15px] [&>svg]:shrink-0">
<MessageSquare className="size-[15px]" />
</Tabs.Icon>
<span className="min-w-0 truncate">{title(id)}</span>
<Tabs.Action
className={[
// Inset 1px with a matching corner, so the cover never paints over the
// tab's ring and top highlight; it inherits the face's gradient too,
// not just its colour, so it doesn't read as a flat block.
"absolute inset-y-px right-px flex items-center rounded-r-[5px] bg-inherit [background-image:inherit] pr-1 pl-3",
"[mask-image:linear-gradient(to_right,transparent,#000_0.5rem)]",
"opacity-0 transition-opacity group-hover/tab:opacity-100 group-data-[selected]/tab:opacity-100",
].join(" ")}
>
<Tabs.Close
aria-label={`Close ${title(id)}`}
className="grid size-5 shrink-0 cursor-pointer select-none place-items-center rounded-full text-zinc-400 transition-colors hover:bg-zinc-950/5 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0169cc]/60 dark:text-zinc-500 dark:hover:bg-white/8 dark:hover:text-zinc-100"
>
<X className="size-3.5" />
</Tabs.Close>
</Tabs.Action>
</Tabs.Trigger>
)}
</Tabs.List>
{/* Outside the list: it takes an explicit value and keeps its own tab
stop rather than joining the roving focus — but it carries the same
disclosure ARIA a tab does. */}
<Tabs.Trigger value={DRAFT} className={`${dockTabClass} ml-1 max-w-none`}>
<Tabs.Icon className="text-zinc-500 dark:text-zinc-400 [&>svg]:size-[15px] [&>svg]:shrink-0">
<Sparkles className="size-[15px]" />
</Tabs.Icon>
Agent
</Tabs.Trigger>
{frame && (
<Tabs.Portal container={frame}>
<Tabs.Positioner
side="top"
align="end"
sideOffset={6}
className="z-40 transition-[top,left] duration-200 ease-out motion-reduce:transition-none"
>
{/* Sized against the room the positioner measured, rather than
guessing and overflowing the frame. */}
<Tabs.Popup
className={[
"flex h-[min(30rem,var(--anchor-available-height,30rem))] w-[min(24rem,var(--anchor-available-width,24rem))] flex-col overflow-hidden",
// The ring is baked into the shadow — no border on top.
"rounded-xl bg-white p-1 shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_12px_32px_-8px_rgb(0_0_0/0.16)]",
// A chat window, so it takes the panel colour; its bubble and
// composer lift off it, as in a full-size chat.
"dark:bg-zinc-900 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.06),inset_0_0_0_1px_rgb(255_255_255/0.07),0_0_0_1px_rgb(0_0_0/0.16),0_12px_32px_-8px_rgb(0_0_0/0.4)]",
// Anchored top/end, so it grows from its bottom-right corner — the tab.
"origin-bottom-right transition-[opacity,scale,translate] duration-150 ease-out",
"data-[starting-style]:translate-y-1 data-[ending-style]:translate-y-1",
"data-[starting-style]:scale-[0.98] data-[ending-style]:scale-[0.98]",
"data-[starting-style]:opacity-0 data-[ending-style]:opacity-0",
"motion-reduce:transition-none",
].join(" ")}
>
<DockHeader title={title} />
<Tabs.Viewport className="relative min-h-0 flex-1">
{(id) =>
id === DRAFT ? (
<NewChat onStart={start} />
) : (
// Keyed so a different chat gets a fresh scroll position
// and an empty composer, rather than inheriting the last one's.
<ChatThread
key={id}
chat={chats[id]}
onSend={(text) => reply(id, text)}
generating={generating.has(id)}
onStop={() => stop(id)}
/>
)
}
</Tabs.Viewport>
</Tabs.Popup>
</Tabs.Positioner>
</Tabs.Portal>
)}
</Tabs.Root>
</div>
</div>
);
};
/**
* One header for the collection, not one per panel — including over the draft,
* which otherwise has no way to dismiss itself short of hitting Agent again.
*/
const DockHeader = ({ title }: { title: (id: string) => string }) => {
const open = useTabs((tabs) => tabs.value);
const select = useTabs((tabs) => tabs.select);
const close = useTabs((tabs) => tabs.close);
const isDraft = open === DRAFT;
return (
<header className="flex h-10 shrink-0 items-center gap-0.5 pr-1 pl-2.5">
<span className="min-w-0 flex-1 truncate font-medium text-[13px] text-zinc-900 dark:text-zinc-100">
{open === null || isDraft ? null : title(open)}
</span>
<button
type="button"
aria-label="Minimise"
onClick={() => select(null)}
className={iconButtonClass}
>
<Minus className="size-[15px]" />
</button>
<button
type="button"
aria-label={isDraft ? "Discard draft" : "Close chat"}
onClick={() => {
// A draft is not in `items`, so there is nothing to close —
// deselecting is what discards it.
if (open === null || isDraft) return select(null);
close(open);
}}
className={iconButtonClass}
>
<X className="size-[15px]" />
</button>
</header>
);
};
const dockTabClass = [
"group/tab relative flex h-[30px] max-w-40 shrink-0 cursor-pointer select-none items-center gap-1.5 overflow-hidden",
"rounded-md px-2.5 font-medium text-[13px] text-zinc-700 transition-colors dark:text-zinc-300",
// Opaque rather than a translucent wash: the action inherits this colour, and
// a wash painted twice would show as a darker band behind the ×.
"hover:bg-[#e9e9ea] dark:hover:bg-[#262628]",
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0169cc]/60",
"data-[selected]:bg-white data-[selected]:bg-linear-to-b data-[selected]:from-white data-[selected]:to-[#fdfdfd] data-[selected]:text-zinc-900",
"data-[selected]:shadow-[inset_0_1px_0_#fff,0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.07),0_2px_6px_-2px_rgb(0_0_0/0.05)]",
"dark:data-[selected]:bg-[#2d2d30] dark:data-[selected]:from-[#313134] dark:data-[selected]:to-[#2a2a2d] dark:data-[selected]:text-zinc-100",
"dark:data-[selected]:shadow-[0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)]",
// The inset ring and top highlight go on an overlay above the content, so the
// close button's fading cover can't paint over them.
"dark:data-[selected]:after:pointer-events-none dark:data-[selected]:after:absolute dark:data-[selected]:after:inset-0 dark:data-[selected]:after:rounded-[inherit] dark:data-[selected]:after:shadow-[inset_0_1px_0_rgb(255_255_255/0.1),inset_0_0_0_1px_rgb(255_255_255/0.05)]",
].join(" ");
const iconButtonClass =
"grid size-7 shrink-0 cursor-pointer select-none place-items-center rounded-full text-zinc-400 transition-colors hover:bg-zinc-950/5 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0169cc]/60 dark:text-zinc-500 dark:hover:bg-white/8 dark:hover:text-zinc-100";The panel is a real chat, built from this package's own parts — a Thread of Messages with a docked Composer. The dock owns which conversation is showing; everything inside the panel is the chat primitives' business.
<Tabs.Root defaultItems={chats} selectOnClose="recent">
<Tabs.List>{/* … */}</Tabs.List>
<Tabs.Portal>
<Tabs.Positioner side="top" align="end" sideOffset={8}>
<Tabs.Popup>
<Tabs.Viewport>{(id) => <Chat id={id} />}</Tabs.Viewport>
</Tabs.Popup>
</Tabs.Positioner>
</Tabs.Portal>
</Tabs.Root>Note the Agent button in the demo: a Tabs.Trigger written outside the list,
whose value is never in items. It anchors a panel to itself without creating
a tab — a draft, in other words — and it keeps its own tab stop rather than
joining the roving focus. Selecting something else is all it takes to discard
it, because there was never a tab to clean up.
One positioner serves the whole collection, anchored to whichever tab is open.
A positioner per tab would mean an autoUpdate loop each — a ResizeObserver
and an IntersectionObserver apiece, still running while closed — and nothing to
morph between when the selection moves.
The surface is non-modal throughout: no backdrop, no scroll lock, no focus trap, and no dismissal on outside press. A chat dock is something you work behind.
Escape closes it, but only an Escape nothing else has used. The listener sits
on the window, so it hears the key last, and it skips any event that is already
defaultPrevented — the contract every Escape handler inside the panel relies
on. A composer stopping a reply, a command list closing, a menu anywhere on the
page: each claims its press with preventDefault(), and the panel stays. Send
a message in the demo and press Escape twice — the first press stops the reply,
the second closes the dock.
Positioner owns the movement and Popup owns the appearance, so the element
being animated is never the element being moved. Until the first placement
lands the surface withholds paint rather than showing at 0,0 — otherwise the
first real position would arrive as auto → a length, which CSS cannot
interpolate, and the surface would slide in from the corner.
Opening a tab on hover
Use the openOnHover prop to open a tab once the mouse rests on it. Leaving
closes it again unless the mouse heads into the popup, and a press on the tab or
inside the popup keeps it. Here the page is the demo's own state, so presses go
to the page and only hover floats a conversation.
Launch brief
Ship the beta to the waitlist on the 14th.
Scope
Invite-only, 500 seats
Pricing stays as drafted
Feedback through the in-app widget
Owners
Design owns the launch page; engineering owns the waitlist email.
"use client";
import { Tabs } from "@intentface/chat/tabs";
import { useState } from "react";
import "./hover-motion.css";
import { BubbleIcon, FileIcon } from "./icons";
import { Body, Preview, TABS } from "./pages";
/*
* A page strip where any tab can be glanced at without leaving the page.
*
* The page is this component's own state, not the tabs' selection: pressing a
* tab opens it in the card below. The tabs' selection is only what floats.
* Hovering a tab floats a preview over the page, a document as a mini page and
* a conversation as a small chat; press one and it becomes the page instead.
*
* `onValueChange` tells the two apart by `eventDetails.reason`. Click into the
* reply field and the float stays, however far the mouse wanders; Escape or
* pressing a tab ends it.
*/
export const Hover = () => {
const [page, setPage] = useState("brief");
const [floating, setFloating] = useState<string | null>(null);
// Replies typed into a floating conversation, kept per tab for this visit.
const [replies, setReplies] = useState<Record<string, string[]>>({});
return (
<div className="relative flex h-96 w-full flex-col overflow-hidden rounded-xl bg-[#f5f5f6] shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_4px_8px_-2px_rgb(0_0_0/0.05)] dark:bg-[#131315] dark:shadow-[0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:after:pointer-events-none dark:after:absolute dark:after:inset-0 dark:after:z-50 dark:after:rounded-[inherit] dark:after:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06)]">
<Tabs.Root
defaultItems={Object.keys(TABS)}
value={floating}
onValueChange={(value, eventDetails) => {
// A press opens the page in the layout and ends any float.
if (eventDetails.reason === "trigger-press" && value !== null) {
setPage(value);
setFloating(null);
return;
}
// Floating the page you are already on would show it twice, so nothing floats.
setFloating(value === page ? null : value);
}}
className="flex min-h-0 flex-1 flex-col"
>
<Tabs.List aria-label="Open tabs" className="flex shrink-0 items-center gap-1 p-2">
{(id) => {
const tab = TABS[id];
return (
<Tabs.Trigger
value={id}
openOnHover
aria-current={id === page ? "page" : undefined}
className={tabClass}
>
<Tabs.Icon className="text-zinc-500 dark:text-zinc-400 [&>svg]:size-[15px] [&>svg]:shrink-0">
{tab?.kind === "conversation" ? <BubbleIcon /> : <FileIcon />}
</Tabs.Icon>
<span className="min-w-0 truncate">{tab?.name ?? id}</span>
</Tabs.Trigger>
);
}}
</Tabs.List>
<div className="mx-2 mb-2 min-h-0 flex-1 overflow-auto rounded-lg bg-white shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_4px_8px_-2px_rgb(0_0_0/0.05)] dark:bg-zinc-900 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)]">
<Body tab={TABS[page]} replies={replies[page]} />
</div>
<Tabs.Portal>
<Tabs.Positioner side="bottom" align="start" sideOffset={6}>
<Tabs.Popup className="hover-demo-popup w-80 overflow-hidden rounded-xl bg-white p-1 shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_12px_32px_-8px_rgb(0_0_0/0.16)] dark:bg-zinc-900 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.06),inset_0_0_0_1px_rgb(255_255_255/0.07),0_0_0_1px_rgb(0_0_0/0.16),0_12px_32px_-8px_rgb(0_0_0/0.4)]">
<Tabs.Viewport>
{(id) => (
<Preview
tab={TABS[id]}
replies={replies[id]}
// Opening is the same as pressing the tab: it becomes the page.
onOpen={() => {
setPage(id);
setFloating(null);
}}
onReply={(text) =>
setReplies((current) => ({
...current,
[id]: [...(current[id] ?? []), text],
}))
}
/>
)}
</Tabs.Viewport>
</Tabs.Popup>
</Tabs.Positioner>
</Tabs.Portal>
</Tabs.Root>
</div>
);
};
const tabClass = [
"relative flex h-[30px] max-w-48 shrink-0 cursor-pointer select-none items-center gap-1.5 overflow-hidden",
"rounded-md px-2.5 font-medium text-[13px] text-zinc-700 transition-[background-color,color] duration-200 dark:text-zinc-300",
"hover:bg-zinc-950/5 dark:hover:bg-white/8",
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0169cc]/60",
// The page you are on, which this demo marks itself with aria-current.
"aria-[current=page]:bg-white aria-[current=page]:bg-linear-to-b aria-[current=page]:from-white aria-[current=page]:to-[#fdfdfd] aria-[current=page]:text-zinc-900",
"aria-[current=page]:shadow-[inset_0_1px_0_#fff,0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.07),0_2px_6px_-2px_rgb(0_0_0/0.05)]",
"dark:aria-[current=page]:bg-[#2d2d30] dark:aria-[current=page]:from-[#313134] dark:aria-[current=page]:to-[#2a2a2d] dark:aria-[current=page]:text-zinc-100",
"dark:aria-[current=page]:shadow-[inset_0_1px_0_rgb(255_255_255/0.1),inset_0_0_0_1px_rgb(255_255_255/0.05),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)]",
// The floating tab is the tabs' selection, and keeps its hover colour while you are over the surface.
"data-[selected]:bg-zinc-950/5 data-[selected]:text-zinc-900 dark:data-[selected]:bg-white/8 dark:data-[selected]:text-zinc-100",
].join(" ");Choosing where the selection lands
Where the selection goes when the open tab is closed is a behaviour, and the
primitive will not invent one you did not ask for. selectOnClose is the only
difference between the three strips below.
| Value | Description |
|---|---|
unsetdefault | |
"adjacent" | |
"recent" |
selectOnClose unsetNothing is selected. Closing what was open shows an empty viewport.
selectOnClose="adjacent"Whatever slides into the vacated slot, or the last tab if the tail went. An editor's behaviour.
selectOnClose="recent"The tab you were in before this one, falling back to adjacent.
"use client";
import { Tabs } from "@intentface/chat/tabs";
import { Archive, Inbox, Pen, Send, X } from "@keyline-icons/react";
import { createElement } from "react";
/*
* The three close policies side by side. Close the open tab in each strip and
* watch where the selection lands.
*
* `selectOnClose` is the whole difference between them — every other prop is
* identical. Leaving it unset is not an oversight: a dock that shows nothing
* after you close the last panel is a legitimate resting state, and the
* primitive will not pick a successor you did not ask for.
*/
export const Closing = () => (
<div className="flex w-full flex-col gap-5">
<Strip
policy="unset"
caption="Nothing is selected. Closing what was open shows an empty viewport."
/>
<Strip
policy="adjacent"
caption="Whatever slides into the vacated slot, or the last tab if the tail went. An editor's behaviour."
/>
<Strip
policy="recent"
caption="The tab you were in before this one, falling back to adjacent."
/>
</div>
);
const TABS = ["Inbox", "Drafts", "Sent", "Archive"];
const Strip = ({
policy,
caption,
}: {
policy: "unset" | "adjacent" | "recent";
caption: string;
}) => (
<div className="flex flex-col gap-2">
<code className="font-mono text-xs text-zinc-900 dark:text-zinc-100">
{policy === "unset" ? "selectOnClose unset" : `selectOnClose="${policy}"`}
</code>
<Tabs.Root
defaultItems={TABS}
defaultValue="Drafts"
selectOnClose={policy === "unset" ? undefined : policy}
className="relative flex flex-col gap-2 rounded-xl bg-[#f5f5f6] p-2 shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_4px_8px_-2px_rgb(0_0_0/0.05)] dark:bg-[#131315] dark:shadow-[0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:after:pointer-events-none dark:after:absolute dark:after:inset-0 dark:after:z-50 dark:after:rounded-[inherit] dark:after:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06)]"
>
<Tabs.List aria-label={`Tabs, ${policy}`} className="flex shrink-0 items-center gap-1">
{(id) => (
<Tabs.Trigger value={id} aria-label={id} className={tabClass}>
<Tabs.Icon className="shrink-0 text-zinc-500 dark:text-zinc-400 [&>svg]:size-[15px]">
{createElement(TAB_ICONS[id] ?? Inbox)}
</Tabs.Icon>
<span className="min-w-0 truncate">{id}</span>
<Tabs.Action className="absolute inset-y-0 right-1.5 flex items-center opacity-0 transition-opacity group-hover/tab:opacity-100 group-data-[selected]/tab:opacity-100">
<Tabs.Close
aria-label={`Close ${id}`}
className="grid size-5 shrink-0 cursor-pointer place-items-center rounded-full text-zinc-400 transition-colors hover:bg-zinc-950/5 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0169cc]/60 dark:text-zinc-500 dark:hover:bg-white/8 dark:hover:text-zinc-100"
>
<X className="size-3.5" />
</Tabs.Close>
</Tabs.Action>
</Tabs.Trigger>
)}
</Tabs.List>
<Tabs.Viewport className="flex h-20 items-center justify-center rounded-lg bg-white px-4 text-[13px] shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_4px_8px_-2px_rgb(0_0_0/0.05)] dark:bg-zinc-900 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)]">
{(id) => <span className="text-zinc-500 dark:text-zinc-400">{id}</span>}
</Tabs.Viewport>
</Tabs.Root>
<p className="text-xs text-zinc-500 leading-5 dark:text-zinc-400">{caption}</p>
</div>
);
const tabClass = [
// No strip behind the tabs: they sit on the frame's ground, and the open one
// is raised to match the panel below, so the selection reads as continuous
// with its content rather than as a highlighted button.
"group/tab relative flex h-[30px] w-40 min-w-0 shrink cursor-pointer select-none items-center gap-2 overflow-hidden",
// pr-7 reserves the close button's slot permanently. Overlaying it would
// cover the label on any short title, and padding it in on hover would make
// every tab jump the moment you point at one.
"rounded-md pr-7 pl-2.5 font-medium text-[13px] text-zinc-700 transition-colors dark:text-zinc-300",
"hover:bg-zinc-950/5 dark:hover:bg-white/8",
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0169cc]/60",
"data-[selected]:bg-white data-[selected]:bg-linear-to-b data-[selected]:from-white data-[selected]:to-[#fdfdfd] data-[selected]:text-zinc-900",
"data-[selected]:shadow-[inset_0_1px_0_#fff,0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.07),0_2px_6px_-2px_rgb(0_0_0/0.05)]",
"dark:data-[selected]:bg-[#2d2d30] dark:data-[selected]:from-[#313134] dark:data-[selected]:to-[#2a2a2d] dark:data-[selected]:text-zinc-100",
"dark:data-[selected]:shadow-[inset_0_1px_0_rgb(255_255_255/0.1),inset_0_0_0_1px_rgb(255_255_255/0.05),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)]",
].join(" ");
/* Per-tab icons rather than one generic page glyph — a strip of identical
icons carries no information, and the whole point of a tab icon is telling
the tabs apart at a glance. */
const TAB_ICONS: Record<string, typeof Inbox> = {
Inbox: Inbox,
Drafts: Pen,
Sent: Send,
Archive: Archive,
};Confirming before a tab closes
Every change reaches onValueChange and onItemsChange with eventDetails,
whose reason says what caused it. Call eventDetails.cancel() to stop the
change landing, as this strip does for a tab with unsaved changes.
| Value | Description |
|---|---|
"trigger-press" | |
"trigger-hover" | |
"list-navigation" | |
"close-press" | |
"keyboard" | |
"escape-key" | |
"imperative-action" |
"use client";
import { Tabs } from "@intentface/chat/tabs";
import { type ComponentProps, useRef, useState } from "react";
import { type Asking, Prompt } from "./confirm-prompt";
/*
* Closing a tab with unsaved changes asks first.
*
* Every change arrives with `eventDetails`, and `eventDetails.cancel()` stops it
* landing. The × and Delete report "close-press" and "keyboard", so cancelling
* those keeps the tab while the prompt waits. Discard then calls `close()` from
* code, which reports "imperative-action" and goes through.
*
* The prompt is a small popover under the tab being closed, placed from that
* tab's own offset — no floating library needed for a demo this size.
*/
const DOCUMENTS = ["Brief", "Roadmap", "Notes"];
const UNSAVED = new Set(["Roadmap"]);
export const ConfirmClose = () => {
const [items, setItems] = useState(DOCUMENTS);
const [asking, setAsking] = useState<Asking | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
return (
<Tabs.Root
ref={rootRef}
items={items}
defaultValue="Roadmap"
selectOnClose="adjacent"
onItemsChange={(next, eventDetails) => {
const closing = items.find((id) => !next.includes(id));
const byUser = eventDetails.reason === "close-press" || eventDetails.reason === "keyboard";
if (closing && byUser && UNSAVED.has(closing)) {
eventDetails.cancel();
// Anchor under the tab: its offset within the (positioned) root.
const tab = rootRef.current?.querySelector<HTMLElement>(`[data-tab="${closing}"]`);
setAsking({
value: closing,
left: tab?.offsetLeft ?? 0,
top: (tab?.offsetTop ?? 0) + (tab?.offsetHeight ?? 0) + 6,
});
return;
}
setItems(next);
}}
className="relative flex w-full flex-col gap-2 rounded-xl bg-[#f5f5f6] p-2 shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_4px_8px_-2px_rgb(0_0_0/0.05)] dark:bg-[#131315] dark:shadow-[0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:after:pointer-events-none dark:after:absolute dark:after:inset-0 dark:after:z-50 dark:after:rounded-[inherit] dark:after:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06)]"
>
<Tabs.List aria-label="Documents" className="flex shrink-0 items-center gap-1">
{(id) => (
<Tabs.Trigger value={id} data-tab={id} className={tabClass}>
<span className="min-w-0 truncate">{id}</span>
{UNSAVED.has(id) ? (
<span
role="img"
aria-label="Unsaved changes"
className="size-1.5 shrink-0 rounded-full bg-zinc-400 dark:bg-zinc-500"
/>
) : null}
<Tabs.Action className="absolute inset-y-0 right-1.5 flex items-center">
<Tabs.Close
aria-label={`Close ${id}`}
className="grid size-5 shrink-0 cursor-pointer place-items-center rounded-full text-zinc-400 transition-colors hover:bg-zinc-950/5 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0169cc]/60 dark:text-zinc-500 dark:hover:bg-white/8 dark:hover:text-zinc-100"
>
<CloseIcon />
</Tabs.Close>
</Tabs.Action>
</Tabs.Trigger>
)}
</Tabs.List>
<Tabs.Viewport className="flex h-24 items-center justify-center rounded-lg bg-white px-4 text-[13px] shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_4px_8px_-2px_rgb(0_0_0/0.05)] dark:bg-zinc-900 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)]">
{(id) => <span className="text-zinc-500 dark:text-zinc-400">{id}</span>}
</Tabs.Viewport>
{asking ? <Prompt asking={asking} onAnswer={() => setAsking(null)} /> : null}
</Tabs.Root>
);
};
const tabClass = [
"relative flex h-[30px] w-40 min-w-0 shrink cursor-pointer select-none items-center gap-2 overflow-hidden",
// pr-7 reserves the close button's slot, so the label never runs under it.
"rounded-md pr-7 pl-2.5 font-medium text-[13px] text-zinc-700 transition-colors dark:text-zinc-300",
"hover:bg-zinc-950/5 dark:hover:bg-white/8",
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0169cc]/60",
"data-[selected]:bg-white data-[selected]:bg-linear-to-b data-[selected]:from-white data-[selected]:to-[#fdfdfd] data-[selected]:text-zinc-900",
"data-[selected]:shadow-[inset_0_1px_0_#fff,0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.07),0_2px_6px_-2px_rgb(0_0_0/0.05)]",
"dark:data-[selected]:bg-[#2d2d30] dark:data-[selected]:from-[#313134] dark:data-[selected]:to-[#2a2a2d] dark:data-[selected]:text-zinc-100",
"dark:data-[selected]:shadow-[inset_0_1px_0_rgb(255_255_255/0.1),inset_0_0_0_1px_rgb(255_255_255/0.05),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)]",
].join(" ");
const CloseIcon = (props: ComponentProps<"svg">) => (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
className="size-3"
aria-hidden="true"
{...props}
>
<path d="m4.5 4.5 7 7m0-7-7 7" />
</svg>
);Persisting the collection
The collection goes out through onItemsChange and the selection through
onValueChange, and both come back in as defaultItems and defaultValue.
<Tabs.Root
defaultItems={stored?.items ?? []}
defaultValue={stored?.value ?? null}
onItemsChange={(items) => save({ items })}
onValueChange={(value) => save({ value })}
>A restored selection naming a tab that is no longer in items is dropped
rather than trusted, so a stale value cannot open a panel for something that
does not exist. See Shell for
why the value has to arrive as a prop rather than be read at init.
Opening a tab from elsewhere
useTabsStore(store, selector) is the outside-the-tree twin of useTabs,
taking an explicit Tabs.createStore() handle — which is how a "new chat"
button somewhere else in the app opens a tab. There is no global fallback.
open adds a tab and selects it, or moves and selects one already present, so
the caller never has to check first.
"use client";
import { Tabs, type TabsStore, useTabsStore } from "@intentface/chat/tabs";
import { Archive, Inbox, Pen, Send, X } from "@keyline-icons/react";
import { createElement, useState } from "react";
/*
* Opening a tab from somewhere else in the app.
*
* `Tabs.createStore()` is the handle. Pass it to the Root and the buttons
* below — siblings of the Root, not descendants — can call the same actions
* the strip calls. This is how a "new chat" button in the window chrome opens
* a tab in a dock it has no path to through context.
*
* `open` adds a tab and selects it, or moves and selects one already present,
* so the button is idempotent without the caller checking first.
*/
export const ExternalTabs = () => {
const [store] = useState(() => Tabs.createStore());
return (
<div className="flex w-full flex-col gap-3">
<Tabs.Root
store={store}
defaultItems={["Inbox"]}
defaultValue="Inbox"
selectOnClose="adjacent"
className="relative flex flex-col gap-2 rounded-xl bg-[#f5f5f6] p-2 shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_4px_8px_-2px_rgb(0_0_0/0.05)] dark:bg-[#131315] dark:shadow-[0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:after:pointer-events-none dark:after:absolute dark:after:inset-0 dark:after:z-50 dark:after:rounded-[inherit] dark:after:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06)]"
>
<Tabs.List aria-label="Documents" className="flex shrink-0 flex-wrap items-center gap-1">
{(id) => (
<Tabs.Trigger value={id} aria-label={id} className={tabClass}>
<Tabs.Icon className="shrink-0 text-zinc-500 dark:text-zinc-400 [&>svg]:size-[15px]">
{createElement(TAB_ICONS[id] ?? Inbox)}
</Tabs.Icon>
<span className="min-w-0 truncate">{id}</span>
<Tabs.Action className="absolute inset-y-0 right-1.5 flex items-center opacity-0 transition-opacity group-hover/tab:opacity-100 group-data-[selected]/tab:opacity-100">
<Tabs.Close
aria-label={`Close ${id}`}
className="grid size-5 shrink-0 cursor-pointer place-items-center rounded-full text-zinc-400 transition-colors hover:bg-zinc-950/5 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0169cc]/60 dark:text-zinc-500 dark:hover:bg-white/8 dark:hover:text-zinc-100"
>
<X className="size-3.5" />
</Tabs.Close>
</Tabs.Action>
</Tabs.Trigger>
)}
</Tabs.List>
<Tabs.Viewport className="flex h-24 items-center justify-center rounded-lg bg-white px-4 text-[13px] text-zinc-500 shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_4px_8px_-2px_rgb(0_0_0/0.05)] dark:bg-zinc-900 dark:text-zinc-400 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)]">
{(id) => <span>{id}</span>}
</Tabs.Viewport>
</Tabs.Root>
{/* Outside Tabs.Root entirely. */}
<Launcher store={store} />
</div>
);
};
const DOCUMENTS = ["Drafts", "Sent", "Archive"];
const Launcher = ({ store }: { store: TabsStore }) => {
const items = useTabsStore(store, (tabs) => tabs.items);
return (
<div className="flex flex-wrap items-center justify-center gap-2">
{DOCUMENTS.map((id) => (
<button
key={id}
type="button"
onClick={() => store.getSnapshot().open(id)}
className="h-8 cursor-pointer rounded-full bg-white bg-linear-to-b from-white to-[#fdfdfd] px-4 font-medium text-[13px] text-zinc-900 shadow-[inset_0_1px_0_#fff,0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.07),0_2px_6px_-2px_rgb(0_0_0/0.05)] transition-colors hover:from-[#fafafa] hover:to-[#f6f6f6] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0169cc]/60 dark:bg-[#2d2d30] dark:from-[#313134] dark:to-[#2a2a2d] dark:text-zinc-100 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.1),inset_0_0_0_1px_rgb(255_255_255/0.05),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:hover:from-[#38383b] dark:hover:to-[#313134]"
>
{items.includes(id) ? `Go to ${id}` : `Open ${id}`}
</button>
))}
</div>
);
};
const tabClass = [
// No strip behind the tabs: they sit on the frame's ground, and the open one
// is raised to match the panel below, so the selection reads as continuous
// with its content rather than as a highlighted button.
"group/tab relative flex h-[30px] w-40 min-w-0 shrink cursor-pointer select-none items-center gap-2 overflow-hidden",
// pr-7 reserves the close button's slot permanently. Overlaying it would
// cover the label on any short title, and padding it in on hover would make
// every tab jump the moment you point at one.
"rounded-md pr-7 pl-2.5 font-medium text-[13px] text-zinc-700 transition-colors dark:text-zinc-300",
"hover:bg-zinc-950/5 dark:hover:bg-white/8",
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0169cc]/60",
"data-[selected]:bg-white data-[selected]:bg-linear-to-b data-[selected]:from-white data-[selected]:to-[#fdfdfd] data-[selected]:text-zinc-900",
"data-[selected]:shadow-[inset_0_1px_0_#fff,0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.07),0_2px_6px_-2px_rgb(0_0_0/0.05)]",
"dark:data-[selected]:bg-[#2d2d30] dark:data-[selected]:from-[#313134] dark:data-[selected]:to-[#2a2a2d] dark:data-[selected]:text-zinc-100",
"dark:data-[selected]:shadow-[inset_0_1px_0_rgb(255_255_255/0.1),inset_0_0_0_1px_rgb(255_255_255/0.05),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)]",
].join(" ");
/* Per-tab icons rather than one generic page glyph — a strip of identical
icons carries no information, and the whole point of a tab icon is telling
the tabs apart at a glance. */
const TAB_ICONS: Record<string, typeof Inbox> = {
Inbox: Inbox,
Drafts: Pen,
Sent: Send,
Archive: Archive,
};Why a toolbar and not a tablist
ARIA's tablist cannot describe this widget, for two independent reasons. A
tablist must own only tabs, so there is nowhere to put a close button; and it
requires exactly one selected tab, so value: null — a dock with everything
closed — is not a state it can express. Both are real, and axe fails the first
outright.
So the strip is a toolbar of buttons, each a disclosure for its panel:
aria-expanded says whether its panel is showing and aria-controls names it.
Close buttons are real buttons rather than pointer-only affordances, arrow-key
roving focus is exactly what a toolbar is expected to do, and nothing open is
an ordinary state.
Static, always-one-selected tabs are a different widget, and tablist is the
right role for those.
Keyboard
| Key | Description |
|---|---|
| Arrow keys | |
| Enter / Space | |
| Delete / Backspace | |
| Escape |
Focus follows the arrows but does not select, which is what manual activation
means; set activateOnFocus to select as focus moves. A text field inside the
strip keeps the arrow key whenever the caret still has somewhere to travel.
API reference
Every part accepts className, style, and render (see
Styling) and emits a bespoke part attribute
(data-<part>) unless noted. Every part in the collection also carries
data-orientation, data-disabled when disabled, data-empty when nothing is
open, and data-activation-direction.
Tabs.Root
The provider and container. Renders data-tabs.
| Prop | Type | Default | Details |
|---|---|---|---|
defaultItems | string[] | — | |
items | string[] | — | |
onItemsChange | (items: string[], eventDetails: TabsRootChangeEventDetails) => void | — | |
defaultValue | string | null | — | |
value | string | null | — | |
onValueChange | (value: string | null, eventDetails: TabsRootChangeEventDetails) => void | — | |
selectOnClose | "adjacent" | "recent" | — | |
orientation | "horizontal" | "vertical" | "horizontal" | |
loop | boolean | true | |
activateOnFocus | boolean | false | |
disabled | boolean | false | |
dismissOnEscape | boolean | true | |
store | TabsStore | — |
| Attribute | Description |
|---|---|
data-tabs | |
data-orientation | |
data-empty | |
data-disabled | |
data-activation-direction |
Tabs.List
The strip. Renders data-tabs-list with role="toolbar" and owns the roving
focus.
| Prop | Type | Default | Details |
|---|---|---|---|
children | ReactNode | ((value: string, index: number) => ReactNode) | — |
| Attribute | Description |
|---|---|
data-tabs-list | |
data-orientation | |
data-empty | |
data-disabled | |
data-activation-direction |
Tabs.Trigger
One tab. Renders data-tabs-trigger, with aria-expanded and an
aria-controls that only claims a viewport really in the document.
One part, two situations, decided by where it sits rather than by a prop:
inside a Tabs.List it joins the collection and the roving focus, and Delete
closes it. Outside one it takes an explicit value, keeps its own tab stop, and
toggles — a panel with no tab behind it, anchored to the button that owns it.
With openOnHover, leaving closes the tab again unless the mouse heads into the
popup. A press on the tab, or a press or focus inside the popup, keeps it.
| Prop | Type | Default | Details |
|---|---|---|---|
value | string | — | |
disabled | boolean | — | |
openOnHover | boolean | false | |
openDelay | number | 50 | |
closeDelay | number | 50 |
| Attribute | Description |
|---|---|
data-tabs-trigger | |
data-selected | |
data-disabled | |
data-orientation |
Tabs.Icon
Decoration inside a tab. Renders a <span> with data-tabs-icon and
aria-hidden — the trigger already has an accessible name, and an icon that
repeats it only makes the announcement longer. It carries the tab's state, so
the mark can respond to its tab being open without a group selector.
| Prop | Type | Default | Details |
|---|---|---|---|
value | string | — |
| Attribute | Description |
|---|---|
data-tabs-icon | |
data-selected | |
data-disabled |
Tabs.Action
The trailing slot inside a tab — where the close button lives. Renders
data-tabs-action, carrying the tab's state.
| Prop | Type | Default | Details |
|---|---|---|---|
value | string | — |
| Attribute | Description |
|---|---|
data-tabs-action | |
data-selected | |
data-disabled |
It wants to be positioned rather than in flow — a tab narrow enough to truncate has nowhere to put a control, so the label needs to run under the button and fade out:
[data-tabs-trigger] {
position: relative;
overflow: hidden;
}
[data-tabs-action] {
position: absolute;
inset-block: 0;
right: 0;
display: flex;
align-items: center;
/* The padding starts the button past the end of the gradient. */
padding-inline: 0.75rem 0.375rem;
background-color: inherit;
mask-image: linear-gradient(to right, transparent, #000 0.5rem);
}background-color: inherit takes the tab's own colour, whatever state it is
in, and the mask fades that background in from the left — so the label slides
under it rather than stopping at a hard edge. Laying it out in flow instead
gives you a button that collides with the label on exactly the tabs where it
matters.
Tabs.Close
The × inside a tab. Renders data-tabs-close as a role="button" with
tabIndex="-1", for the same reason its parent is a div. It keeps out of
the roving order — arrowing along a strip should walk tabs, not alternate
between each tab and its close button — so the keyboard route to closing is
Delete on the tab itself. Every event it handles stops there: nested inside
the trigger, anything that escaped would open the tab on its way out of closing
it.
| Prop | Type | Default | Details |
|---|---|---|---|
value | string | — | |
disabled | boolean | — |
| Attribute | Description |
|---|---|
data-tabs-close | |
data-selected | |
data-disabled |
Tabs.Viewport
The one box that shows a tab's content. Renders data-tabs-viewport as a
role="group" named by whichever tab is open, through aria-labelledby. The
role is what makes the name stick: a bare div maps to generic, whose name
assistive technology discards.
Nothing off-screen exists, and that is deliberate: a tab you are not looking at has no component, so anything that must keep running while you are elsewhere — a reply still streaming — belongs in a store rather than in the panel's state.
| Prop | Type | Default | Details |
|---|---|---|---|
children | (value: string) => ReactNode | — |
| Attribute | Description |
|---|---|
data-tabs-viewport | |
data-empty | |
data-orientation | |
data-activation-direction |
The open tab's id is deliberately not published as an attribute: it is state the viewport needs, but not a styling hook.
Tabs.Portal
Owns the mounting of the floating surface. Renders no element of its own — so
it has no attributes to publish — and keeps its children in the DOM through the
exit animation, which Tabs.Popup reports as finished.
| Prop | Type | Default | Details |
|---|---|---|---|
container | HTMLElement | null | document.body | |
keepMounted | boolean | false |
Tabs.Positioner
Owns the placement. Renders data-tabs-positioner as role="presentation",
anchored to whichever tab is open.
| Prop | Type | Default | Details |
|---|---|---|---|
side | "top" | "bottom" | "left" | "right" | "top" | |
align | "start" | "center" | "end" | "center" | |
sideOffset | number | 8 | |
collisionPadding | number | 8 |
Placement lands on the positioner, which is also where the measured geometry is
published — position, left and top are written imperatively, so do not
set them from a stylesheet on this part.
| Attribute | Description |
|---|---|
data-tabs-positioner | |
data-open | |
data-closed | |
data-side | |
data-align |
| CSS variable | Description |
|---|---|
--anchor-width | |
--anchor-height | |
--anchor-available-width | |
--anchor-available-height |
Tabs.Popup
The surface itself: the part to style and animate. Renders data-tabs-popup,
and it is what reports the exit animation as finished so the portal knows when
to unmount.
| Attribute | Description |
|---|---|
data-tabs-popup | |
data-open | |
data-closed | |
data-starting-style | |
data-ending-style |
useTabs
Read the collection from anywhere inside <Tabs.Root>:
const openTab = useTabs((tabs) => tabs.value);| Prop | Type | Default | Details |
|---|---|---|---|
value | string | null | — | |
items | string[] | — | |
recent | string[] | — | |
direction | "left" | "right" | "up" | "down" | "none" | — | |
open | (value, options?) => void | — | |
close | (value: string) => void | — | |
select | (value: string | null) => void | — | |
selectRelative | (direction, options?) => void | — | |
move | (value: string, toIndex: number) => void | — | |
setItems | (items: string[]) => void | — |
useTabsStore(store, selector) is the outside-the-tree twin, taking an
explicit Tabs.createStore() handle — which is how a "new chat" button
somewhere else in the app opens a tab. There is no global fallback.