Skip to content

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.

switch/basic.tsx

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.

switch/sizes.tsx

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.

switch/disabled.tsx

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.

switch/settings-list.tsx

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

Keyboard interactions
KeyBehaviour
TabMoves focus to the switch. The root element is the focus target; the hidden checkbox is parked at tabindex -1 and never receives focus.
SpaceToggles the switch.
EnterToggles 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.

Theming tokens for Switch
PropertyControlsDefault
--forte-switch-track-widthTrack 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-heightTrack height. size retunes it: 1rem for sm, 1.5rem for lg.1.25rem
--forte-switch-thumb-sizeThumb width and height. size retunes it: 0.625rem for sm, 1.125rem for lg.0.875rem
--forte-switch-paddingInset between the track and the thumb.0.125rem
--forte-switch-border-widthTrack border width.1px
--forte-switch-radiusTrack corner radius.var(--forte-radius-pill)
--forte-switch-track-bgTrack fill. Swapped to var(--forte-color-primary) when checked.var(--forte-color-panel)
--forte-switch-track-bg-hoverTrack fill on hover, on pointer devices only. Swapped to var(--forte-color-primary-hover) when checked.var(--forte-color-panel-hover)
--forte-switch-track-borderTrack 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-bgThumb fill. Swapped to var(--forte-color-on-primary) when checked.var(--forte-color-foreground-subtle)
--forte-switch-thumb-fgCheckmark colour. Swapped to var(--forte-color-primary) when checked.var(--forte-color-panel)
--forte-switch-thumb-shadowThumb shadow.var(--forte-shadow-1)
--forte-switch-travelDistance 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.

Props for Switch
PropTypeDefaultDescription
classNamestringAdditional class name(s) for the track. Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
idstringThe 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.
nativeButtonbooleanfalseWhether 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.
sizeSwitchSizemdSize of the switch. Track, thumb and travel distance all scale together, so the thumb always lands flush against the end of the track.
stateIconbooleantrueDraw 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.
thumbClassNamestringAdditional class name(s) for the thumb, for the same reason.