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 open flag. value is a tab id or null, and null means nothing is showing. A page-tab strip never reaches null; a chat dock does, every time you close the last one.
  • Order is data — items is an ordered array on the store, not something derived from the DOM. Drag it with whatever library you like through render; 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 › Popup is 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

tsx
<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:

tsx
<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.

tsx
<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.

ValueDescription
unsetdefault
Nowhere: closing what was open shows nothing.
"adjacent"
Whatever slides into the vacated slot, or the last tab if the tail went — an editor's behaviour.
"recent"
The tab you were in before this one, falling back to adjacent.
selectOnClose unset
Drafts

Nothing is selected. Closing what was open shows an empty viewport.

selectOnClose="adjacent"
Drafts

Whatever slides into the vacated slot, or the last tab if the tail went. An editor's behaviour.

selectOnClose="recent"
Drafts

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.

ValueDescription
"trigger-press"
A click, Enter or Space on a tab.
"trigger-hover"
The mouse resting on, or leaving, an openOnHover tab.
"list-navigation"
Arrowing onto a tab with activateOnFocus.
"close-press"
A press on Tabs.Close.
"keyboard"
Delete or Backspace on a focused tab.
"escape-key"
Escape closing the open panel.
"imperative-action"
A store action called from your own code.
Roadmap
"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.

tsx
<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.

Inbox
"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

KeyDescription
Arrow keys
Move focus along the strip, following its orientation.
Enter / Space
Open the focused tab.
Delete / Backspace
Close the focused tab — only inside a Tabs.List, and the keyboard equivalent of the × beside it.
Escape
Close the open panel. The one key bound on the window rather than the strip, since the panel may be portaled away from it — turn it off with dismissOnEscape. From inside the panel, focus returns to its tab.

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.

PropTypeDefaultDetails
defaultItemsstring[]—
itemsstring[]—
onItemsChange(items: string[], eventDetails: TabsRootChangeEventDetails) => void—
defaultValuestring | null—
valuestring | null—
onValueChange(value: string | null, eventDetails: TabsRootChangeEventDetails) => void—
selectOnClose"adjacent" | "recent"—
orientation"horizontal" | "vertical""horizontal"
loopbooleantrue
activateOnFocusbooleanfalse
disabledbooleanfalse
dismissOnEscapebooleantrue
storeTabsStore—
AttributeDescription
data-tabs
The container.
data-orientation
Which arrow keys walk the strip."horizontal" | "vertical"
data-empty
Present while nothing is open. A page-tab strip never sees this; a dock does.
data-disabled
Present while the whole collection is disabled.
data-activation-direction
Which way the selection last moved, for panels that slide rather than fade. Absent when it has not moved."left" | "right" | "up" | "down"

Tabs.List

The strip. Renders data-tabs-list with role="toolbar" and owns the roving focus.

PropTypeDefaultDetails
childrenReactNode | ((value: string, index: number) => ReactNode)—
AttributeDescription
data-tabs-list
The strip, with aria-orientation matching the Root's.role="toolbar"
data-orientation
The strip's orientation."horizontal" | "vertical"
data-empty
Present while nothing is open.
data-disabled
Present while the collection is disabled.
data-activation-direction
Which way the selection last moved."left" | "right" | "up" | "down"

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.

PropTypeDefaultDetails
valuestring—
disabledboolean—
openOnHoverbooleanfalse
openDelaynumber50
closeDelaynumber50
AttributeDescription
data-tabs-trigger
The tab, and its identity — the strip's roving focus finds tabs by this attribute and reads the value back off it. Select it without the value for styling.the tab's value
data-selected
Present while this tab's panel is showing.
data-disabled
Present while disabled.
data-orientation
The strip's orientation."horizontal" | "vertical"

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.

PropTypeDefaultDetails
valuestring—
AttributeDescription
data-tabs-icon
The icon slot.
data-selected
Present while the tab's panel is showing.
data-disabled
Present while the tab is disabled.

Tabs.Action

The trailing slot inside a tab — where the close button lives. Renders data-tabs-action, carrying the tab's state.

PropTypeDefaultDetails
valuestring—
AttributeDescription
data-tabs-action
The slot.
data-selected
Present while the tab's panel is showing.
data-disabled
Present while the tab is 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:

css
[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.

PropTypeDefaultDetails
valuestring—
disabledboolean—
AttributeDescription
data-tabs-close
The close button.
data-selected
Present while the tab's panel is showing.
data-disabled
Present while 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.

PropTypeDefaultDetails
children(value: string) => ReactNode—
AttributeDescription
data-tabs-viewport
The content box. Carries an id, and `aria-labelledby` naming whichever tab is open.
data-empty
Present while nothing is open — the children function is not called, so the box is empty.
data-orientation
The collection's orientation."horizontal" | "vertical"
data-activation-direction
Which way the selection last moved, so the panel can slide the right way."left" | "right" | "up" | "down"

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.

PropTypeDefaultDetails
containerHTMLElement | nulldocument.body
keepMountedbooleanfalse

Tabs.Positioner

Owns the placement. Renders data-tabs-positioner as role="presentation", anchored to whichever tab is open.

PropTypeDefaultDetails
side"top" | "bottom" | "left" | "right""top"
align"start" | "center" | "end""center"
sideOffsetnumber8
collisionPaddingnumber8

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.

AttributeDescription
data-tabs-positioner
The positioned wrapper.role="presentation"
data-open
Present while a tab is open.
data-closed
Present while nothing is.
data-side
The resolved side, which flip may have changed — so a surface that flipped can style itself as where it ended up."top" | "bottom" | "left" | "right"
data-align
The resolved alignment."start" | "center" | "end"
CSS variableDescription
--anchor-width
The open tab's own width, e.g. so a surface can match it.measured px
--anchor-height
The open tab's own height.measured px
--anchor-available-width
Free space toward the placement side — cap a max-width instead of overflowing.measured px
--anchor-available-height
The same, vertically.measured px

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.

AttributeDescription
data-tabs-popup
The surface.
data-open
Present while a tab is open.
data-closed
Present while nothing is.
data-starting-style
Present on the first open frame.
data-ending-style
Present while the exit animation runs.

useTabs

Read the collection from anywhere inside <Tabs.Root>:

tsx
const openTab = useTabs((tabs) => tabs.value);
PropTypeDefaultDetails
valuestring | null—
itemsstring[]—
recentstring[]—
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.