Slab

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.

Styled example As it ships
Broom Trowel Stamp Polish Fiber Accelerator Color
HTML
<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>
Broom Trowel Stamp Polish Fiber Accelerator Color
HTML
<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

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

Attributes
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
Properties
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.
Methods
Signature Description
focus(options?: FocusOptions): void
Slots
Name Type Description
(default) ToggleGroupButton
CSS parts
Name Description
group The buttons

<slab-toggle-group-button>

Attributes
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
Events
Name Type Description
input Event
change Event
Slots
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);
  }
`;