---
title: Tabs
description: An open-ended, closable tab collection whose panel renders either in the layout or in a popover anchored to its own tab.
source: tabs
---

```tsx title="primitives/tabs/demos/basic.tsx"
"use client";

import { Tabs, useTabs } from "@intentface/chat/tabs";
import { IconFile, IconPlus, IconX } from "@tabler/icons-react";
import { Fragment, type ReactNode, useEffect, useRef, useState } from "react";

/*
 * 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.
 */

type Document = { name: string; sections: { heading: string; paragraphs: string[] }[] };

const LOREM = {
  short:
    "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
  medium:
    "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.",
  long: "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.",
};

// Different lengths on purpose: switching tabs has to visibly change the
// viewport, since that is the thing being demonstrated.
const SEEDED: Record<string, Document> = {
  "doc-1": {
    name: "Getting started",
    sections: [
      { heading: "Overview", paragraphs: [LOREM.short, LOREM.medium] },
      { heading: "Before you begin", paragraphs: [LOREM.long] },
    ],
  },
  "doc-2": {
    name: "Installation",
    sections: [
      { heading: "Package manager", paragraphs: [LOREM.medium] },
      { heading: "Peer dependencies", paragraphs: [LOREM.short, LOREM.long] },
    ],
  },
  "doc-3": {
    name: "Design tokens",
    sections: [
      { heading: "Surfaces", paragraphs: [LOREM.long, LOREM.short] },
      { heading: "State", paragraphs: [LOREM.medium] },
      { heading: "Typography", paragraphs: [LOREM.short] },
    ],
  },
  "doc-4": {
    name: "Accessibility",
    sections: [{ heading: "Roles", paragraphs: [LOREM.medium] }],
  },
};

export const Basic = () => {
  const [documents, setDocuments] = useState(SEEDED);

  return (
    <div className="tabs-demo flex h-[32rem] w-full flex-col overflow-hidden rounded-xl border border-[#f0f0f0] bg-[#fafafa] dark:border-[#262626] dark:bg-[#111111]">
      <ScrollMask />
      <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="[&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:opacity-60">
                    <IconFile className="size-4" />
                  </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={[
                      "absolute inset-y-0 right-0 flex items-center bg-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-md text-[#686868] transition-colors hover:bg-[#e4e4e4] hover:text-[#1a1a1a] dark:text-[#9b9b9b] dark:hover:bg-[#333333] dark:hover:text-[#fcfcfc]"
                    >
                      <IconX 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-md border border-[#f0f0f0] bg-white data-[empty]:invisible dark:border-[#262626] dark:bg-[#181818]">
          {(id) => <DocumentBody document={documents[id]} />}
        </Tabs.Viewport>
      </Tabs.Root>
    </div>
  );
};

/*
 * The scrolling half of the strip. Its edges fade only where tabs are hidden
 * behind them — see ScrollMask below — and the open tab is brought into view
 * whenever the selection moves, since a tab added at the end would otherwise
 * open off-screen.
 */
const TabStrip = ({ children }: { children: ReactNode }) => {
  const ref = useRef<HTMLDivElement | null>(null);
  const value = useTabs((tabs) => tabs.value);

  // biome-ignore lint/correctness/useExhaustiveDependencies: `value` is the trigger, not an input — the open tab is found off the DOM, and this has to re-run whenever the selection moves.
  useEffect(() => {
    ref.current
      ?.querySelector("[data-tabs-trigger][data-selected]")
      ?.scrollIntoView({ block: "nearest", inline: "nearest" });
  }, [value]);

  return (
    <div
      ref={ref}
      className="scroll-mask-x min-w-0 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
    >
      {children}
    </div>
  );
};

/*
 * Scroll-position-driven edge fades, with no JavaScript: a scroll timeline
 * animates two registered custom properties, and those feed the mask. At the
 * start the left mask is fully opaque; from 10% in, it fades. The right mask
 * does the reverse. Unsupported browsers get hard edges, which is fine.
 * After https://twilson.net/scroll-mask.
 */
const ScrollMask = () => (
  <style>{`
@property --tabs-demo-mask-l { syntax: "<length-percentage>"; inherits: false; initial-value: 100%; }
@property --tabs-demo-mask-r { syntax: "<length-percentage>"; inherits: false; initial-value: 100%; }

@keyframes tabs-demo-scroll-mask {
  0% { --tabs-demo-mask-l: 100%; }
  10%, 100% { --tabs-demo-mask-l: var(--tabs-demo-fade-from); }
  0%, 90% { --tabs-demo-mask-r: var(--tabs-demo-fade-from); }
  100% { --tabs-demo-mask-r: 100%; }
}

@supports (animation-timeline: scroll()) {
  .tabs-demo .scroll-mask-x {
    --tabs-demo-fade-from: calc(100% - 1.5rem);
    mask-image:
      linear-gradient(to left, black, black var(--tabs-demo-mask-l), transparent),
      linear-gradient(to right, black, black var(--tabs-demo-mask-r), transparent);
    mask-composite: intersect;
    -webkit-mask-composite: source-in;
    animation: tabs-demo-scroll-mask linear;
    animation-timeline: scroll(self inline);
  }
}
`}</style>
);

/** `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-md text-[#686868] transition-colors hover:bg-[#f4f4f4] hover:text-[#1a1a1a] focus-visible:-outline-offset-2 focus-visible:outline-2 focus-visible:outline-[#1a1a1a] dark:text-[#9b9b9b] dark:hover:bg-[#232323] dark:hover:text-[#fcfcfc] dark:focus-visible:outline-[#fcfcfc]"
    >
      <IconPlus className="size-4" />
    </button>
  );
};

// Left-aligned with a deep left pad, not centred — centring in a wide card
// pushes the prose into the middle and leaves it looking adrift.
const DocumentBody = ({ document }: { document: Document | undefined }) => {
  if (!document) return null;

  return (
    <article className="max-w-2xl px-10 py-8">
      <h1 className="mb-6 text-balance font-semibold text-[#1a1a1a] text-2xl leading-[1.2] tracking-tight dark:text-[#fcfcfc]">
        {document.name}
      </h1>
      {document.sections.map((section) => (
        <Fragment key={section.heading}>
          <h2 className="mt-7 mb-2 font-semibold text-[#1a1a1a] text-base tracking-tight dark:text-[#fcfcfc]">
            {section.heading}
          </h2>
          {section.paragraphs.map((paragraph) => (
            <p
              key={paragraph}
              className="mb-4 text-[#686868] text-sm leading-[1.7] dark:text-[#9b9b9b]"
            >
              {paragraph}
            </p>
          ))}
        </Fragment>
      ))}
    </article>
  );
};

/*
 * 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-7 max-w-56 shrink-0 cursor-pointer select-none items-center gap-1.5 overflow-hidden",
  "rounded-md px-2.5 text-[#686868] text-sm transition-[background-color,color] duration-200 dark:text-[#9b9b9b]",
  "hover:bg-[#f4f4f4] dark:hover:bg-[#232323]",
  "focus-visible:-outline-offset-2 focus-visible:outline-2 focus-visible:outline-[#1a1a1a] dark:focus-visible:outline-[#fcfcfc]",
  // A real border, transparent until selected. A shadow ring would sit outside
  // the box, and the scroller clips anything past its content height.
  "border border-transparent data-[selected]:border-[#f0f0f0] data-[selected]:bg-white data-[selected]:text-[#1a1a1a]",
  "dark:data-[selected]:border-[#262626] dark:data-[selected]:bg-[#181818] dark:data-[selected]:text-[#fcfcfc]",
].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](#why-a-toolbar-and-not-a-tablist). ARIA's tablist cannot describe a closable, open-ended strip.
- **Get started** — see [Quick start](/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:

```tsx title="primitives/tabs/demos/anchored.tsx"
"use client";

import { Composer, type ComposerSubmitData } from "@intentface/chat/composer";
import { Message } from "@intentface/chat/message";
import { Tabs, useTabs } from "@intentface/chat/tabs";
import { Thread } from "@intentface/chat/thread";
import { IconArrowUp, IconMessage, IconMinus, IconSparkles, IconX } from "@tabler/icons-react";
import { useState } from "react";

/*
 * 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.
 */
const dockStore = Tabs.createStore();

/** The draft's value. It is never in `items` — that is the whole point. */
const DRAFT = "new-chat";

type Turn = { id: string; role: "user" | "assistant"; text: string };
type Chat = { name: string; turns: Turn[] };

const REPLIES = [
  "Right — and the reason is that a function is compared by identity, so a fresh one each render reads as a change.",
  "In this case, nothing: the parent only re-renders when its own state moves, and none of it does here.",
  "It depends what is downstream of it. A memo-wrapped child cares; a plain one doesn't.",
];

// Different lengths on purpose: switching tabs has to visibly change the
// panel, since that is what the dock is here to demonstrate.
const SEEDED: Record<string, Chat> = {
  "chat-1": {
    name: "Notes",
    turns: [
      { id: "1", role: "user", text: "What's the difference between useMemo and useCallback?" },
      {
        id: "2",
        role: "assistant",
        text: "useMemo caches a computed value; useCallback caches a function reference. useCallback(fn, deps) is just useMemo(() => fn, deps).",
      },
      { id: "3", role: "user", text: "So when do I actually need useCallback?" },
      { id: "4", role: "assistant", text: REPLIES[2] as string },
    ],
  },
  "chat-2": {
    name: "Follow-up",
    turns: [
      { id: "1", role: "user", text: "Does the parent re-render when I pass a new callback?" },
      { id: "2", role: "assistant", text: REPLIES[1] as string },
    ],
  },
  "chat-3": {
    name: "Summary",
    turns: [
      { id: "1", role: "user", text: "Summarise the thread so far." },
      {
        id: "2",
        role: "assistant",
        text: "Cache values with useMemo, cache functions with useCallback, and reach for either only when something downstream is memoised.",
      },
      { id: "3", role: "user", text: "Why does identity matter for the function case?" },
      { id: "4", role: "assistant", text: REPLIES[0] as string },
      { id: "5", role: "user", text: "Got it." },
      { id: "6", role: "assistant", text: "That's the whole of it." },
    ],
  },
};

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

  const reply = (id: string, text: string) =>
    setChats((current) => {
      const chat = current[id];
      if (!chat) return current;
      const next = chat.turns.length;
      return {
        ...current,
        [id]: {
          ...chat,
          turns: [
            ...chat.turns,
            { id: `${next}-u`, role: "user", text },
            { id: `${next}-a`, role: "assistant", text: REPLIES[next % REPLIES.length] as string },
          ],
        },
      };
    });

  /** 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 border border-[#f0f0f0] bg-white dark:border-[#262626] dark:bg-[#181818]"
    >
      {/* The page the dock sits over. */}
      <article className="min-h-0 flex-1 overflow-hidden px-10 py-8">
        <h1 className="mb-6 font-semibold text-[#1a1a1a] text-2xl tracking-tight dark:text-[#fcfcfc]">
          Getting started
        </h1>
        <p className="mb-4 max-w-2xl text-[#686868] text-sm leading-[1.7] dark:text-[#9b9b9b]">
          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-[#686868] text-sm leading-[1.7] dark:text-[#9b9b9b]">
          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 px-2 pb-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="[&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:opacity-60">
                  <IconMessage className="size-4" />
                </Tabs.Icon>
                <span className="min-w-0 truncate">{title(id)}</span>
                <Tabs.Action
                  className={[
                    "absolute inset-y-0 right-0 flex items-center bg-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 text-[#686868] transition-colors hover:bg-[#e4e4e4] hover:text-[#1a1a1a] dark:text-[#9b9b9b] dark:hover:bg-[#333333] dark:hover:text-[#fcfcfc]"
                  >
                    <IconX 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="[&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:opacity-60">
              <IconSparkles className="size-4" />
            </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 smooth-shadow-ring-lg dark:bg-[#181818]",
                    // 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)} />
                      )
                    }
                  </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-1 px-2.5">
      <span className="min-w-0 flex-1 truncate font-medium text-[#1a1a1a] text-sm dark:text-[#fcfcfc]">
        {open === null || isDraft ? null : title(open)}
      </span>
      <button
        type="button"
        aria-label="Minimise"
        onClick={() => select(null)}
        className={iconButtonClass}
      >
        <IconMinus className="size-3.5" />
      </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}
      >
        <IconX className="size-3.5" />
      </button>
    </header>
  );
};

// Thread measures its docked composer and publishes the reserve as
// --thread-overlay-bottom-height, so the last message never hides behind it.
const ChatThread = ({
  chat,
  onSend,
}: {
  chat: Chat | undefined;
  onSend: (text: string) => void;
}) => {
  if (!chat) return null;

  // `bottom` rather than the default `follow`: it is the one mode that reserves
  // no viewport for the last turn. In a dock this small the reserve would push
  // every earlier turn out of sight, so each chat would look like one exchange.
  return (
    <Thread.Root
      autoScroll="bottom"
      className="relative flex h-full w-full overflow-hidden [--thread-overlay-top-height:0.75rem]"
    >
      <Thread.Viewport className="h-full w-full overflow-x-hidden overflow-y-auto outline-none [overflow-anchor:auto]">
        <div className="relative flex min-h-full w-full flex-col pt-(--thread-overlay-top-height) pb-(--thread-overlay-bottom-height)">
          <Thread.Content className="flex w-full flex-col justify-end gap-3 px-3">
            {chat.turns.map((turn, index) => (
              <Message.Root
                key={turn.id}
                role={turn.role}
                isLast={index === chat.turns.length - 1}
                className="group flex w-full flex-col data-[role=user]:items-end"
              >
                <Message.Text className="text-[#1a1a1a] text-sm leading-[1.7] group-data-[role=user]:max-w-[85%] group-data-[role=user]:rounded-2xl group-data-[role=user]:rounded-br-md group-data-[role=user]:bg-[#f4f4f4] group-data-[role=user]:px-3 group-data-[role=user]:py-1.5 dark:text-[#fcfcfc] dark:group-data-[role=user]:bg-[#262626]">
                  {turn.text}
                </Message.Text>
              </Message.Root>
            ))}
          </Thread.Content>
        </div>
      </Thread.Viewport>
      <Thread.Composer className="absolute inset-x-0 bottom-0 z-2 w-full p-2 pt-0">
        <DockComposer placeholder="Reply…" onSubmit={onSend} />
      </Thread.Composer>
    </Thread.Root>
  );
};

/**
 * A chat that does not exist yet. The one place real words survive: a draft
 * that looks like an empty chat gives no hint that sending it creates a tab.
 */
const NewChat = ({ onStart }: { onStart: (text: string) => void }) => (
  <div className="flex h-full flex-col">
    <div className="flex flex-1 flex-col items-center justify-center gap-1.5 px-6 text-center">
      <IconSparkles className="size-5 text-[#949494] dark:text-[#6f6f6f]" />
      <p className="font-medium text-[#1a1a1a] text-sm dark:text-[#fcfcfc]">Ask the agent</p>
      <p className="text-[#686868] text-sm leading-[1.7] dark:text-[#9b9b9b]">
        This is a draft — it becomes a tab once you send something.
      </p>
    </div>
    <div className="shrink-0 p-2 pt-0">
      <DockComposer placeholder="Ask anything…" onSubmit={onStart} />
    </div>
  </div>
);

// The composer clears itself on submit, so the handler only has to act on the
// text. Every Composer.Root owns an isolated store — no setup beyond onSubmit.
const DockComposer = ({
  placeholder,
  onSubmit,
}: {
  placeholder: string;
  onSubmit: (text: string) => void;
}) => {
  const handleSubmit = (data: ComposerSubmitData) => {
    if (data.kind !== "message") return;
    const text = data.text.trim();
    if (text) onSubmit(text);
  };

  return (
    <Composer.Root onSubmit={handleSubmit} className="flex w-full flex-col">
      <Composer.Container className="cursor-text rounded-xl border border-[#f0f0f0] bg-white shadow-xs transition-colors focus-within:border-[#ececec] dark:border-[#262626] dark:bg-[#111111] dark:focus-within:border-[#2d2d2d]">
        <Composer.Textarea className="max-h-32 min-h-10 overflow-y-auto px-3 pt-2.5 text-sm **:data-composer-editor:w-full **:data-composer-editor:max-w-none **:data-composer-editor:leading-[1.7] [&_[data-composer-editor]:focus]:outline-none">
          <Composer.Placeholder
            placeholder={placeholder}
            className="leading-[1.7] text-[#949494] dark:text-[#6f6f6f]"
          />
        </Composer.Textarea>
        <Composer.Actions className="flex justify-end p-1.5 pt-0">
          <Composer.Submit
            aria-label="Send"
            className="flex size-7 items-center justify-center rounded-full bg-[#1a1a1a] text-white transition-opacity disabled:opacity-30 dark:bg-[#fcfcfc] dark:text-[#111111]"
          >
            <IconArrowUp className="size-4" />
          </Composer.Submit>
        </Composer.Actions>
      </Composer.Container>
    </Composer.Root>
  );
};

const dockTabClass = [
  "group/tab relative flex h-7 max-w-40 shrink-0 cursor-pointer select-none items-center gap-1.5 overflow-hidden",
  "rounded-md px-2.5 text-[#686868] text-sm transition-colors dark:text-[#9b9b9b]",
  "hover:bg-[#f4f4f4] dark:hover:bg-[#232323]",
  "focus-visible:-outline-offset-2 focus-visible:outline-2 focus-visible:outline-[#1a1a1a] dark:focus-visible:outline-[#fcfcfc]",
  "data-[selected]:bg-[#ececec] data-[selected]:text-[#1a1a1a] dark:data-[selected]:bg-[#2d2d2d] dark:data-[selected]:text-[#fcfcfc]",
].join(" ");

const iconButtonClass =
  "grid size-6 shrink-0 cursor-pointer select-none place-items-center rounded-md text-[#949494] transition-colors hover:bg-[#f4f4f4] hover:text-[#1a1a1a] focus-visible:-outline-offset-2 focus-visible:outline-2 focus-visible:outline-[#1a1a1a] dark:text-[#6f6f6f] dark:hover:bg-[#232323] dark:hover:text-[#fcfcfc] dark:focus-visible:outline-[#fcfcfc]";
```

The panel is a real chat, built from this package's own parts — a
[Thread](/primitives/thread) of [Messages](/primitives/message) with a docked
[Composer](/primitives/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*.

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

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

export const closePolicies = [
  { value: "unset", default: true, description: "Nowhere: closing what was open shows nothing." },
  { value: '"adjacent"', description: "Whatever slides into the vacated slot, or the last tab if the tail went — an editor's behaviour." },
  { value: '"recent"', description: "The tab you were in before this one, falling back to adjacent." },
];

<ValuesTable rows={closePolicies} />

```tsx title="primitives/tabs/demos/closing.tsx"
"use client";

import { Tabs } from "@intentface/chat/tabs";
import { IconArchive, IconInbox, IconPencil, IconSend, IconX } from "@tabler/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-[#1a1a1a] text-xs dark:text-[#fcfcfc]">
      {policy === "unset" ? "selectOnClose unset" : `selectOnClose="${policy}"`}
    </code>

    <Tabs.Root
      defaultItems={TABS}
      defaultValue="Drafts"
      selectOnClose={policy === "unset" ? undefined : policy}
      className="flex flex-col gap-1.5"
    >
      <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-[#949494] dark:text-[#6f6f6f] [&>svg]:size-3.5">
              {createElement(TAB_ICONS[id] ?? IconInbox)}
            </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 text-[#949494] transition-colors hover:bg-[#dcdcdc] hover:text-[#1a1a1a] dark:text-[#6f6f6f] dark:hover:bg-[#3d3d3d] dark:hover:text-[#fcfcfc]"
              >
                <IconX className="size-3.5" />
              </Tabs.Close>
            </Tabs.Action>
          </Tabs.Trigger>
        )}
      </Tabs.List>

      <Tabs.Viewport className="flex h-20 items-center justify-center rounded-xl border border-[#f0f0f0] bg-white px-4 text-sm dark:border-[#262626] dark:bg-[#111111]">
        {(id) => <span className="text-[#686868] dark:text-[#9b9b9b]">{id}</span>}
      </Tabs.Viewport>
    </Tabs.Root>

    <p className="text-[#686868] text-xs leading-5 dark:text-[#9b9b9b]">{caption}</p>
  </div>
);

const tabClass = [
  // No strip behind the tabs: they sit on the page ground, and the open one is
  // a white card matching the panel below, so the selection reads as continuous
  // with its content rather than as a highlighted button.
  "group/tab relative flex h-8 w-40 shrink-0 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-lg pr-7 pl-2.5 text-[#686868] text-sm transition-colors dark:text-[#9b9b9b]",
  "hover:bg-[#e7e7e7] dark:hover:bg-[#262626]",
  "focus-visible:-outline-offset-2 focus-visible:outline-2 focus-visible:outline-[#1a1a1a] dark:focus-visible:outline-[#fcfcfc]",
  "data-[selected]:bg-white data-[selected]:text-[#1a1a1a] data-[selected]:shadow-[0_1px_2px_rgba(0,0,0,0.06)]",
  "dark:data-[selected]:bg-[#2d2d2d] dark:data-[selected]:text-[#fcfcfc]",
].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 IconInbox> = {
  Inbox: IconInbox,
  Drafts: IconPencil,
  Sent: IconSend,
  Archive: IconArchive,
};
```

### 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](/primitives/shell#persisting-across-sessions) 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.

```tsx title="primitives/tabs/demos/external.tsx"
"use client";

import { Tabs, type TabsStore, useTabsStore } from "@intentface/chat/tabs";
import { IconArchive, IconInbox, IconPencil, IconSend, IconX } from "@tabler/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="flex flex-col gap-1.5"
      >
        <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-[#949494] dark:text-[#6f6f6f] [&>svg]:size-3.5">
                {createElement(TAB_ICONS[id] ?? IconInbox)}
              </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 text-[#949494] transition-colors hover:bg-[#dcdcdc] hover:text-[#1a1a1a] dark:text-[#6f6f6f] dark:hover:bg-[#3d3d3d] dark:hover:text-[#fcfcfc]"
                >
                  <IconX className="size-3.5" />
                </Tabs.Close>
              </Tabs.Action>
            </Tabs.Trigger>
          )}
        </Tabs.List>

        <Tabs.Viewport className="flex h-24 items-center justify-center rounded-xl border border-[#f0f0f0] bg-white px-4 text-sm text-[#686868] dark:border-[#262626] dark:bg-[#111111] dark:text-[#9b9b9b]">
          {(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 border border-[#e4e4e4] bg-white px-4 font-medium text-[#1a1a1a] text-sm transition-colors hover:bg-[#f4f4f4] focus-visible:-outline-offset-2 focus-visible:outline-2 focus-visible:outline-[#1a1a1a] dark:border-[#2d2d2d] dark:bg-[#181818] dark:text-[#fcfcfc] dark:hover:bg-[#232323] dark:focus-visible:outline-[#fcfcfc]"
        >
          {items.includes(id) ? `Go to ${id}` : `Open ${id}`}
        </button>
      ))}
    </div>
  );
};

const tabClass = [
  // No strip behind the tabs: they sit on the page ground, and the open one is
  // a white card matching the panel below, so the selection reads as continuous
  // with its content rather than as a highlighted button.
  "group/tab relative flex h-8 w-40 shrink-0 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-lg pr-7 pl-2.5 text-[#686868] text-sm transition-colors dark:text-[#9b9b9b]",
  "hover:bg-[#e7e7e7] dark:hover:bg-[#262626]",
  "focus-visible:-outline-offset-2 focus-visible:outline-2 focus-visible:outline-[#1a1a1a] dark:focus-visible:outline-[#fcfcfc]",
  "data-[selected]:bg-white data-[selected]:text-[#1a1a1a] data-[selected]:shadow-[0_1px_2px_rgba(0,0,0,0.06)]",
  "dark:data-[selected]:bg-[#2d2d2d] dark:data-[selected]:text-[#fcfcfc]",
].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 IconInbox> = {
  Inbox: IconInbox,
  Drafts: IconPencil,
  Sent: IconSend,
  Archive: IconArchive,
};
```

## 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

export const keys = [
  { keys: "Arrow keys", description: "Move focus along the strip, following its orientation." },
  { keys: "Enter / Space", description: "Open the focused tab." },
  { keys: "Delete / Backspace", description: "Close the focused tab — only inside a Tabs.List, and the keyboard equivalent of the × beside it." },
  { keys: "Escape", description: "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." },
];

<KeysTable rows={keys} />

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](/handbook/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`.

export const rootProps = [
  { name: "defaultItems", type: "string[]", description: "The starting tabs when nothing controls them." },
  { name: "items", type: "string[]", description: "Controlled collection." },
  { name: "onItemsChange", type: "(items: string[]) => void", description: "Fires whenever a tab is added, removed or moved." },
  { name: "defaultValue", type: "string | null", description: "The tab open on first render." },
  { name: "value", type: "string | null", description: "Controlled selection; null shows nothing." },
  { name: "onValueChange", type: "(value: string | null) => void", description: "Fires on every selection change." },
  { name: "selectOnClose", type: '"adjacent" | "recent"', description: "Where the selection lands when the open tab is closed. Unset means nowhere." },
  { name: "orientation", type: '"horizontal" | "vertical"', default: '"horizontal"', description: "Which arrow keys walk the strip." },
  { name: "loop", type: "boolean", default: "true", description: "Wrap at the ends of the strip." },
  { name: "activateOnFocus", type: "boolean", default: "false", description: "Select as focus moves, instead of on activation." },
  { name: "disabled", type: "boolean", default: "false", description: "Disable the whole collection." },
  { name: "dismissOnEscape", type: "boolean", default: "true", description: "Escape closes the open panel." },
  { name: "store", type: "TabsStore", description: "An explicit Tabs.createStore() handle. Must be stable for the Root's lifetime." },
];

<PropsTable rows={rootProps} />

export const rootAttrs = [
  { attribute: "data-tabs", description: "The container." },
  { attribute: "data-orientation", values: '"horizontal" | "vertical"', description: "Which arrow keys walk the strip." },
  { attribute: "data-empty", description: "Present while nothing is open. A page-tab strip never sees this; a dock does." },
  { attribute: "data-disabled", description: "Present while the whole collection is disabled." },
  { attribute: "data-activation-direction", values: '"left" | "right" | "up" | "down"', description: "Which way the selection last moved, for panels that slide rather than fade. Absent when it has not moved." },
];

<AttributesTable rows={rootAttrs} />

### Tabs.List

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

export const listProps = [
  { name: "children", type: "ReactNode | ((value: string, index: number) => ReactNode)", description: "Plain children when you lay the strip out yourself; a function renders one call per tab, in order." },
];

<PropsTable rows={listProps} />

export const listAttrs = [
  { attribute: "data-tabs-list", values: 'role="toolbar"', description: "The strip, with aria-orientation matching the Root's." },
  { attribute: "data-orientation", values: '"horizontal" | "vertical"', description: "The strip's orientation." },
  { attribute: "data-empty", description: "Present while nothing is open." },
  { attribute: "data-disabled", description: "Present while the collection is disabled." },
  { attribute: "data-activation-direction", values: '"left" | "right" | "up" | "down"', description: "Which way the selection last moved." },
];

<AttributesTable rows={listAttrs} />

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

export const triggerProps = [
  { name: "value", type: "string", description: "The tab's id. Required outside a List; inherited from the enclosing tab inside one." },
  { name: "disabled", type: "boolean", description: "Disable this tab. It stays in the arrow-key ring." },
];

<PropsTable rows={triggerProps} />

export const triggerAttrs = [
  { attribute: "data-tabs-trigger", values: "the tab's value", description: "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." },
  { attribute: "data-selected", description: "Present while this tab's panel is showing." },
  { attribute: "data-disabled", description: "Present while disabled." },
  { attribute: "data-orientation", values: '"horizontal" | "vertical"', description: "The strip's orientation." },
];

<AttributesTable rows={triggerAttrs} />

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

export const iconProps = [
  { name: "value", type: "string", description: "Only outside a tab, where there is none to inherit from." },
];

<PropsTable rows={iconProps} />

export const iconAttrs = [
  { attribute: "data-tabs-icon", description: "The icon slot." },
  { attribute: "data-selected", description: "Present while the tab's panel is showing." },
  { attribute: "data-disabled", description: "Present while the tab is disabled." },
];

<AttributesTable rows={iconAttrs} />

### Tabs.Action

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

export const actionProps = [
  { name: "value", type: "string", description: "Only outside a tab, where there is none to inherit from." },
];

<PropsTable rows={actionProps} />

export const actionAttrs = [
  { attribute: "data-tabs-action", description: "The slot." },
  { attribute: "data-selected", description: "Present while the tab's panel is showing." },
  { attribute: "data-disabled", description: "Present while the tab is disabled." },
];

<AttributesTable rows={actionAttrs} />

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.

export const closeProps = [
  { name: "value", type: "string", description: "Only outside a tab, where there is none to inherit from." },
  { name: "disabled", type: "boolean", description: "Defaults to the tab's disabled state." },
];

<PropsTable rows={closeProps} />

export const closeAttrs = [
  { attribute: "data-tabs-close", description: "The close button." },
  { attribute: "data-selected", description: "Present while the tab's panel is showing." },
  { attribute: "data-disabled", description: "Present while disabled." },
];

<AttributesTable rows={closeAttrs} />

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

export const viewportProps = [
  { name: "children", type: "(value: string) => ReactNode", description: "Renders the content for whatever is open. Not called when nothing is." },
];

<PropsTable rows={viewportProps} />

export const viewportAttrs = [
  { attribute: "data-tabs-viewport", description: "The content box. Carries an id, and `aria-labelledby` naming whichever tab is open." },
  { attribute: "data-empty", description: "Present while nothing is open — the children function is not called, so the box is empty." },
  { attribute: "data-orientation", values: '"horizontal" | "vertical"', description: "The collection's orientation." },
  { attribute: "data-activation-direction", values: '"left" | "right" | "up" | "down"', description: "Which way the selection last moved, so the panel can slide the right way." },
];

<AttributesTable rows={viewportAttrs} />

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.

export const portalProps = [
  { name: "container", type: "HTMLElement | null", default: "document.body", description: "Where the surface is portaled." },
  { name: "keepMounted", type: "boolean", default: "false", description: "Keep the surface mounted while nothing is open. Costs a live positioning loop." },
];

<PropsTable rows={portalProps} />

### Tabs.Positioner

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

export const positionerProps = [
  { name: "side", type: '"top" | "bottom" | "left" | "right"', default: '"top"', description: "Preferred side; flips when there is no room." },
  { name: "align", type: '"start" | "center" | "end"', default: '"center"', description: "Alignment along that side." },
  { name: "sideOffset", type: "number", default: "8", description: "Gap between the tab and the surface." },
  { name: "collisionPadding", type: "number", default: "8", description: "How far clear of the viewport edges to stay." },
];

<PropsTable rows={positionerProps} />

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.

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

<AttributesTable rows={positionerAttrs} />

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

export const popupAttrs = [
  { attribute: "data-tabs-popup", description: "The surface." },
  { attribute: "data-open", description: "Present while a tab is open." },
  { attribute: "data-closed", description: "Present while nothing is." },
  { attribute: "data-starting-style", description: "Present on the first open frame." },
  { attribute: "data-ending-style", description: "Present while the exit animation runs." },
];

<AttributesTable rows={popupAttrs} />

## useTabs

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

```tsx
const openTab = useTabs((tabs) => tabs.value);
```

export const hookMembers = [
  { name: "value", type: "string | null", description: "The open tab, or null when nothing is." },
  { name: "items", type: "string[]", description: "The tabs, in order." },
  { name: "recent", type: "string[]", description: "Most recently opened first — what the recent close policy reads." },
  { name: "direction", type: '"left" | "right" | "up" | "down" | "none"', description: "Which way the selection last moved, for panels that slide rather than fade." },
  { name: "open", type: "(value, options?) => void", description: "Add a tab (or move it, if already present) and select it." },
  { name: "close", type: "(value: string) => void", description: "Remove a tab; selectOnClose decides where the selection lands." },
  { name: "select", type: "(value: string | null) => void", description: "Open a tab, or null to show nothing." },
  { name: "selectRelative", type: "(direction, options?) => void", description: "Step the selection — what the arrow keys drive." },
  { name: "move", type: "(value: string, toIndex: number) => void", description: "Reorder; the target index is clamped into range." },
  { name: "setItems", type: "(items: string[]) => void", description: "Replace the whole collection." },
];

<PropsTable rows={hookMembers} />

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