Switch

A control that lets the user toggle between checked and not checked.

import { Label } from "@/components/ui/label"
import { HStack } from "@/components/ui/stack"
import { Switch } from "@/components/ui/switch"

export default function SwitchDemo() {
  return (
    <HStack gap={2} align="center">
      <Switch id="airplane-mode" name="airplane-mode" />
      <Label htmlFor="airplane-mode">Airplane Mode</Label>
    </HStack>
  )
}

Installation

Install the following dependencies:

pnpm add @base-ui/react @stylexjs/stylex

Set up the token layer and the StyleX compiler, if you have not already. See Installation, and paste states.ts and state-styles.ts from the same page. The switch imports both. The examples caption their switches with a Label or a Field.

Paste field-state.ts from the same installation page.

Copy and paste the following code into your project.

components/ui/switch.tsx
"use client"

import { use, type CSSProperties } from "react"
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
import * as stylex from "@stylexjs/stylex"
import type { StyleXStyles } from "@stylexjs/stylex"

import { stateStyles } from "@/lib/state-styles"
import { FieldStateContext } from "@/lib/field-state"
import { states } from "@/theme/states"
import { color, duration, ease, radius, shadow, space } from "@/theme/tokens.stylex"

const switchTrackBg = `light-dark(${color.input}, color-mix(in oklab, ${color.input} 80%, transparent))`
const switchThumbBg = `light-dark(${color.background}, ${color.foreground})`
const switchThumbCheckedBg = `light-dark(${color.background}, ${color.primaryForeground})`

const styles = stylex.create({
  root: {
    position: "relative",
    display: "inline-flex",
    alignItems: "center",
    flexShrink: 0,
    borderRadius: radius.full,
    borderWidth: 1,
    borderStyle: "solid",
    backgroundColor: switchTrackBg,
    backgroundOrigin: "border-box",
    transitionProperty: "background-color, border-color, box-shadow",
    transitionDuration: duration.color,
    transitionTimingFunction: ease.standard,
    "::after": {
      content: '""',
      position: "absolute",
      insetInline: `calc(-1 * ${space.s3})`,
      insetBlock: `calc(-1 * ${space.s2})`,
    },
  },
  checked: {
    backgroundColor: color.primary,
    backgroundImage: `linear-gradient(to bottom, ${color.buttonPrimarySheen}, transparent)`,
  },
  thumb: {
    position: "relative",
    insetInlineStart: 0,
    width: "var(--carbon-switch-thumb)",
    height: "var(--carbon-switch-thumb)",
    borderRadius: radius.full,
    backgroundColor: switchThumbBg,
    backgroundImage: `linear-gradient(to bottom, transparent, ${color.thumbShade})`,
    boxShadow: shadow.thumb,
    pointerEvents: "none",
    transitionProperty: "inset-inline-start, background-color",
    transitionDuration: {
      default: duration.move,
      "@media (prefers-reduced-motion: reduce)": "0s",
    },
    transitionTimingFunction: ease.spring,
  },
  thumbChecked: {
    insetInlineStart: "calc(100% - var(--carbon-switch-thumb))",
    backgroundColor: switchThumbCheckedBg,
  },
})

const sizes = stylex.create({
  default: {
    width: space.s8,
    height: "1.125rem",
    "--carbon-switch-thumb": space.s4,
  },
  sm: {
    width: space.s6,
    height: space.s3_5,
    "--carbon-switch-thumb": space.s3,
  },
})

const switchStyles = {
  root: styles.root,
  sizes,
  checked: styles.checked,
  disabled: states.disabled,
  invalid: states.invalidRing,
  thumb: styles.thumb,
  thumbChecked: styles.thumbChecked,
}

type SwitchSize = keyof typeof sizes

type SwitchProps = Omit<SwitchPrimitive.Root.Props, "className" | "style"> & {
  size?: SwitchSize
  xstyle?: StyleXStyles
  style?: CSSProperties
  className?: string
}

function Switch({ size = "default", xstyle, style, className, ...props }: SwitchProps) {
  const fieldState = use(FieldStateContext)
  return (
    <SwitchPrimitive.Root
      data-slot="switch"
      data-size={size}
      {...props}
      {...stateStyles(
        (state) => [
          styles.root,
          states.focusRing,
          sizes[size],
          (state.valid === false ||
            fieldState?.valid === false ||
            props["aria-invalid"] === true ||
            props["aria-invalid"] === "true") &&
            states.invalidRing,
          state.checked && styles.checked,
          state.disabled && states.disabled,
          xstyle,
        ],
        { className, style },
      )}
    >
      <SwitchPrimitive.Thumb
        data-slot="switch-thumb"
        {...stateStyles((state) => [styles.thumb, state.checked && styles.thumbChecked])}
      />
    </SwitchPrimitive.Root>
  )
}

export { Switch, switchStyles, type SwitchSize }

Update the import paths to match your project setup.

Usage

import { Label } from "@/components/ui/label"
import { HStack } from "@/components/ui/stack"
import { Switch } from "@/components/ui/switch"
<HStack gap={2} align="center">
  <Switch id="airplane-mode" />
  <Label htmlFor="airplane-mode">Airplane Mode</Label>
</HStack>

Match the label's htmlFor to the switch's id, or wrap the switch in the label. A switch takes effect the moment it is flipped, so it needs no submit button beside it. Use a Checkbox for a value that is saved with a form.

The thumb travels on inset-inline-start, so it moves toward the inline end in a right-to-left document.

Checked state

Use defaultChecked for an uncontrolled switch, or checked and onCheckedChange to control it.

const [enabled, setEnabled] = useState(false)

return <Switch checked={enabled} onCheckedChange={setEnabled} />

Give the switch a name to submit it with a form. Base UI renders a hidden <input type="checkbox"> beside the control, and that input carries the value.

Description

Put the switch in a horizontal Field and a FieldContent beside it to add a line of helper text under the label.

Focus is shared across devices, and turns off when you leave the app.

import * as stylex from "@stylexjs/stylex"
import { Field, FieldContent, FieldDescription, FieldLabel } from "@/components/ui/field"
import { Switch } from "@/components/ui/switch"

const styles = stylex.create({
  field: { maxWidth: "24rem" },
})

export default function SwitchDescription() {
  return (
    <Field orientation="horizontal" xstyle={styles.field}>
      <FieldContent>
        <FieldLabel>Share across devices</FieldLabel>
        <FieldDescription>
          Focus is shared across devices, and turns off when you leave the app.
        </FieldDescription>
      </FieldContent>
      <Switch id="switch-focus-mode" />
    </Field>
  )
}

Choice card

Wrap a whole Field in a FieldLabel to turn the row into a card, and pass checked to style the cards that are on.

"use client"

import { useState } from "react"
import * as stylex from "@stylexjs/stylex"
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldGroup,
  FieldLabel,
  FieldTitle,
} from "@/components/ui/field"
import { Switch } from "@/components/ui/switch"

const styles = stylex.create({
  group: { maxWidth: "24rem" },
})

const settings = [
  {
    id: "share",
    title: "Share across devices",
    description: "Focus is shared across devices, and turns off when you leave the app.",
  },
  {
    id: "notifications",
    title: "Enable notifications",
    description: "Receive notifications when focus mode is enabled or disabled.",
  },
]

export default function SwitchChoiceCard() {
  const [enabled, setEnabled] = useState<Record<string, boolean>>({ notifications: true })

  return (
    <FieldGroup gap={3} xstyle={styles.group}>
      {settings.map((setting) => (
        <FieldLabel
          key={setting.id}
          htmlFor={`setting-${setting.id}`}
          checked={enabled[setting.id] ?? false}
        >
          <Field orientation="horizontal">
            <FieldContent>
              <FieldTitle>{setting.title}</FieldTitle>
              <FieldDescription>{setting.description}</FieldDescription>
            </FieldContent>
            <Switch
              id={`setting-${setting.id}`}
              checked={enabled[setting.id] ?? false}
              onCheckedChange={(checked) =>
                setEnabled((current) => ({ ...current, [setting.id]: checked }))
              }
            />
          </Field>
        </FieldLabel>
      ))}
    </FieldGroup>
  )
}

FieldLabel does not read the switch's state, so checked is a prop you set from the same value you give the switch. That makes the card a controlled switch.

Disabled

Set disabled on Field to disable its control and dim its label, or on a bare control directly.

import * as stylex from "@stylexjs/stylex"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Switch } from "@/components/ui/switch"

const styles = stylex.create({
  group: { width: "fit-content" },
  label: { fontWeight: 400 },
})

export default function SwitchDisabled() {
  return (
    <FieldGroup gap={3} xstyle={styles.group}>
      <Field orientation="horizontal" disabled>
        <Switch id="switch-disabled-off" />
        <FieldLabel xstyle={styles.label}>Off</FieldLabel>
      </Field>
      <Field orientation="horizontal" disabled>
        <Switch id="switch-disabled-on" defaultChecked />
        <FieldLabel xstyle={styles.label}>On</FieldLabel>
      </Field>
    </FieldGroup>
  )
}

Invalid

Set invalid on Field to style its control, or aria-invalid on a bare control. The examples show both.

import { Switch } from "@/components/ui/switch"
import { Field, FieldLabel, FieldError } from "@/components/ui/field"
import { Label } from "@/components/ui/label"
import { VStack } from "@/components/ui/stack"

export default function SwitchInvalid() {
  return (
    <VStack gap={5} width="100%" maxWidth="24rem">
      <VStack gap={2}>
        <Label htmlFor="switch-invalid-bare">Accept terms and conditions</Label>
        <Switch
          id="switch-invalid-bare"
          aria-invalid
          aria-describedby="switch-invalid-bare-error"
        />
        <FieldError id="switch-invalid-bare-error">
          You must accept the terms and conditions to continue.
        </FieldError>
      </VStack>
      <Field invalid>
        <FieldLabel>Accept terms and conditions</FieldLabel>
        <Switch />
        <FieldError>You must accept the terms and conditions to continue.</FieldError>
      </Field>
    </VStack>
  )
}

Size

Use the size prop to change the size of the switch. The thumb and the travel follow the track, so a sm switch needs no other change.

import * as stylex from "@stylexjs/stylex"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Switch } from "@/components/ui/switch"

const styles = stylex.create({
  group: { width: "fit-content" },
  label: { fontWeight: 400 },
})

export default function SwitchSize() {
  return (
    <FieldGroup gap={3} xstyle={styles.group}>
      <Field orientation="horizontal">
        <Switch id="switch-size-sm" size="sm" defaultChecked />
        <FieldLabel xstyle={styles.label}>Small</FieldLabel>
      </Field>
      <Field orientation="horizontal">
        <Switch id="switch-size-default" size="default" defaultChecked />
        <FieldLabel xstyle={styles.label}>Default</FieldLabel>
      </Field>
    </FieldGroup>
  )
}

API reference

Pass render to replace a part's element or compose it with another component.

Switch wraps the Base UI Switch primitive and accepts its props. See the Base UI reference.

Switch

PropTypeDefault
size"sm" | "default""default"
checkedboolean—
defaultCheckedbooleanfalse
onCheckedChange(checked, details) => void—
namestring—
valuestring—
disabledbooleanfalse
readOnlybooleanfalse
requiredbooleanfalse
aria-invalidboolean—
idstring—
inputRefRef<HTMLInputElement>—
xstyleStyleXStyles—
styleCSSProperties—
classNamestring—

The root is a <button role="switch">. Base UI renders a hidden <input> beside it that carries id, name and value. A label's htmlFor and a form submission reach the control through that input. The component renders the thumb. There is no SwitchThumb part to place.

switchStyles

The style maps behind the component. Spread them onto any element.

KeyWhat it is
rootThe track: shape, fill, hit area
sizesThe sm and default track sizes, keyed by name
checkedThe primary fill and its sheen
disabledThe dimmed, non-interactive state
invalidThe destructive border and ring
thumbThe thumb at rest and the transition it travels on
thumbCheckedThe thumb's far end of the travel and its fill

A size sets --carbon-switch-thumb, which the thumb reads for its width, height and the distance it moves. Override that property to resize a switch without adding a size.

root carries no focus ring. The ring, disabled and invalid all come from states in theme/states.ts, and the ring reads a resting border colour from --carbon-border-color. The switch leaves that property unset, so the track's border stays transparent until focus colours it.

Data attributes

AttributeOnEffect
data-slot="switch"the trackTargets the control
data-slot="switch-thumb"the thumbTargets the moving part
data-sizethe trackMirrors the size prop
data-checked, data-uncheckedtrack, thumbBase UI writes them
data-disabled, data-readonly, data-requiredtrack, thumbBase UI writes them