component
Toggle
A 40×40 icon button that stays on or off, alone or as one choice in a group.
Hover, press or tab to one to see the other 6 states.
import { Toggle } from 'rost/components/toggle/Toggle'
<Toggle selected aria-label="Show archived" />
<div role="radiogroup" aria-label="Tax year">
<Toggle role="radio" selected>2025</Toggle>
<Toggle role="radio">2026</Toggle>
</div>src/components/toggle/Toggle.tsx
import type { ButtonHTMLAttributes, ReactNode } from 'react'
import './toggle.css'
export interface ToggleProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/** On or off. Off is a faded plate with 12px corners, on a fully round primary pill. */
readonly selected?: boolean
/** Replaces the arrow. Draw it with `currentColor` and it follows the states. With a label, there's no icon unless you pass one. */
readonly icon?: ReactNode
/** A text label, like a year. The toggle grows wider than 40px to fit it. */
readonly children?: ReactNode
/** Needed when there's no label: say what it switches. */
readonly 'aria-label'?: string
/** `span` draws it without the button, for a preview inside something that's already a link or a button. */
readonly as?: 'button' | 'span'
}
const b = 'rost-toggle'
/**
* A button that stays on or off. On its own it's `aria-pressed`. In a group
* of options, pass `role="radio"` and `aria-checked` instead. Built from
* contracts/toggle.contract.yaml.
*/
export function Toggle({ selected = false, icon, children, className, as = 'button', disabled, type = 'button', ...rest }: ToggleProps) {
const labelled = children !== undefined && children !== null && children !== false
const radio = rest.role === 'radio'
const Root = as
const own = as === 'button' ? { type, disabled } : { 'aria-disabled': disabled || undefined }
return (
<Root
{...own}
className={[b, selected && `${b}--selected`, labelled && `${b}--labelled`, className].filter(Boolean).join(' ')}
{...(radio ? { 'aria-checked': selected } : { 'aria-pressed': selected })}
{...(rest as object)}
>
<span className={`${b}__background`} />
{(icon || !labelled) && <span className={`${b}__icons-arrow-left`} aria-hidden="true">{icon ?? <span className={`${b}__glyph`} />}</span>}
{labelled && <span className={`${b}__label`}>{children}</span>}
<span className={`${b}__focus-ring`} />
</Root>
)
}When to use
- Switching a view or a filter on and off, when the change happens straight away.
- Used to pick one option from a short set, as a group: a year switcher, a view switcher.
When not to use
- Doing something. That's IconButton. A toggle remembers, a button doesn't.
- A settings-style switch with a label. There isn't one in the kit yet, so ask for one rather than stretching Toggle.
Behaviour
It changes with selected and state. Off, it's a plate with 12px corners on the secondary surface, faded down. On, it becomes a fully round primary pill. The change of shape is part of the signal, not just the colour.
Accessibility
- Alone:
<button aria-pressed="true|false">. - Group:
role="radio"witharia-checked, inside arole="radiogroup"that has a name. Arrow keys move between options, Tab gets you in and out. - Name:
aria-labelwhen it's icon only. - Focus:
focus-ring(2px,actions/primary/focus/focus-ringwhen it's on), on:focus-visible. - Colour: on and off show in the shape (12px corners to fully round) as well as the colour.
Content
An icon, or a short text label: a year, a number, one word. The label makes the toggle grow wider than 40px, and it never wraps. Figma only draws the icon-only one.
Props
| Prop | Type | Values | Default |
|---|---|---|---|
| selected | boolean | false | |
| label | slot | ||
| labelled | boolean | false |
States: Default, Hover, Pressed, Focus, Disabled.
Where it's used
- akount.ing
src/rost/toggle/Toggle.tsx.rost-toggle
Where code differs on purpose
- akount.ing:Each toggle is a `role="radio"` inside a `ToggleGroup`. The group has no surface of its own.There they're one choice from a set. The kit has no ToggleGroup, so akount.ing made one.
Values
Straight from the contract. Each value sits next to its token, because six tokens here all come out as #3a3a3a and the colour alone can't tell you which one is right.
Never changes
| Part | Property | Value | Token |
|---|---|---|---|
| root | justify-content | center | layout |
| root | align-items | center | layout |
| root | display | inline-flex | layout |
| root | vertical-align | middle | layout |
| root | position | relative | layout |
| root | background | none | Figma frame size |
| root | border | none | Figma frame size |
| root | cursor | pointer | Figma frame size |
| root | outline | none | Figma frame size |
| root | gap | 8px | spacing/gap/small |
| root | padding-left | 20px | spacing/gap/medium-large |
| root | padding-right | 20px | spacing/gap/medium-large |
| root | padding-top | 0px | spacing/gap/none |
| root | padding-bottom | 0px | spacing/gap/none |
| root | width | 40px | spacing/size/medium |
| root | height | 40px | spacing/size/medium |
| background | position | absolute | layout |
| background | inset | 0 | layout |
| background | pointer-events | none | Figma frame size |
| icons-arrow-left | flex-shrink | 0 | layout |
| icons-arrow-left | position | relative | layout |
| icons-arrow-left | overflow | clip | layout |
| icons-arrow-left | width | 20px | spacing/size/xsmall |
| icons-arrow-left | height | 20px | spacing/size/xsmall |
| label | position | relative | layout |
| label | white-space | nowrap | layout |
| label | font-family | 'General Sans Variable' | font-family/base |
| label | font-weight | 500 | typography/body/font-weight/medium |
| label | line-height | 22px | typography/body/line-height |
| label | font-size | 16px | typography/body/font-size |
| label | letter-spacing | 0px | typography/body/letter-spacing |
| label | font-variant-numeric | tabular-nums | Figma frame size |
| glyph | display | block | layout |
| glyph | width | 100% | layout |
| glyph | height | 100% | layout |
| glyph | background | currentColor | no token yet |
| glyph | mask | url(./icon-arrow-left.svg) center / contain no-repeat | on purpose: The icon file itself, `assets/icons/arrow-left.svg`, the same shape as Figma's `Union` layer. |
| focus-ring | display | none | layout |
| focus-ring | position | absolute | layout |
| focus-ring | inset | calc(-1 * var(--spacing-gap-2xsmall)) | layout |
| focus-ring | border-style | solid | no token yet |
| focus-ring | border-width | 2px | spacing/outline/medium |
| focus-ring | pointer-events | none | Figma frame size |
Changes by variant
background.background5
| state=Default, selected=False | #3a3a3a | actions/secondary/regular/default/background |
| state=Hover, selected=False | #747573 | actions/secondary/regular/hover/background |
| state=Pressed, selected=False | #999 | actions/secondary/regular/pressed/background |
| state=Disabled, selected=False | #3a3a3a | actions/secondary/regular/disabled/background |
| state=Focus, selected=False | #3a3a3a | actions/secondary/regular/focus/background |
background.border-color5
| state=Default, selected=False | #ededed | actions/secondary/regular/default/outline |
| state=Hover, selected=False | #ededed | actions/secondary/regular/hover/outline |
| state=Pressed, selected=False | #ededed | actions/secondary/regular/pressed/outline |
| state=Disabled, selected=False | #ededed | actions/secondary/regular/disabled/outline |
| state=Focus, selected=False | #ededed | actions/secondary/regular/focus/outline |
background.border-radius5
| state=Default, selected=False | 12px | spacing/radius/medium |
| state=Hover, selected=False | 12px | spacing/radius/medium |
| state=Pressed, selected=False | 12px | spacing/radius/medium |
| state=Disabled, selected=False | 12px | spacing/radius/medium |
| state=Focus, selected=False | 12px | spacing/radius/medium |
background.border-sides5
| state=Default, selected=False | left top | layout |
| state=Hover, selected=False | left top | layout |
| state=Pressed, selected=False | left top | layout |
| state=Disabled, selected=False | left top | layout |
| state=Focus, selected=False | left top | layout |
background.opacity5
| state=Default, selected=False | 0.24 | opacity/gentle |
| state=Hover, selected=False | 0.32 | opacity/mild |
| state=Pressed, selected=False | 0.32 | opacity/mild |
| state=Disabled, selected=False | 0.32 | opacity/mild |
| state=Focus, selected=False | 0.24 | opacity/gentle |
focus-ring.border-color2
| state=Focus, selected=True | #b6ffda | actions/primary/focus/focus-ring |
| state=Focus, selected=False | #999 | actions/secondary/regular/focus/focus-ring |
focus-ring.border-radius2
| state=Focus, selected=True | 10000px | spacing/radius/full |
| state=Focus, selected=False | 16px | spacing/radius/large |
focus-ring.display2
| state=Focus, selected=True | block | layout |
| state=Focus, selected=False | block | layout |
icons-arrow-left.color10
| state=Default, selected=True | #3a3a3a | actions/primary/default/icon |
| state=Default, selected=False | #00d669 | actions/secondary/regular/default/icon |
| state=Hover, selected=True | #3a3a3a | actions/primary/hover/icon |
| state=Hover, selected=False | #00b450 | actions/secondary/regular/hover/icon |
| state=Pressed, selected=True | #3a3a3a | actions/primary/pressed/icon |
| state=Pressed, selected=False | #00d669 | actions/secondary/regular/pressed/icon |
| state=Disabled, selected=True | #3a3a3a | actions/primary/disabled/icon |
| state=Disabled, selected=False | #00d669 | actions/secondary/regular/disabled/icon |
| state=Focus, selected=True | #3a3a3a | actions/primary/focus/icon |
| state=Focus, selected=False | #00d669 | actions/secondary/regular/focus/icon |
label.color10
| state=Default, selected=True | #060805 | actions/primary/default/label |
| state=Default, selected=False | #d3d9d5 | actions/secondary/regular/default/label |
| state=Hover, selected=True | #060805 | actions/primary/hover/label |
| state=Hover, selected=False | #ededed | actions/secondary/regular/hover/label |
| state=Pressed, selected=True | #060805 | actions/primary/pressed/label |
| state=Pressed, selected=False | #d3d9d5 | actions/secondary/regular/pressed/label |
| state=Disabled, selected=True | #060805 | actions/primary/disabled/label |
| state=Disabled, selected=False | #d3d9d5 | actions/secondary/regular/disabled/label |
| state=Focus, selected=True | #060805 | actions/primary/focus/label |
| state=Focus, selected=False | #d3d9d5 | actions/secondary/regular/focus/label |
root.background5
| state=Default, selected=True | #00d669 | actions/primary/default/background |
| state=Hover, selected=True | #00b450 | actions/primary/hover/background |
| state=Pressed, selected=True | #00913a | actions/primary/pressed/background |
| state=Disabled, selected=True | #00d669 | actions/primary/disabled/background |
| state=Focus, selected=True | #00d669 | actions/primary/focus/background |
root.border-radius10
| state=Default, selected=True | 10000px | spacing/radius/full |
| state=Default, selected=False | 12px | spacing/radius/medium |
| state=Hover, selected=True | 10000px | spacing/radius/full |
| state=Hover, selected=False | 12px | spacing/radius/medium |
| state=Pressed, selected=True | 10000px | spacing/radius/full |
| state=Pressed, selected=False | 12px | spacing/radius/medium |
| state=Disabled, selected=True | 10000px | spacing/radius/full |
| state=Disabled, selected=False | 12px | spacing/radius/medium |
| state=Focus, selected=True | 10000px | spacing/radius/full |
| state=Focus, selected=False | 12px | spacing/radius/medium |
root.box-shadow5
| state=Default, selected=True | inset var(--spacing-outline-small) var(--spacing-outline-small) 0 0 var(--actions-primary-default-outline) | on purpose: Figma's 1px left and top stroke, drawn inside the shape as Figma draws it. The width and colour are tokens. |
| state=Hover, selected=True | inset var(--spacing-outline-small) var(--spacing-outline-small) 0 0 var(--actions-primary-hover-outline) | on purpose: Figma's 1px left and top stroke, drawn inside the shape as Figma draws it. The width and colour are tokens. |
| state=Pressed, selected=True | inset var(--spacing-outline-small) var(--spacing-outline-small) 0 0 var(--actions-primary-pressed-outline) | on purpose: Figma's 1px left and top stroke, drawn inside the shape as Figma draws it. The width and colour are tokens. |
| state=Disabled, selected=True | inset var(--spacing-outline-small) var(--spacing-outline-small) 0 0 var(--actions-primary-disabled-outline) | on purpose: Figma's 1px left and top stroke, drawn inside the shape as Figma draws it. The width and colour are tokens. |
| state=Focus, selected=True | inset var(--spacing-outline-small) var(--spacing-outline-small) 0 0 var(--actions-primary-focus-outline) | on purpose: Figma's 1px left and top stroke, drawn inside the shape as Figma draws it. The width and colour are tokens. |
root.cursor2
| state=Disabled, selected=True | default | Figma frame size |
| state=Disabled, selected=False | default | Figma frame size |
root.min-width1
| labelled=True | 40px | spacing/size/medium |
root.opacity2
| state=Disabled, selected=True | 0.24 | opacity/gentle |
| state=Disabled, selected=False | 0.24 | opacity/gentle |
root.width1
| labelled=True | auto | no token yet |