BetaForms

Switch

A setting that is on or off, and takes effect the moment it is flipped.

Overview

A setting that is on or off, and takes effect the moment it is flipped.

First example

<Switch isSelected={isOn} onSelectedChange={setIsOn}>
Mode sombre
</Switch>
<Switch defaultSelected onSelectedChange={save}>
Synchroniser en Wi-Fi seulement
</Switch>

Anatomy

<Switch>
  <Switch.Track>
    <Switch.Thumb />
  </Switch.Track>
  <Switch.Label />
</Switch>
  • Switch — the root, and it is the row, not the track. It is the pressable: it owns the state, resolves the recipe once (R5) and publishes it to its slots.
  • Switch.Track — the bar the knob slides on, and the surface that says on or off.
  • Switch.Thumb — the knob. The only thing on this component that moves.
  • Switch.Label — what the setting is.

The root is the row so that tapping the label flips the switch — the Checkbox's shape, for the Checkbox's reason.

R3 — and the track comes with the auto-wrap:

<Switch>Mode sombre</Switch>
// is exactly
<Switch>
  <Switch.Track>
    <Switch.Thumb />
  </Switch.Track>
  <Switch.Label>Mode sombre</Switch.Label>
</Switch>

With no children at all it is the track alone — the form a settings list wants, where the label is the row's own text.

Usage

Controlled, or not

<Switch isSelected={isOn} onSelectedChange={setIsOn}>
  Mode sombre
</Switch>

<Switch defaultSelected onSelectedChange={save}>
  Synchroniser en Wi-Fi seulement
</Switch>

onPress still fires, composed with the flip.

The two shapes

<Switch variant="primary">Le pouce à l'intérieur</Switch>
<Switch variant="secondary">Le pouce par-dessus</Switch>

variant is a geometry axis on this component, which is unusual and deliberate — both shapes are the accent when they are on, and what changes is where the knob sits:

  • primary (the default) rides the thumb inside the track, clear of its edges by the track's own inset. The shape everything else in this library shares: a filled surface with something on it.
  • secondary makes the track a thin bar and stands the thumb over it, overhanging above and below. Less ink, and the knob reads as the thing you drag.

They are the legacy component's inside and overlap, under the library's own two names — the same two shapes, the same measurements, and one vocabulary across the whole v1 API instead of a third pair of words for this component alone.

A glyph on the knob

<Switch.Track>
  <Switch.Thumb>
    <Icon as={CheckIcon} size={12} color={theme.colors.accent} />
  </Switch.Thumb>
</Switch.Track>

The knob's children travel with it. Anything else you put in the track stays where you put it — a glyph at each end, for instance, which is what the reference implementation's StartContent and EndContent are; here they are two Views you position, because the track is a node you were given rather than one that was hidden from you.

Disabled

<Switch isDisabled defaultSelected>
  Géré par votre organisation
</Switch>

On the row, not the track: what is disabled is the control, and the label is part of it.

As another element

<Switch asChild isSelected={isOn} onSelectedChange={setIsOn}>
  <Animated.View layout={LinearTransition}>…</Animated.View>
</Switch>

R12 — the caller's element is the row, so it takes the children it was written with and the auto-wrap does not apply.

Root props

SwitchProps

The node's inherited React Native props apply too, and so do its style props padding, margin, width and the rest.

PropTypeRequiredDescription
variantSwitchVariant | undefinedNo
sizeSwitchSize | undefinedNoThe track, the thumb, the gap and the label's type.
radiusRadiusKey | undefinedNoOverrides the track's corner, which is `full`. The thumb stays round.
colorstring | undefinedNoA raw tint (`'#7c3aed'`), never a token (R7): the colour the track takes once the switch is on, with a thumb derived to read against it. The track at rest keeps the neutral it had — a switch that is off is off in every brand.
isSelectedboolean | undefinedNoControlled. Leave it out and the switch keeps its own state.
defaultSelectedboolean | undefinedNoThe starting value when uncontrolled.
onSelectedChange((isSelected: boolean) => void) | undefinedNoFired with the new value on every flip, controlled or not.
isDisabledboolean | undefinedNoDims the row and stops the press.
animationAnimationProp | undefinedNo
styleStyleProp<ViewStyle> | ((state: PressableStateCallbackType) => StyleProp<ViewStyle>)NoR9 — `Pressable`'s function form as much as an object or an array.
childrenReactNodeNo

Slots

SwitchLabelProps

The node's inherited React Native props apply too, and so do its style props padding, margin, width and the rest.

PropTypeRequiredDescription
childrenReactNodeNo

SwitchThumbProps

The node's inherited React Native props apply too, and so do its style props padding, margin, width and the rest.

PropTypeRequiredDescription
childrenReactNodeNoA glyph on the knob — a check, a moon. It travels with it.
animationboolean | undefinedNo`false` puts the thumb at its end of the track with no slide.
styleStyleProp<ViewStyle>No

SwitchTrackProps

The node's inherited React Native props apply too, and so do its style props padding, margin, width and the rest.

PropTypeRequiredDescription
childrenReactNodeNoThe thumb, and anything else the caller lays over the track.
animationboolean | undefinedNo`false` swaps the colour at the flip instead of crossing to it.
styleStyleProp<ViewStyle>No

Variants, sizes and colour

Sizes

primary — the knob inside the track:

sizeTrackKnobInsetTravelLabel
sm44 × 262031814/20
md48 × 282232016/24
lg56 × 322632418/28

secondary — the knob over the bar:

sizeTrackKnobInsetTravelLabel
sm44 × 182402014/20
md48 × 182602216/24
lg56 × 203002618/28

md is the legacy switch measured — a 48 × 28 track with a 22 knob — and the reference implementation's is the same 48 wide.

The width is part of the control, unlike everywhere else in the library, where size never touches it: a switch is a fixed shape, and a track that stretched with its parent would be a progress bar.

The travel is not a number anyone writes: it is width − knob − 2 × inset, arithmetic the root does on the flattened style, because a slide happens in a worklet and a worklet needs a number rather than a style to flatten every frame.

radius is the track's corner, full by default. The knob stays round — a squared track with a round knob is a real design; the reverse is not.

Colour

<Switch color="#7c3aed">Teinté</Switch>

A raw tint (R7), and here it is the colour the switch turns on to, with a knob derived to read against it. The track at rest keeps its neutral: a switch that is off is off in every brand.

It reaches there through the bgSelected / fgSelected roles, which the tint pass repaints — the same mechanism as the Checkbox's, written up once in checkbox.md.

The knob at rest is the theme's white, one of the two places in the library where a primitive is named on purpose: a switch's knob is white in both modes, the way the platform's own is, because it has to read against the neutral track and against the accent one.

Accessibility

  • accessibilityRole="switch" on the root, overridable, with accessibilityState.checked following the value and .disabled following isDisabled. A caller's own accessibilityState is merged, not spread over.
  • The label is inside the control, so the switch announces itself with its text. A switch with no label needs an accessibilityLabel.
  • The whole row is the touch target.
  • The label does not change with the state. "Mode sombre" is the setting whether it is on or off; a label reading "Activé" would say what the track and the role already say, twice.

Migration from legacy

Legacyv1
label="…"<Switch>…</Switch> — the text child is the label
labelAlignment="left"write <Switch.Label> before <Switch.Track>
labelAlignment="justify-*"justifyContent="space-between" plus a width, in style props
variant="inside"variant="primary" — the same shape, the library's name
variant="overlap"variant="secondary" — likewise
isSelected / onValueChangeisSelected / onSelectedChange, plus defaultSelected
themeColor="primary"color={theme.colors.accent} — a raw value (R7)
size="sm" | "md" | "lg"size — the same three
radiusradius — a RadiusKey now, still full by default
fullWidthwidth="100%" in style props (R14)
isDisabledunchanged, on the root
labelStylestyle on <Switch.Label>
stylestyle on the root

Implementation notes

A switch is not a checkbox

They look like the same control with two skins and they are not:

CheckboxSwitch
When it actsLater, when the form is submittedImmediately
What it saysAn intentionA state of the system
isInvalidYes — the value can be wrongNo
IndeterminateYes — a "select all" needs itNo

There is no isInvalid here because a setting that has already taken effect has no later moment at which it can be wrong. A setting that cannot be turned on is isDisabled, with the reason written beside it.

Animation

The track's colour is crossed, not swappedinterpolateColor between the two ends — and the knob slides on the same 175ms, so a flip reads as one movement rather than as a repaint under a jump. The number lives in switch.style.ts and neither slot owns it, which is what keeps them arriving together.

animation={false} on Switch.Track or Switch.Thumb puts that one at its end state with no worklet mounted at all. animation on the root is the library's usual press knob — false, 'disabled', 'disable-all', or the object — and belongs to PressableFeedback.

The knob moves with translateX, not with an edge. R13 bans a directional inset, and a transform is not one — but a transform does not mirror under RTL either, so the sign is flipped by hand against I18nManager.isRTL. It is the one place in the library that reads it, and it reads it for a movement rather than for a layout.

Alignment with the reference implementation

Measured against their switch.tsx and switch.css, and against the legacy component this one replaces.

Identical to the reference implementation: the 48pt track at md, the knob's field shadow, the accent track when on with a contrasting knob, the 175ms crossfade, and a default knob so nothing has to be written for the common case.

Four deltas:

TheirsOursWhy
The root is the track; no labelThe root is the row, Label is a slotThe Checkbox's reason, and the legacy component had a label too.
A 28 × 20 pill knobA round knob, 22 at mdThe legacy switch's, and the platform's. A pill knob in a round track is a shape neither of our two variants is.
No shape variantsprimary / secondaryThe legacy component shipped both, and dropping one at the migration would be a redesign rather than a port.
StartContent / EndContentChildren of the trackThe track is a slot here, so a glyph at either end is a View you place — one API instead of two named positions.

Extending it

useSwitch() is exported (R10) and carries the resolved styles, the two colour pairs, the travel and the state — enough to write a glyph that changes at each end of the track, or a second label:

import { useSwitch } from '@xaui/native/switch'

function SwitchState() {
  const { isSelected, labelStyle } = useSwitch()
  return <Text style={labelStyle}>{isSelected ? 'Activé' : 'Désactivé'}</Text>
}

Outside a <Switch> it throws by name.