# Tabs

> Tabs and their Panels, with one Panel showing at a time.

`<slab-tabs>` · Navigation · built on `[role=tablist]`

## Install

```sh
pnpm add slab-elements lit
```

## Usage

```html
<script type="module">
  import 'slab-elements/tabs.js';
  import 'slab-elements/tabs.tab.js';
  import 'slab-elements/tabs.panel.js';
</script>

<slab-tabs label="Account">
  <slab-tabs-tab panel="profile">Profile</slab-tabs-tab>
  <slab-tabs-tab panel="billing">Billing</slab-tabs-tab>

  <slab-tabs-panel name="profile">…</slab-tabs-panel>
  <slab-tabs-panel name="billing">…</slab-tabs-panel>
</slab-tabs>
```

## Example

Here's a styled version, `<slab-styled-tabs>`. It's not in the package — it's an example of what you can build with the headless element. Copy it and make it yours at the bottom of this page.

```html
<slab-styled-tabs label="Foundation">
  <slab-styled-tabs-tab panel="pour">Pour</slab-styled-tabs-tab>
  <slab-styled-tabs-tab panel="cure">Cure</slab-styled-tabs-tab>
  <slab-styled-tabs-tab panel="notes">Notes</slab-styled-tabs-tab>

  <slab-styled-tabs-panel name="pour">Friday at 7 a.m. 38 cubic yards of 4,000 psi, with a broom finish.</slab-styled-tabs-panel>
  <slab-styled-tabs-panel name="cure">Keep it wet for seven days. It reaches about 70% of its strength in a week.</slab-styled-tabs-panel>
  <slab-styled-tabs-panel name="notes">The northeast corner sits about an inch low. Shim the forms.</slab-styled-tabs-panel>
</slab-styled-tabs>
```

Swap `slab-styled-` for `slab-` in that markup and you've got the same example as it ships: the headless elements with no CSS at all. That's your starting point.

## Keyboard

| Key          | What it does                                                                                                                              |
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
| Left, Right  | Moves to the previous or next Tab, wrapping, and selects it unless `manual` is set. Up and Down instead when `orientation` is "vertical". |
| Home, End    | Moves to the first or last Tab.                                                                                                           |
| Enter, Space | Selects the focused Tab when `manual` is set.                                                                                             |
| Tab          | Moves from the selected Tab into its Panel.                                                                                               |

## Behavior

### `<slab-tabs>`

Tabs and their Panels, with no visual styles.

There's no native tab list, so this is a `[role=tablist]` holding Tabs, with the Panels beside it. Tabs and Panels are found by class rather than by tag name, so your subclasses of them can be named anything.

Exactly one Tab is selected and exactly one is tabbable: the selected one. The arrow keys move focus between Tabs, wrapping at each end and skipping disabled ones: Left and Right, or Up and Down when `orientation` is `"vertical"`. Home and End go to the first and last. Focus selects the Tab it lands on, unless `manual` is set, where Space and Enter do. A click selects either way.

Each Tab is wired to the Panel whose `name` matches its `panel` with `aria-controls` and `aria-labelledby`, and every other Panel is `hidden`.

Style it through its parts, each Tab through `:state(disabled)` and `:state(selected)`, and each Panel directly.

### `<slab-tabs-tab>`

A Tab with no visual styles. The host is the tab, so style it directly.

It shows the Panel whose `name` matches its `panel`, and puts itself in its Tabs' `tab` slot. Its Tabs moves focus between Tabs, selects one at a time, and wires each Tab to its Panel.

It sets its own `:state(disabled)` and `:state(selected)`.

### `<slab-tabs-panel>`

A Panel with no visual styles. The host is the tab panel, so style it directly.

Its Tabs shows it when the Tab whose `panel` matches its `name` is selected, and sets `hidden` on it otherwise. It's in the tab sequence, as the APG recommends, so a Panel with nothing focusable in it can still be reached.

## API

### `<slab-tabs>`

#### Attributes

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `label` (required) | `string` |  | Names the tab list. |
| `manual` | `boolean` | `false` | Space and Enter select the focused Tab rather than focus itself, for Panels that are slow to show. |
| `orientation` | `'horizontal' \| 'vertical'` | `'horizontal'` | Which arrow keys move between Tabs. Where they sit is still up to your styles. |
| `version` | `string` |  |  |

#### Properties

| Name | Type | Description |
| --- | --- | --- |
| `panels` (read-only) | `Panel[]` |  |
| `selectedTab` (read-only) | `Tab \| null` |  |
| `tabs` (read-only) | `Tab[]` |  |

#### Methods

| Signature | Description |
| --- | --- |
| `focus(options?: FocusOptions): void` |  |

#### Events

| Name | Type | Description |
| --- | --- | --- |
| `change` | `Event` |  |

#### Slots

| Name | Type | Description |
| --- | --- | --- |
| `tab` | `Tab` |  |
| `(default)` | `Panel` |  |

#### CSS parts

| Name | Description |
| --- | --- |
| `tablist` | The Tabs |

### `<slab-tabs-tab>`

#### Attributes

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `panel` (required) | `string` |  | The `name` of the Panel it shows. |
| `disabled` | `boolean` | `false` |  |
| `selected` | `boolean` | `false` | Like Option's, the attribute is the default state and the property is the current one. Style `:state(selected)` rather than `[selected]`. |
| `version` | `string` |  |  |

#### Slots

| Name | Type | Description |
| --- | --- | --- |
| `(default)` | `Element \| string` | The label, plus anything else a design puts in a Tab |

### `<slab-tabs-panel>`

#### Attributes

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `name` (required) | `string` |  | What its Tab's `panel` names. |
| `version` | `string` |  |  |

#### Slots

| Name | Type | Description |
| --- | --- | --- |
| `(default)` | `Element \| string` | The content |

## Copy and own it

`<slab-styled-tabs>` isn't in the package. It's an example — copy it into your project and change whatever you want. Under the hood, it's just the headless element, subclassed, with `styles` set. That's it. Keyboard, focus, ARIA, forms, and events all come from the headless element, and the styles only reach in through `::part()` and `:state()`, same as yours would.

To use it, copy these files, swap the tag in `@customElement()` for one of your own, and import it. `tokens.styles.ts` holds the colors and fonts every styled example shares, so you only need it once.

### TypeScript

These files are TypeScript, and `@customElement()` is one of Lit's decorators (TypeScript's experimental flavor). You'll need two options in your tsconfig, per [Lit's docs](https://lit.dev/docs/components/decorators/#decorators-typescript): `experimentalDecorators` to compile them, and `useDefineForClassFields` turned off so class fields don't stomp on the accessors Lit sets up.

```json
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "useDefineForClassFields": false
  }
}
```

Using plain JavaScript? Drop the types and register the class with `customElements.define()` instead of the decorator.

### tabs.ts

```ts
import { customElement } from 'lit/decorators.js';
import Tabs from 'slab-elements/tabs.js';
import styles from './tabs.styles.js';
import tokens from './tokens.styles.js';

declare global {
  interface HTMLElementTagNameMap {
    'slab-styled-tabs': StyledTabs;
  }
}

/**
 * Tabs, styled: its Tabs sit on an ink rule, and the selected one is a block
 * of ink standing on it.
 */
@customElement('slab-styled-tabs')
export default class StyledTabs extends Tabs {
  static override styles = [tokens, styles];
}
```

### tabs.styles.ts

```ts
import { css } from 'lit';

export default css`
  :host {
    color: var(--ink);
    font-family: var(--font-body);
  }

  [part~='tablist'] {
    border-block-end: 4px solid var(--ink);
    display: flex;
    flex-wrap: wrap;
    gap: 0.25rem;
  }

  :host([orientation='vertical']) {
    display: grid;
    gap: 1.5rem;
    grid-template-columns: auto 1fr;

    & [part~='tablist'] {
      align-content: start;
      border-block-end: 0;
      border-inline-end: 4px solid var(--ink);
      flex-direction: column;
    }
  }
`;
```

### tabs.tab.ts

```ts
import { customElement } from 'lit/decorators.js';
import Tab from 'slab-elements/tabs.tab.js';
import styles from './tabs.tab.styles.js';
import tokens from './tokens.styles.js';

declare global {
  interface HTMLElementTagNameMap {
    'slab-styled-tabs-tab': StyledTab;
  }
}

/**
 * Tab, styled: slab-serif capitals that invert to paper on ink when the Tab
 * is selected, with an ember cap.
 */
@customElement('slab-styled-tabs-tab')
export default class StyledTab extends Tab {
  static override styles = [tokens, styles];
}
```

### tabs.tab.styles.ts

```ts
import { css } from 'lit';

export default css`
  :host {
    border-block-start: 4px solid transparent;
    cursor: pointer;
    font-family: var(--font-display);
    font-size: 1.0625rem;
    font-weight: 600;
    letter-spacing: 0.04em;
    padding: 0.5rem 1.125rem;
    text-transform: uppercase;
    transition:
      background-color 100ms,
      color 100ms;
  }

  :host(:hover) {
    background-color: var(--chalk);
  }

  :host(:focus-visible) {
    outline: 3px solid var(--ember);
    outline-offset: -3px;
  }

  /*
    The ring is inside the Tab, so on a selected one it's on ink, where ember
    falls short of 3:1 in the dark. The text's color always has the contrast.
  */
  :host(:state(selected):focus-visible) {
    outline-color: currentcolor;
  }

  :host(:state(selected)) {
    background-color: var(--ink);
    border-block-start-color: var(--ember);
    color: var(--paper);
  }

  :host(:state(disabled)) {
    background-color: transparent;
    color: var(--ash);
    cursor: not-allowed;
    text-decoration: line-through;
  }

  @media (prefers-reduced-motion: reduce) {
    :host {
      transition: none;
    }
  }
`;
```

### tabs.panel.ts

```ts
import { customElement } from 'lit/decorators.js';
import Panel from 'slab-elements/tabs.panel.js';
import styles from './tabs.panel.styles.js';
import tokens from './tokens.styles.js';

declare global {
  interface HTMLElementTagNameMap {
    'slab-styled-tabs-panel': StyledPanel;
  }
}

/**
 * Panel, styled: room around its content, and the same focus ring as
 * everything else when it's focused itself.
 */
@customElement('slab-styled-tabs-panel')
export default class StyledPanel extends Panel {
  static override styles = [tokens, styles];
}
```

### tabs.panel.styles.ts

```ts
import { css } from 'lit';

export default css`
  :host {
    display: block;
    padding-block: 1.5rem;
  }

  :host(:focus-visible) {
    outline: 3px solid var(--ember);
    outline-offset: 2px;
  }
`;
```

### tokens.styles.ts

```ts
import { css } from 'lit';

/*
  The styled components' palette and type, taken from the logo: ink for the
  courses, ember for the base, paper behind them.

  Each is a short alias for a "--slab-styled-*" property, with the default as
  its fallback. Retheming is "--slab-styled-ember" and the rest on ":root",
  which every shadow root inherits. Were the defaults declared under those
  names here instead, each host's own declaration would shadow the page's.

  Each default is "light-dark()", so the components follow the page's
  "color-scheme": "dark" for dark, "light" for light, and "light dark" to
  follow the operating system. Ink and paper trade places in the dark, so
  ink is always the text and paper always what's behind it. Contrast is given
  as light, then dark.
*/
export default css`
  :host {
    /* Secondary text. 6.8:1 and 6.9:1 on paper. */
    --ash: var(--slab-styled-ash, light-dark(#5e5a55, #a8a19a));
    --border: 2px solid var(--ink);

    /* Behind disabled and read-only controls, and hovered rows. */
    --chalk: var(--slab-styled-chalk, light-dark(#f4f1ec, #2b2926));

    /*
      The logo's orange, brighter in the dark. 4.25:1 and 5.2:1 on paper is
      enough for borders, fills and large text, but not always for small text,
      which takes "--ember-deep".
    */
    --ember: var(--slab-styled-ember, light-dark(#d9492a, #ec5f3b));

    /*
      Orange for small text on paper, and for paper text on it. 5.7:1 and
      7.4:1.
    */
    --ember-deep: var(--slab-styled-ember-deep, light-dark(#b3381a, #ff8566));
    --font-body: var(
      --slab-styled-font-body,
      'IBM Plex Sans',
      system-ui,
      sans-serif
    );
    --font-display: var(
      --slab-styled-font-display,
      'Zilla Slab',
      rockwell,
      'Roboto Slab',
      serif
    );
    --font-mono: var(
      --slab-styled-font-mono,
      'IBM Plex Mono',
      ui-monospace,
      monospace
    );
    --ink: var(--slab-styled-ink, light-dark(#1c1c1c, #f4f1ec));
    --paper: var(--slab-styled-paper, light-dark(#fff, #1a1918));

    /* The hard, offset shadow a raised surface casts. */
    --shadow: 4px 4px 0 var(--ink);
  }
`;
```
