Switch
Use a switch for a setting that takes effect the moment it is flipped — notification preferences, feature toggles, anything with no Save button. If the value is only meaningful once a form is submitted, or the user is picking several things from a list, use a checkbox instead. A switch still submits with a form when you give it a name; it renders a hidden checkbox input beside the visible control.
Import
import { Switch } from "@forte-ui/react";Examples
Sizes
size scales the track, the thumb and the distance the thumb travels together, so the thumb always lands flush against the end of the track. The painted track is 16px tall at sm, 20px at md and 24px at lg; the component grows the hit area to a 24×24px minimum without changing the painted box, so even sm meets SC 2.5.8.
Disabled
disabled blocks interaction and drops the whole control to 55% opacity. Both states stay distinguishable while disabled — put the reason in visible text next to the switch, because a disabled switch is skipped by Tab and cannot show a tooltip to a keyboard user. Set it on the Field.Root rather than on the Switch, as the demo does: it takes precedence over the control's own prop and dims the label with it, so that reason does not stay at full contrast beside a greyed-out control.
Settings list
A list of settings usually needs a description under each label, which is more text than belongs inside an accessible name. Field is what this is for: Field.Description is registered into the control's aria-describedby, and Field.Label sits beside the switch rather than around it. There is no nativeButton here and no htmlFor to write — the label points at the switch's hidden <input>, which is a labelable element, so clicking the setting's name toggles it.
The one thing Field cannot guess is the layout. Field.Root is a column; the row here is three declarations of the demo's own.
New features and changes, at most once a month.
Sign-ins from new devices, and password changes.
A Monday summary of your team's activity.
Accessibility
| Key | Behaviour |
|---|---|
| Tab | Moves focus to the switch. The root element is the focus target; the hidden checkbox is parked at tabindex -1 and never receives focus. |
| Space | Toggles the switch. |
| Enter | Toggles the switch. A native checkbox ignores Enter — Base UI's button emulation handles it here, and with nativeButton the browser does. |
Base UI renders the root with role="switch" and aria-checked, so the state reaches assistive technology as text regardless of what the pixels do. The checkmark inside the thumb is aria-hidden — it is a visual cue only, and announcing it would duplicate aria-checked.
readOnly is not the same as disabled: a read-only switch stays focusable and still reads its
value out, it just cannot be flipped. Use it for a value the user is allowed to inspect but not
change. disabled takes the control out of the tab order instead — as aria-disabled plus
tabindex="-1" on the default span, or the native disabled attribute when you set nativeButton.
The unchecked track border and thumb are drawn with --forte-color-foreground-subtle, measured at
3.73:1 against the page — above the 3:1 that SC 1.4.11 asks of non-text controls. In forced-colours
mode the two states are separated on three axes at once (track fill, track border, and the thumb's
own fill plus its checkmark), all from system keywords, because a <span role="switch"> gets no
automatic system colours from the UA. The disabled state overrides its own opacity there and uses
GrayText instead, since forced colours does not touch opacity and a 55% control would otherwise
keep full system contrast and read as enabled.
Motion under prefers-reduced-motion
The thumb still moves when reduced motion is requested, and that is deliberate. Everywhere else in
this library a positional animation is decoration and collapses to zero distance under the motion
tokens. Here the thumb's position is the state — a switch whose thumb never moves is a switch
with no visible on/off difference except colour. So the travel is never multiplied by
--forte-motion-ok. What changes is the feel: the transition rides --forte-duration-spring-snappy,
which drops from 400ms to 120ms, flattening the spring's overshoot into what reads as an instant
jump rather than a glide.
Movement is never the only signal either. The track fill, the thumb fill and the checkmark all
change with the state, and each identifies it on its own — which is what makes it safe to shorten
the animation without losing information. The checkmark's opacity in particular is not gated on any
motion token, so it survives intact when the pop that accompanies it has collapsed. Setting
stateIcon={false} removes that cue and leaves colour as the only non-positional one; the state
still reaches assistive technology, but sighted users lose a fallback. Use the Reduced motion
control under any demo above to see the difference.
Theming
Every property below is declared on the switch's own root element. That means setting one on an
ancestor has no effect — the element's own declaration wins over an inherited value. Override them
on the switch itself, through the className prop (an unlayered rule beats the library's
@layer forte.components, whatever its specificity) or an inline style.
| Property | Controls | Default |
|---|---|---|
--forte-switch-track-width | Track width, and the input the travel distance is derived from. size retunes it: 1.75rem for sm, 2.75rem for lg. | 2.25rem |
--forte-switch-track-height | Track height. size retunes it: 1rem for sm, 1.5rem for lg. | 1.25rem |
--forte-switch-thumb-size | Thumb width and height. size retunes it: 0.625rem for sm, 1.125rem for lg. | 0.875rem |
--forte-switch-padding | Inset between the track and the thumb. | 0.125rem |
--forte-switch-border-width | Track border width. | 1px |
--forte-switch-radius | Track corner radius. | var(--forte-radius-pill) |
--forte-switch-track-bg | Track fill. Swapped to var(--forte-color-primary) when checked. | var(--forte-color-panel) |
--forte-switch-track-bg-hover | Track fill on hover, on pointer devices only. Swapped to var(--forte-color-primary-hover) when checked. | var(--forte-color-panel-hover) |
--forte-switch-track-border | Track border colour. var(--forte-color-primary) when checked; var(--forte-color-danger-border) when invalid inside a field. | var(--forte-color-foreground-subtle) |
--forte-switch-thumb-bg | Thumb fill. Swapped to var(--forte-color-on-primary) when checked. | var(--forte-color-foreground-subtle) |
--forte-switch-thumb-fg | Checkmark colour. Swapped to var(--forte-color-primary) when checked. | var(--forte-color-panel) |
--forte-switch-thumb-shadow | Thumb shadow. | var(--forte-shadow-1) |
--forte-switch-travel | Distance the thumb moves when checked. | calc( var(--forte-switch-track-width) - var(--forte-switch-thumb-size) - 2 * var(--forte-switch-padding) - 2 * var(--forte-switch-border-width) ) |
--forte-switch-travel is derived from the four size properties above it, so resizing the track is
enough to keep the thumb landing flush against the far end — you rarely want to set it by hand.
API reference
The table lists the props Switch adds. Everything else on Base UI's Switch.Root passes straight
through: checked, defaultChecked, onCheckedChange, name, value, uncheckedValue,
disabled, readOnly, required, form, inputRef, render and style. The component renders
Switch.Root and Switch.Thumb for you, so there are no sub-parts to compose.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s) for the track. Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
id | string | The id of the hidden `<input>` element — or of the root element when `nativeButton` is `true`. Point a sibling `<label htmlFor>` at whichever of the two your `nativeButton` setting produces. | |
nativeButton | boolean | false | Whether the rendered element is a native `<button>`. Base UI defaults this to `false` here — the opposite of trigger-style parts — because the root renders a `<span>` so that an enclosing `<label>` stays valid HTML. Set it (together with `render={<button />}`) for the sibling-label `htmlFor`/`id` pattern. Note that it also moves `id`: with `nativeButton` the `id` lands on the root element, otherwise on the hidden input. |
size | SwitchSize | md | Size of the switch. Track, thumb and travel distance all scale together, so the thumb always lands flush against the end of the track. |
stateIcon | boolean | true | Draw a checkmark inside the thumb while the switch is on. This is the switch's non-positional state cue, and it is why the default is `true`: a user who cannot perceive the thumb sliding still gets a glyph appearing. Turning it off leaves colour as the only cue that does not depend on noticing movement. |
thumbClassName | string | Additional class name(s) for the thumb, for the same reason. |