> ## Documentation Index
> Fetch the complete documentation index at: https://anvil.servicetitan.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Tabbed Layout - Web

> Tabbed layouts organize related views into selectable tabs.

> [!NOTE]
>
> For development, import `TabbedLayout` from
> `@servicetitan/anvil2-ai-kit`. For design, find it in the
> [AI Kit Web Component Library file](https://www.figma.com/design/QEIsArLktLeJAaFvcpw3PO/AI-Kit-Web-Component-Library).

ai-kit-tabbed-layout

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/ai-kit-tabbed-layout?file=src/App.tsx)

```tsx lines theme={null}
import { TabbedLayout, Text } from "@servicetitan/anvil2-ai-kit";
import type { TabbedLayoutTab } from "@servicetitan/anvil2-ai-kit";

const tabs: TabbedLayoutTab[] = [
  {
    key: "tab-a",
    label: "Tab Name A",
    content: <Text text="Tab A contents." />,
  },
  {
    key: "tab-b",
    label: "Tab Name B",
    content: <Text text="Tab B contents." />,
  },
];

function App() {
  return (
    <div
      style={{
        width: 900,
        height: 420,
        border: "1px solid #ccc",
        overflow: "hidden",
      }}
    >
      <TabbedLayout tabs={tabs} />
    </div>
  );
}

export default App;
```

## Options

TabbedLayout holds one or more tabs and arranges them either as a tab strip or two simultaneously-visible panes.

### Display Modes

ai-kit-tabbed-layout-display-modes

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/ai-kit-tabbed-layout-display-modes?file=src/App.tsx)

```tsx lines theme={null}
import { CartoTheme, TabbedLayout, Text } from "@servicetitan/anvil2-ai-kit";
import type {
  TabbedLayoutMode,
  TabbedLayoutTab,
} from "@servicetitan/anvil2-ai-kit";
import "@servicetitan/anvil2-ai-kit/styles.css";

const tabs: TabbedLayoutTab[] = [
  {
    key: "tab-a",
    label: "Tab Name A",
    content: <Text text="Tab A contents." />,
  },
  {
    key: "tab-b",
    label: "Tab Name B",
    content: <Text text="Tab B contents." />,
  },
];

function Labeled({ label, mode }: { label: string; mode: TabbedLayoutMode }) {
  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
      <Text variant="eyebrow" text={label} />
      <div
        style={{
          width: 820,
          height: 220,
          border: "1px solid #ccc",
          overflow: "hidden",
        }}
      >
        <TabbedLayout tabs={tabs} defaultMode={mode} />
      </div>
    </div>
  );
}

function App() {
  return (
    <CartoTheme>
      <div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
        <Labeled label="Stacked" mode="stacked" />
        <Labeled label="Vertical Split" mode="vertical-split" />
        <Labeled label="Horizontal Split" mode="horizontal-split" />
      </div>
    </CartoTheme>
  );
}

export default App;
```

#### Stacked

The classic tabs interaction: a tab strip with one panel visible at a time. Always available, and the mode every layout falls back to below 720px or with anything other than exactly two tabs.

#### Vertical Split

Both panels shown side by side, divided by a vertical rule. Offered only with exactly two tabs, at ≥ 720px.

#### Horizontal Split

Both panels stacked top and bottom, divided by a horizontal rule. Same two-tab, ≥ 720px constraint as Vertical Split.

### Narrow

ai-kit-tabbed-layout-narrow

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/ai-kit-tabbed-layout-narrow?file=src/App.tsx)

```tsx lines theme={null}
import { CartoTheme, TabbedLayout, Text } from "@servicetitan/anvil2-ai-kit";
import type { TabbedLayoutTab } from "@servicetitan/anvil2-ai-kit";
import "@servicetitan/anvil2-ai-kit/styles.css";

const tabs: TabbedLayoutTab[] = [
  {
    key: "tab-a",
    label: "Tab Name A",
    content: <Text text="Tab A contents." />,
  },
  {
    key: "tab-b",
    label: "Tab Name B",
    content: <Text text="Tab B contents." />,
  },
];

function App() {
  return (
    <CartoTheme>
      <div
        style={{
          width: 560,
          height: 160,
          border: "1px solid #ccc",
          overflow: "hidden",
        }}
      >
        <TabbedLayout tabs={tabs} defaultMode="vertical-split" />
      </div>
    </CartoTheme>
  );
}

export default App;
```

Below 720px — measured on the layout's own width, not the viewport — the layout is forced to Stacked and the mode menu hides, regardless of `defaultMode`. The last chosen mode is remembered and restored once it grows back past the threshold.

### Single Tab

ai-kit-tabbed-layout-single-tab

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/ai-kit-tabbed-layout-single-tab?file=src/App.tsx)

```tsx lines theme={null}
import { CartoTheme, TabbedLayout, Text } from "@servicetitan/anvil2-ai-kit";
import type { TabbedLayoutTab } from "@servicetitan/anvil2-ai-kit";
import "@servicetitan/anvil2-ai-kit/styles.css";

const tabs: TabbedLayoutTab[] = [
  {
    key: "preview",
    label: "Preview",
    content: <Text text="Preview contents." />,
  },
];

function App() {
  return (
    <CartoTheme>
      <div
        style={{
          width: 420,
          height: 140,
          padding: 8,
          background: "#e0e0e0",
        }}
      >
        <div style={{ width: "100%", height: "100%", overflow: "hidden" }}>
          <TabbedLayout tabs={tabs} />
        </div>
      </div>
    </CartoTheme>
  );
}

export default App;
```

A single tab shows neither a tab strip nor the mode menu — there's nothing to switch between or arrange, so the layout renders just that tab's label heading and content.

## Behavior

The layout adapts to its own measured width and lets the user resize or rearrange its panes.

### Mode Picker

ai-kit-tabbed-layout-mode-picker

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/ai-kit-tabbed-layout-mode-picker?file=src/App.tsx)

```tsx lines theme={null}
import { CartoTheme, TabbedLayout, Text } from "@servicetitan/anvil2-ai-kit";
import type { TabbedLayoutTab } from "@servicetitan/anvil2-ai-kit";
import "@servicetitan/anvil2-ai-kit/styles.css";

const tabs: TabbedLayoutTab[] = [
  {
    key: "tab-a",
    label: "Tab Name A",
    content: <Text text="Tab A contents." />,
  },
  {
    key: "tab-b",
    label: "Tab Name B",
    content: <Text text="Tab B contents." />,
  },
];

function App() {
  return (
    <CartoTheme>
      <div
        style={{
          width: 900,
          height: 300,
          border: "1px solid #ccc",
          overflow: "hidden",
        }}
      >
        <TabbedLayout tabs={tabs} />
      </div>
    </CartoTheme>
  );
}

export default App;
```

The top-right options menu lets the user pick a display mode — shown only when a split is currently possible (≥ 720px, exactly two tabs). The current mode carries a check. When the layout is the artifact drawer, a close button joins the same corner and dismisses the drawer via the app surface.

### Split Resize

ai-kit-tabbed-layout-split-resize

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/ai-kit-tabbed-layout-split-resize?file=src/App.tsx)

```tsx lines theme={null}
import { CartoTheme, TabbedLayout, Text } from "@servicetitan/anvil2-ai-kit";
import type { TabbedLayoutTab } from "@servicetitan/anvil2-ai-kit";
import "@servicetitan/anvil2-ai-kit/styles.css";

const tabs: TabbedLayoutTab[] = [
  {
    key: "tab-a",
    label: "Tab Name A",
    content: <Text text="Tab A contents." />,
  },
  {
    key: "tab-b",
    label: "Tab Name B",
    content: <Text text="Tab B contents." />,
  },
];

function App() {
  return (
    <CartoTheme>
      <div
        style={{
          width: 820,
          height: 260,
          border: "1px solid #ccc",
          overflow: "hidden",
        }}
      >
        <TabbedLayout tabs={tabs} defaultMode="vertical-split" />
      </div>
    </CartoTheme>
  );
}

export default App;
```

Drag the separator between the two panes (or focus it and use the arrow keys) to resize them. Both panes are clamped to a per-axis minimum, so neither can be dragged small enough to lose its heading and all its content.

### Overflow Handling

ai-kit-tabbed-layout-overflow-handling

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/ai-kit-tabbed-layout-overflow-handling?file=src/App.tsx)

```tsx lines theme={null}
import { CartoTheme, TabbedLayout, Text } from "@servicetitan/anvil2-ai-kit";
import type { TabbedLayoutTab } from "@servicetitan/anvil2-ai-kit";
import "@servicetitan/anvil2-ai-kit/styles.css";

const tabs: TabbedLayoutTab[] = [
  {
    key: "doc",
    label: "Document",
    content: (
      <>
        {Array.from({ length: 12 }, (_, i) => (
          <p key={i} style={{ marginTop: i === 0 ? 0 : undefined }}>
            Paragraph {i + 1}. Overflowing content scrolls within the panel's
            padding, not flush to its edge.
          </p>
        ))}
      </>
    ),
  },
  { key: "notes", label: "Notes", content: <Text text="Short notes." /> },
];

function App() {
  return (
    <CartoTheme>
      <div
        style={{
          width: 900,
          height: 320,
          border: "1px solid #ccc",
          overflow: "hidden",
        }}
      >
        <TabbedLayout tabs={tabs} defaultMode="vertical-split" />
      </div>
    </CartoTheme>
  );
}

export default App;
```

Content taller than a panel scrolls inside its padding — the scroll region is inset, so the scrollbar and the scrolled content stop at the padded edge rather than running flush to the panel's border.

## Usage Guidelines

### When to Use

Use the Tabbed Layout when:

* **Related views in the artifact region** – Supporting content in the artifact region of an [App Shell](https://anvil.servicetitan.com/docs/components/app-shell/web.md) or [Window](https://anvil.servicetitan.com/docs/components/window/web.md) needs access to multiple related views.
* **Two views at once** – Exactly two views benefit from being visible together, side by side (Vertical Split) or top and bottom (Horizontal Split), in a layout at least 720px wide.
* **A single artifact view** – One view needs a heading and content without a tab strip or mode menu.

### When not to use

Avoid using the Tabbed Layout for:

* **Three or more views at once** – Split modes require exactly two tabs. With three or more tabs, the layout always shows one panel at a time in Stacked mode.
* **Side-by-side views in a narrow region** – Below 720px, the layout forces Stacked mode and hides the mode menu, so a split never appears.

## Keyboard Interaction

Users can switch tabs, resize a split, and choose a display mode using standard keyboard controls.

| Key | Description |
| - | - |
| Tab | Moves focus among the tab strip, panel, and controls |
| Arrow Left / Arrow Right | Moves focus to the previous/next tab (Stacked) |
| Arrow keys | Resizes the split when the separator is focused |
| Enter / Space | Activates the focused tab or control |
| Escape | Closes the layout options menu |

## Patterns that use this component

* [Agentic Experience Shell](https://anvil.servicetitan.com/docs/patterns/agentic-experience-shell/web.md) — Organizes multiple related views within the supporting workspace

## Code

**Implementation**

ai-kit-tabbed-layout

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/ai-kit-tabbed-layout?file=src/App.tsx)

```tsx lines expandable theme={null}
import { TabbedLayout, Text } from "@servicetitan/anvil2-ai-kit";
import type { TabbedLayoutTab } from "@servicetitan/anvil2-ai-kit";

const tabs: TabbedLayoutTab[] = [
  {
    key: "tab-a",
    label: "Tab Name A",
    content: <Text text="Tab A contents." />,
  },
  {
    key: "tab-b",
    label: "Tab Name B",
    content: <Text text="Tab B contents." />,
  },
];

function App() {
  return (
    <div
      style={{
        width: 900,
        height: 420,
        border: "1px solid #ccc",
        overflow: "hidden",
      }}
    >
      <TabbedLayout tabs={tabs} />
    </div>
  );
}

export default App;
```

## Common Examples

To use the `TabbedLayout`, pass one or more `tabs` with a unique `key`, a localized `label`, and `content`. The kit forbids `children`.

```tsx theme={null}
import { TabbedLayout } from "@servicetitan/anvil2-ai-kit";

function ExampleComponent() {
  return (
    <TabbedLayout
      tabs={[
        { key: "a", label: "Summary", content: <Summary /> },
        { key: "b", label: "Details", content: <Details /> },
      ]}
    />
  );
}
```

Its primary home is the artifact region of an `AppShell` or `Window`. When the artifact is a drawer, the layout surfaces a close button wired through `useAppSurface`.

### Display modes

`stacked` shows one panel at a time. `vertical-split` and `horizontal-split` show both panels. Splits are offered only with exactly two tabs and a component width of at least 720px. Below that, the layout forces `stacked` and hides the mode picker. The last chosen mode is restored when the layout grows past the threshold.

ai-kit-tabbed-layout-display-modes

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/ai-kit-tabbed-layout-display-modes?file=src/App.tsx)

```tsx lines expandable theme={null}
import { CartoTheme, TabbedLayout, Text } from "@servicetitan/anvil2-ai-kit";
import type {
  TabbedLayoutMode,
  TabbedLayoutTab,
} from "@servicetitan/anvil2-ai-kit";
import "@servicetitan/anvil2-ai-kit/styles.css";

const tabs: TabbedLayoutTab[] = [
  {
    key: "tab-a",
    label: "Tab Name A",
    content: <Text text="Tab A contents." />,
  },
  {
    key: "tab-b",
    label: "Tab Name B",
    content: <Text text="Tab B contents." />,
  },
];

function Labeled({ label, mode }: { label: string; mode: TabbedLayoutMode }) {
  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
      <Text variant="eyebrow" text={label} />
      <div
        style={{
          width: 820,
          height: 220,
          border: "1px solid #ccc",
          overflow: "hidden",
        }}
      >
        <TabbedLayout tabs={tabs} defaultMode={mode} />
      </div>
    </div>
  );
}

function App() {
  return (
    <CartoTheme>
      <div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
        <Labeled label="Stacked" mode="stacked" />
        <Labeled label="Vertical Split" mode="vertical-split" />
        <Labeled label="Horizontal Split" mode="horizontal-split" />
      </div>
    </CartoTheme>
  );
}

export default App;
```

Seed the starting mode with `defaultMode`. The layout coerces to `stacked` when a split is unavailable.

ai-kit-tabbed-layout-mode-picker

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/ai-kit-tabbed-layout-mode-picker?file=src/App.tsx)

```tsx lines expandable theme={null}
import { CartoTheme, TabbedLayout, Text } from "@servicetitan/anvil2-ai-kit";
import type { TabbedLayoutTab } from "@servicetitan/anvil2-ai-kit";
import "@servicetitan/anvil2-ai-kit/styles.css";

const tabs: TabbedLayoutTab[] = [
  {
    key: "tab-a",
    label: "Tab Name A",
    content: <Text text="Tab A contents." />,
  },
  {
    key: "tab-b",
    label: "Tab Name B",
    content: <Text text="Tab B contents." />,
  },
];

function App() {
  return (
    <CartoTheme>
      <div
        style={{
          width: 900,
          height: 300,
          border: "1px solid #ccc",
          overflow: "hidden",
        }}
      >
        <TabbedLayout tabs={tabs} />
      </div>
    </CartoTheme>
  );
}

export default App;
```

### Single tab

A single tab shows neither a tab strip nor the mode menu — only its content. The drawer close button still appears when applicable.

ai-kit-tabbed-layout-single-tab

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/ai-kit-tabbed-layout-single-tab?file=src/App.tsx)

```tsx lines expandable theme={null}
import { CartoTheme, TabbedLayout, Text } from "@servicetitan/anvil2-ai-kit";
import type { TabbedLayoutTab } from "@servicetitan/anvil2-ai-kit";
import "@servicetitan/anvil2-ai-kit/styles.css";

const tabs: TabbedLayoutTab[] = [
  {
    key: "preview",
    label: "Preview",
    content: <Text text="Preview contents." />,
  },
];

function App() {
  return (
    <CartoTheme>
      <div
        style={{
          width: 420,
          height: 140,
          padding: 8,
          background: "#e0e0e0",
        }}
      >
        <div style={{ width: "100%", height: "100%", overflow: "hidden" }}>
          <TabbedLayout tabs={tabs} />
        </div>
      </div>
    </CartoTheme>
  );
}

export default App;
```

### Selection

Pass `defaultSelectedKey` for uncontrolled selection, or `selectedKey` with `onSelectionChange` to own it. Selection changes the visible panel only in `stacked` mode. Split modes show both panels.

### Overflow and narrow widths

Extra tabs overflow the strip. Below 720px the layout stays stacked.

ai-kit-tabbed-layout-overflow-handling

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/ai-kit-tabbed-layout-overflow-handling?file=src/App.tsx)

```tsx lines expandable theme={null}
import { CartoTheme, TabbedLayout, Text } from "@servicetitan/anvil2-ai-kit";
import type { TabbedLayoutTab } from "@servicetitan/anvil2-ai-kit";
import "@servicetitan/anvil2-ai-kit/styles.css";

const tabs: TabbedLayoutTab[] = [
  {
    key: "doc",
    label: "Document",
    content: (
      <>
        {Array.from({ length: 12 }, (_, i) => (
          <p key={i} style={{ marginTop: i === 0 ? 0 : undefined }}>
            Paragraph {i + 1}. Overflowing content scrolls within the panel's
            padding, not flush to its edge.
          </p>
        ))}
      </>
    ),
  },
  { key: "notes", label: "Notes", content: <Text text="Short notes." /> },
];

function App() {
  return (
    <CartoTheme>
      <div
        style={{
          width: 900,
          height: 320,
          border: "1px solid #ccc",
          overflow: "hidden",
        }}
      >
        <TabbedLayout tabs={tabs} defaultMode="vertical-split" />
      </div>
    </CartoTheme>
  );
}

export default App;
```

ai-kit-tabbed-layout-narrow

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/ai-kit-tabbed-layout-narrow?file=src/App.tsx)

```tsx lines expandable theme={null}
import { CartoTheme, TabbedLayout, Text } from "@servicetitan/anvil2-ai-kit";
import type { TabbedLayoutTab } from "@servicetitan/anvil2-ai-kit";
import "@servicetitan/anvil2-ai-kit/styles.css";

const tabs: TabbedLayoutTab[] = [
  {
    key: "tab-a",
    label: "Tab Name A",
    content: <Text text="Tab A contents." />,
  },
  {
    key: "tab-b",
    label: "Tab Name B",
    content: <Text text="Tab B contents." />,
  },
];

function App() {
  return (
    <CartoTheme>
      <div
        style={{
          width: 560,
          height: 160,
          border: "1px solid #ccc",
          overflow: "hidden",
        }}
      >
        <TabbedLayout tabs={tabs} defaultMode="vertical-split" />
      </div>
    </CartoTheme>
  );
}

export default App;
```

### Split resize

Split modes include a drag handle between panes.

ai-kit-tabbed-layout-split-resize

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/ai-kit-tabbed-layout-split-resize?file=src/App.tsx)

```tsx lines expandable theme={null}
import { CartoTheme, TabbedLayout, Text } from "@servicetitan/anvil2-ai-kit";
import type { TabbedLayoutTab } from "@servicetitan/anvil2-ai-kit";
import "@servicetitan/anvil2-ai-kit/styles.css";

const tabs: TabbedLayoutTab[] = [
  {
    key: "tab-a",
    label: "Tab Name A",
    content: <Text text="Tab A contents." />,
  },
  {
    key: "tab-b",
    label: "Tab Name B",
    content: <Text text="Tab B contents." />,
  },
];

function App() {
  return (
    <CartoTheme>
      <div
        style={{
          width: 820,
          height: 260,
          border: "1px solid #ccc",
          overflow: "hidden",
        }}
      >
        <TabbedLayout tabs={tabs} defaultMode="vertical-split" />
      </div>
    </CartoTheme>
  );
}

export default App;
```

## Internationalization

`TabbedLayout` owns these localized strings:

* `ai-kit.tabbedLayout.layoutOptions`
* `ai-kit.tabbedLayout.layoutMenu`
* `ai-kit.tabbedLayout.modeStacked`
* `ai-kit.tabbedLayout.modeVerticalSplit`
* `ai-kit.tabbedLayout.modeHorizontalSplit`
* `ai-kit.tabbedLayout.close`
* `ai-kit.tabbedLayout.resizePanes`
* `ai-kit.tabbedLayout.tabs`

Localize tab `label` values before passing them. Mount `AiKitIntlProvider` inside `CartoTheme` to switch the chrome strings.

## React Accessibility

* The tab strip is a labeled tablist. Split pane resize exposes a localized name.
* Close appears only when the artifact is a drawer, and uses the localized close label.

**TabbedLayout Props**

```tsx theme={null}
<TabbedLayout
  tabs={tabs}
  defaultMode="stacked"
  selectedKey={key}
  onSelectionChange={setKey}
/>
```

## `TabbedLayout` Props

The `TabbedLayout` accepts the following props and forwards remaining `<div>` attributes except `children` and `style`.

**tabs** `TabbedLayoutTab[]` *required*

One or more tabs. Split modes require exactly two.

**className** `string`

Class merged onto the root element.

**defaultMode** `"stacked" | "vertical-split" | "horizontal-split"` (default: stacked)

Uncontrolled starting display mode. Coerced to `stacked` when a split is
unavailable.

**defaultSelectedKey** `string`

Uncontrolled selected tab at mount. Defaults to the first tab.

**onSelectionChange** `(key: string) =\> void`

Fired when the user picks a tab, controlled or not.

**selectedKey** `string`

Controlled selected tab. Pair with `onSelectionChange`.

**TabbedLayoutTab**

```tsx theme={null}
const tab: TabbedLayoutTab = {
  key: "summary",
  label: "Summary",
  content: <Summary />,
};
```

## `TabbedLayoutTab`

**content** `ReactNode` *required*

Panel content.

**key** `string` *required*

Stable identity. Must be unique within `tabs`.

**label** `string` *required*

Localized label for the strip and split pane heading.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.