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.
<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>
<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
pnpm add slab-elements lit
<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>
| 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 |
| Name | Type | Description |
|---|---|---|
panels
Read-only
|
Panel[] |
|
selectedTab
Read-only
|
Tab | null |
|
tabs
Read-only
|
Tab[] |
| Signature | Description |
|---|---|
focus(options?: FocusOptions): void
|
| Name | Type | Description |
|---|---|---|
change
|
Event |
| Name | Type | Description |
|---|---|---|
tab
|
Tab |
|
(default)
|
Panel |
| Name | Description |
|---|---|
tablist
|
The Tabs |
<slab-tabs-tab>
| 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 |
| Name | Type | Description |
|---|---|---|
(default)
|
Element | string |
The label, plus anything else a design puts in a Tab |
<slab-tabs-panel>
| Name | Type | Default | Description |
|---|---|---|---|
name
Required
|
string |
What its Tab's panel names. |
|
version
|
string |
| 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);
}
`;