Toggle
Use a toggle when the button is the state — bold, mute, pin, favorite — and
the effect is immediate and local. It is a <button> with aria-pressed, not a
form control: it has no label of its own and submits nothing.
If the state is a setting the user confirms later, you want
Switch or Checkbox instead —
both carry a visible label and a form value, and a toggle button has neither. If
the buttons are alternatives rather than independent states, reach for
ToggleGroup.
Import
import { Toggle } from "@forte-ui/react";Examples
Variants
variant decides how loud the pressed state is. Unpressed is quiet in every
variant — that is what makes a toggle read as off at a glance — so the variant
only describes what "on" looks like: solid fills with the tone, soft tints
with it, outline tints and keeps a border that is drawn while unpressed too.
Tones
tone picks the semantic color set the pressed state draws from, and combines
freely with variant. It only reaches the pressed state: an unpressed toggle
looks the same in all four tones.
Sizes
The same three steps as Button — 1.75rem, 2.25rem, 2.75rem — so a toggle
dropped into a row of buttons lines up rather than sitting a pixel proud. All
three follow the ambient data-forte-density setting. Left unset, size comes
from the enclosing ToggleGroup, and failing that from a
Toolbar.Root around it, so a toggle in a sm bar is
sm without being told.
Icon only
iconOnly squares the button and keeps it on the control height: the space
around the icon, --forte-toggle-icon-padding, is derived from the height and
--forte-toggle-icon-size, so a sm toggle is 28px beside a 28px button rather
than growing past it, and the square is still pinned to at least 24px so the
hit target clears WCAG SC 2.5.8. A direct svg child is sized from that same
knob — 14px, 16px, 18px by size — the way Button
sizes one, so an icon set's own default does not decide the square. An icon
bigger than the knob says grows the button instead of crowding the corners.
Swapping the icon with the state
The render callback receives the toggle's state, which is what lets the
icon itself change rather than only its color. This is Base UI's own example,
styled with our tokens.
Two different shapes — outline and filled — mean the state survives greyscale, a monochrome theme and forced colors, none of which a hue swap would. Reach for the callback form whenever a toggle's meaning is carried by an icon.
Controlled
Pass pressed and onPressedChange for a controlled toggle; defaultPressed
for an uncontrolled one. The callback receives the new state first and Base UI's
event details second — call details.cancel() on it to veto the change.
Disabled
disabled blocks pointer and keyboard interaction and sets aria-disabled,
keeping whichever state the toggle is in. On a ToggleGroup it reaches every
toggle inside.
Accessibility
| Key | Behavior |
|---|---|
| Tab | Moves focus to the toggle. Inside a ToggleGroup the whole group is one tab stop — see that page. |
| Space then Enter | Flips the pressed state. Both keys work, as on any button. |
The root renders a native <button> carrying aria-pressed, so assistive
technology announces it as a toggle button and reads the state out with the
name. That is the whole reason to prefer this over a <button> whose label
changes: the name stays stable and only the state moves.
Under forced colors a pressed toggle paints Highlight with HighlightText,
an unpressed one Canvas with CanvasText, and a disabled one GrayText on
Canvas whichever state it holds. This needs a rule of its own in the component
rather than the library-wide one in patterns.css: that rule covers
data-checked, aria-checked and aria-selected, and a toggle button is none
of those — it is aria-pressed. Without it, on and off would look identical in
high contrast.
Motion. The only geometry is the press squash, --forte-scale-press, which
collapses to exactly 1 under prefers-reduced-motion: reduce on its own — so
there is no media query in the component. Press runs on
--forte-duration-instant and the release on --forte-ease-spring-snappy: snap in,
spring out. There is deliberately no hover lift or hover scale slot, unlike
Button. A toggle is usually one of several in a strip, and a single item
growing under the pointer breaks the shared edge that makes the strip read as
one control.
Theming
Every visual decision is a custom property declared on the toggle root. Set them
on the toggle itself — through className or an inline style — rather than on
an ancestor: the component declares its own defaults on the root element, and an
element's own declaration beats an inherited value.
| Property | Controls | Default |
|---|---|---|
--forte-toggle-radius | Corner radius. | var(--forte-radius-control) |
--forte-toggle-height | Height. size re-points it. | var(--forte-control-h-md) |
--forte-toggle-padding-x | Inline padding. iconOnly replaces it with --forte-toggle-icon-padding on all four sides. | var(--forte-control-px-md) |
--forte-toggle-font-size | Label size. | var(--forte-font-size-2) |
--forte-toggle-font-weight | Label weight. | var(--forte-font-weight-medium) |
--forte-toggle-gap | Gap between an icon and its label. | var(--forte-control-gap) |
--forte-toggle-icon-size | Size of an svg a consumer drops in; reassigned by size. Direct children only, so a composed forte-ui component keeps sizing its own glyphs. | var(--forte-font-size-3) |
--forte-toggle-border-width | Border width, drawn in every variant — outline is the only one that gives it a color. | 1px |
--forte-toggle-duration | Color transitions between states. | var(--forte-duration-fast) |
--forte-toggle-press-scale | How far the button squashes while held. Multiplied by --forte-motion-ok, so a literal value still collapses under reduced motion. | var(--forte-scale-press) |
--forte-toggle-bg | Fill while unpressed. | transparent |
--forte-toggle-bg-hover | Fill on hover while unpressed. | var(--forte-color-panel-hover) |
--forte-toggle-fg | Label color while unpressed. | var(--forte-color-foreground-muted) |
--forte-toggle-fg-hover | Label color on hover while unpressed. | var(--forte-color-foreground) |
--forte-toggle-border-color | Border color while unpressed. var(--forte-color-border) for variant="outline". | transparent |
--forte-toggle-bg-pressed | Fill while pressed. Set by variant from the tone. | var(--forte-toggle-tone-soft-hover) |
--forte-toggle-bg-pressed-hover | Fill on hover while pressed. Set by variant from the tone. | var(--forte-toggle-tone-soft-active) |
--forte-toggle-fg-pressed | Label color while pressed. Set by variant from the tone. | var(--forte-toggle-tone-text) |
--forte-toggle-fg-pressed-hover | Label color on hover while pressed — the tone's high-contrast text, which is what lets the fill deepen a step without the label dropping below AA. solid holds its on-fill color instead. | var(--forte-toggle-tone-text-strong) |
--forte-toggle-border-color-pressed | Border color while pressed. The tone's border for variant="outline". | transparent |
--forte-toggle-icon-padding | Space between the icon and the toggle's edge (iconOnly only). Derived from the height and --forte-toggle-icon-size so the square lands on the control height; set a length to pad a larger icon instead. | max( 0px, calc( (var(--forte-toggle-height) - var(--forte-toggle-icon-size)) / 2 - var(--forte-toggle-border-width) ) ) |
tone reassigns the nine --forte-toggle-tone-* properties the variants read
from, so overriding one of those re-skins every variant at once, while
overriding a -pressed slot changes only the variant in play.
The -hover values only ever apply on devices that report hover support, so a
touch device never sticks in a hover color after a tap.
Hovering a pressed toggle deepens the label along with the fill — the tone's
-text-strong in place of its -text. That pairing is not decoration: the
resting fill already sits at step 4, so its hover moves to step 5, and the
tone's ordinary text tier does not clear AA there (4.17:1 at the worst seed in
the contrast sweep, against 8.33:1 for the high-contrast tier). solid keeps
its on-fill label, having no tint to deepen.
API reference
Toggle
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
iconOnly | boolean | false | Render as a square button sized for a single icon, holding the 24×24 minimum hit target from WCAG SC 2.5.8. Always pair with `aria-label` — an icon is not an accessible name. |
ref | Ref<HTMLButtonElement> | Ref to the button element. Declared as a prop rather than through `forwardRef` because the component is generic — a `forwardRef` wrapper would erase `Value` and with it the inference on `value`. | |
size | ToggleSize | md | Size of the button. Matches `Button` step for step so the two line up in one toolbar. Inherited from an enclosing `ToggleGroup` when left unset, and failing that from a `Toolbar.Root` around it — a toggle handed to `Toolbar.Button` through `render` needs no size of its own. |
tone | ToggleTone | primary | Which semantic color set the pressed state draws from. Combines freely with `variant`. Inherited from an enclosing `ToggleGroup` when left unset. |
value | string | A unique string identifying this toggle within a `ToggleGroup`. Required inside a group — the group's value is the list of pressed toggles' values, so a toggle without one can never appear in it. Ignored outside a group, where `pressed` / `defaultPressed` carry the state instead. | |
variant | ToggleVariant | soft | How loud the *pressed* state is. Unpressed is quiet in every variant — that is what makes a toggle read as off — so the variant only decides what "on" looks like: `solid` fills with the tone, `soft` tints with it, `outline` tints and keeps a border that is also drawn while unpressed. There is deliberately no `ghost`: every variant is already chromeless at rest, so a ghost toggle would differ from `soft` in nothing but its pressed fill — and dropping that fill would leave hue as the only cue separating on from off. Inherited from an enclosing `ToggleGroup` when left unset. |
Anything Base UI's Toggle accepts passes through as well — including
pressed, defaultPressed, onPressedChange, disabled, nativeButton and
render.