Slab

Navigation

Tabs

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

Element
<slab-tabs>
Built on
[role=tablist]
As Markdown
tabs.md

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. Switch to "As it ships" to see the same markup with the headless element and no CSS at all. That's your starting point.

Styled example As it ships
Pour Cure Notes Friday at 7 a.m. 38 cubic yards of 4,000 psi, with a broom finish. Keep it wet for seven days. It reaches about 70% of its strength in a week. The northeast corner sits about an inch low. Shim the forms.
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>
Pour Cure Notes Friday at 7 a.m. 38 cubic yards of 4,000 psi, with a broom finish. Keep it wet for seven days. It reaches about 70% of its strength in a week. The northeast corner sits about an inch low. Shim the forms.
HTML
<slab-tabs label="Foundation">
  <slab-tabs-tab panel="pour">Pour</slab-tabs-tab>
  <slab-tabs-tab panel="cure">Cure</slab-tabs-tab>
  <slab-tabs-tab panel="notes">Notes</slab-tabs-tab>

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

Usage

Install
pnpm add slab-elements lit
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>

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.

tabs.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
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
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
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
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
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
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);
  }
`;