Selection
Toggle Group
A row of toggle buttons that Tab reaches once, with one pressed at a time or several.
- Element
<slab-toggle-group>- Built on
[role=group]- As Markdown
- toggle-group.md
Example
Here's a styled version, <slab-styled-toggle-group>. 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.
<div class="stack">
<slab-styled-toggle-group label="Finish">
<slab-styled-toggle-group-button value="broom" pressed>Broom</slab-styled-toggle-group-button>
<slab-styled-toggle-group-button value="trowel">Trowel</slab-styled-toggle-group-button>
<slab-styled-toggle-group-button value="stamp">Stamp</slab-styled-toggle-group-button>
<slab-styled-toggle-group-button value="polish" disabled>Polish</slab-styled-toggle-group-button>
</slab-styled-toggle-group>
<slab-styled-toggle-group label="Additives" multiple>
<slab-styled-toggle-group-button value="fiber" pressed>Fiber</slab-styled-toggle-group-button>
<slab-styled-toggle-group-button value="accelerator">Accelerator</slab-styled-toggle-group-button>
<slab-styled-toggle-group-button value="color" pressed>Color</slab-styled-toggle-group-button>
</slab-styled-toggle-group>
</div>
<div class="stack">
<slab-toggle-group label="Finish">
<slab-toggle-group-button value="broom" pressed>Broom</slab-toggle-group-button>
<slab-toggle-group-button value="trowel">Trowel</slab-toggle-group-button>
<slab-toggle-group-button value="stamp">Stamp</slab-toggle-group-button>
<slab-toggle-group-button value="polish" disabled>Polish</slab-toggle-group-button>
</slab-toggle-group>
<slab-toggle-group label="Additives" multiple>
<slab-toggle-group-button value="fiber" pressed>Fiber</slab-toggle-group-button>
<slab-toggle-group-button value="accelerator">Accelerator</slab-toggle-group-button>
<slab-toggle-group-button value="color" pressed>Color</slab-toggle-group-button>
</slab-toggle-group>
</div>
Usage
pnpm add slab-elements lit
<script type="module">
import 'slab-elements/toggle-group.js';
import 'slab-elements/toggle-group.button.js';
</script>
<slab-toggle-group label="Alignment">
<slab-toggle-group-button value="left" pressed>Left</slab-toggle-group-button>
<slab-toggle-group-button value="center">Center</slab-toggle-group-button>
<slab-toggle-group-button value="right">Right</slab-toggle-group-button>
</slab-toggle-group>
Each item is a Toggle Group Button. The group sets each one’s aria-pressed, so there’s no ARIA to write.
Keyboard
| Key | What it does |
|---|---|
| Space, Enter | Presses the focused button, or releases it. Unless multiple is set, pressing one releases the rest. |
| Left, Right | Moves to the previous or next button, wrapping and skipping disabled ones. Up and Down instead when orientation is “vertical”. |
| Home, End | Moves to the first or last button. |
| Tab | Leaves the group. Coming back, focus returns to the button focused last. |
Behavior
<slab-toggle-group>
A group of toggle buttons with no visual styles: a [role=group] around its Toggle Group Buttons, so Tab reaches the group once and the arrow keys move between them.
A click, Space or Enter presses a button or releases it. Only one is pressed at a time unless multiple is set, so pressing one releases the rest, and when more than one is pressed anyway, the last one stays pressed. The group sets each button's aria-pressed, so set pressed on one that starts out pressed and leave the ARIA to it. A click your own handler cancels doesn't press anything.
Buttons are found by class rather than by tag name, so your subclass of Toggle Group Button can be named anything.
Exactly one button is tabbable: the one most recently focused, or the first that isn't disabled. Left and Right, or Up and Down when orientation is "vertical", move focus between them, wrapping at each end and skipping disabled ones, and Home and End go to the first and last. Moving focus doesn't press anything.
Style it through its part, and the buttons through their :state(pressed) and :state(disabled).
<slab-toggle-group-button>
A toggle button with no visual styles. The host is the button, so style it directly.
There's no native <button> inside it. Its Toggle Group moves focus between its buttons with a roving tabindex, which has to be on the element that takes focus, and a button in a shadow root can't be reached from outside it. So the host is the button, with the role, aria-pressed and the keyboard of one: Space and Enter click it.
Its group presses and releases it, and it sets its own :state(pressed) and :state(disabled).
API
<slab-toggle-group>
| Name | Type | Default | Description |
|---|---|---|---|
label
Required
|
string |
Names the group. | |
multiple
|
boolean |
false |
Lets more than one button be pressed at a time. |
orientation
|
'horizontal' | 'vertical' |
'horizontal' |
Which arrow keys move between the buttons. Where they sit is still up to your styles. |
version
|
string |
| Name | Type | Description |
|---|---|---|
buttons
Read-only
|
ToggleGroupButton[] |
|
pressedButtons
Read-only
|
ToggleGroupButton[] |
Pressed and enabled buttons, in the order they appear. |
value
|
string |
The value of the first pressed and enabled button. Setting it presses the button with that value, and only that one. |
values
|
string[] |
The value of every pressed and enabled button, in the order they appear. Setting it presses the buttons with those values, and only those, or only the first of them unless multiple is set. |
| Signature | Description |
|---|---|
focus(options?: FocusOptions): void
|
| Name | Type | Description |
|---|---|---|
(default)
|
ToggleGroupButton |
| Name | Description |
|---|---|
group
|
The buttons |
<slab-toggle-group-button>
| Name | Type | Default | Description |
|---|---|---|---|
label
|
string |
Names the button when what's in it doesn't, such as when it holds only an icon. | |
disabled
|
boolean |
false |
|
pressed
|
boolean |
false |
Like a native checkbox's checked, the attribute is the default state and the property is the current one. Style :state(pressed) rather than [pressed]. |
value
|
string |
'' |
|
version
|
string |
| Name | Type | Description |
|---|---|---|
input
|
Event |
|
change
|
Event |
| Name | Type | Description |
|---|---|---|
(default)
|
Element | string |
The label, plus anything else a design puts in a button |
Copy and own it
<slab-styled-toggle-group> 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.
toggle-group.ts
import { customElement } from 'lit/decorators.js';
import ToggleGroup from 'slab-elements/toggle-group.js';
import styles from './toggle-group.styles.js';
import tokens from './tokens.styles.js';
declare global {
interface HTMLElementTagNameMap {
'slab-styled-toggle-group': StyledToggleGroup;
}
}
/**
* Toggle Group, styled: a raised row of buttons joined by ink rules.
*/
@customElement('slab-styled-toggle-group')
export default class StyledToggleGroup extends ToggleGroup {
static override styles = [tokens, styles];
}
toggle-group.styles.ts
import { css } from 'lit';
/*
The row and the rules between its buttons. The buttons style themselves.
*/
export default css`
[part~='group'] {
background-color: var(--paper);
border: var(--border);
box-shadow: var(--shadow);
display: flex;
inline-size: fit-content;
}
:host([orientation='vertical']) [part~='group'] {
flex-direction: column;
}
/* An ink rule between each pair, across the row or down the column. */
::slotted(:not(:first-child)) {
border-inline-start: var(--border);
}
:host([orientation='vertical']) ::slotted(:not(:first-child)) {
border-block-start: var(--border);
border-inline-start: 0;
}
`;
toggle-group.button.ts
import { customElement } from 'lit/decorators.js';
import ToggleGroupButton from 'slab-elements/toggle-group.button.js';
import styles from './toggle-group.button.styles.js';
import tokens from './tokens.styles.js';
declare global {
interface HTMLElementTagNameMap {
'slab-styled-toggle-group-button': StyledToggleGroupButton;
}
}
/**
* Toggle Group Button, styled: uppercase slab type, filled in ember when it's
* pressed.
*/
@customElement('slab-styled-toggle-group-button')
export default class StyledToggleGroupButton extends ToggleGroupButton {
static override styles = [tokens, styles];
}
toggle-group.button.styles.ts
import { css } from 'lit';
export default css`
:host {
color: var(--ink);
cursor: pointer;
font-family: var(--font-display);
font-size: 0.9375rem;
font-weight: 600;
letter-spacing: 0.04em;
padding: 0.5rem 0.875rem;
text-transform: uppercase;
transition:
background-color 100ms,
color 100ms;
}
:host(:hover) {
background-color: var(--chalk);
}
/*
Inside the group's border rather than over it, so the ring isn't cut off
by the buttons on either side.
*/
:host(:focus-visible) {
outline: 3px solid var(--ember);
outline-offset: -5px;
}
/*
As specific as the hover above, and after it, so a pressed button keeps
its ember while it's hovered. Paper text on "--ember-deep" is 5.7:1 and
7.4:1, where on "--ember" it would fall short for small text.
*/
:host(:state(pressed)) {
background-color: var(--ember-deep);
color: var(--paper);
}
:host(:state(pressed):focus-visible) {
outline-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;
}
}
`;
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);
}
`;