# Switch

A toggle control that turns a single setting on or off.

Switch is an open-source component from suiss/ui for React, React Native, SwiftUI and Jetpack Compose. The @esuiss/ui CLI copies its source into your project, so you own the code.

Source: https://ui.suiss.app/docs/components/switch

**React**

**Example**

```tsx
import { Switch } from "@/components/ui/switch"

export default function SwitchDemo() {
  return <Switch aria-label="Wi-Fi" />
}
```

`Switch` wraps the switch primitive of the chosen base and renders the track and thumb together. The thumb stretches while pressed, and the track fills with the primary color when on.

Use a switch for settings that take effect immediately. To select an option in a form that's submitted later, use `Checkbox`.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/switch
```

Works in any project with a `components.json`. See [Installation](https://ui.suiss.app/docs/installation#add-components) for the one-line registry setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add switch
```

### Manual

1. Install the dependencies and add the [`cn` helper](https://ui.suiss.app/docs/installation#the-cn-helper):

```bash title="Terminal"
npm install @base-ui/react
```

2. Copy the source into `components/ui/switch.tsx`:

```tsx title="components/ui/switch.tsx"
"use client"

import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
import { cn } from "@/lib/utils"

function Switch({
  className,
  size = "default",
  ...props
}: SwitchPrimitive.Root.Props & {
  size?: "sm" | "default"
}) {
  return (
    <SwitchPrimitive.Root
      data-slot="switch"
      data-size={size}
      className={cn(
        "peer group/switch relative inline-flex shrink-0 items-center rounded-full border-2 transition-colors outline-none group-has-focus-visible/field-label:ring-0 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 data-[size=default]:h-6 data-[size=default]:w-13 data-[size=sm]:h-5 data-[size=sm]:w-11 data-checked:border-primary data-checked:bg-primary group-has-focus-visible/field-label:data-checked:border-primary data-unchecked:border-transparent data-unchecked:bg-input dark:data-unchecked:bg-input/80 group-has-focus-visible/field-label:data-unchecked:border-transparent data-disabled:cursor-not-allowed data-disabled:data-checked:border-transparent data-disabled:opacity-50",
        className
      )}
      {...props}
    >
      <SwitchPrimitive.Thumb
        data-slot="switch-thumb"
        className="pointer-events-none block rounded-full bg-background shadow-sm ring-0 transition-[translate,width] duration-200 ease-[cubic-bezier(0.32,0.72,0,1)] not-dark:bg-clip-padding group-data-[size=default]/switch:h-5 group-data-[size=default]/switch:w-8 group-data-[size=sm]/switch:h-4 group-data-[size=sm]/switch:w-6.5 data-unchecked:translate-x-0 group-data-[size=default]/switch:data-checked:translate-x-4 group-data-[size=sm]/switch:data-checked:translate-x-3.5 rtl:group-data-[size=default]/switch:data-checked:-translate-x-4 rtl:group-data-[size=sm]/switch:data-checked:-translate-x-3.5 group-[:active:not([data-disabled])]/switch:group-data-[size=default]/switch:w-9 group-[:active:not([data-disabled])]/switch:group-data-[size=sm]/switch:w-7.5 group-[:active:not([data-disabled])]/switch:group-data-[size=default]/switch:data-checked:translate-x-3 group-[:active:not([data-disabled])]/switch:group-data-[size=sm]/switch:data-checked:translate-x-2.5 rtl:group-[:active:not([data-disabled])]/switch:group-data-[size=default]/switch:data-checked:-translate-x-3 rtl:group-[:active:not([data-disabled])]/switch:group-data-[size=sm]/switch:data-checked:-translate-x-2.5 dark:bg-foreground"
      />
    </SwitchPrimitive.Root>
  )
}

export { Switch }
```

3. Update the import paths to match your project.

## Usage

```tsx
import { Switch } from "@/components/ui/switch"
```

```tsx
<Switch aria-label="Wi-Fi" />
```

Use `defaultChecked` for an uncontrolled switch, or `checked` with `onCheckedChange` to control it. Wrap the switch and its text in a `Label` so the text toggles it too.

## Examples

### Checked

Starts in the on state.

**Example**

```tsx
import { Switch } from "@/components/ui/switch"

export default function SwitchChecked() {
  return <Switch defaultChecked aria-label="Wi-Fi" />
}
```

### Sizes

Compares the `sm` and `default` sizes.

**Example**

```tsx
import { Switch } from "@/components/ui/switch"

export default function SwitchSizes() {
  return (
    <div className="flex items-center gap-4">
      <Switch size="sm" defaultChecked aria-label="Wi-Fi" />
      <Switch size="default" defaultChecked aria-label="Bluetooth" />
    </div>
  )
}
```

### With label

Wraps the switch in a `Label` so the text names and toggles it.

**Example**

```tsx
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"

export default function SwitchWithLabel() {
  return (
    <Label>
      <Switch />
      Airplane mode
    </Label>
  )
}
```

### Disabled

Shows disabled switches in the off and on states.

**Example**

```tsx
import { Switch } from "@/components/ui/switch"

export default function SwitchDisabled() {
  return (
    <div className="flex items-center gap-4">
      <Switch disabled aria-label="Wi-Fi" />
      <Switch disabled defaultChecked aria-label="Bluetooth" />
    </div>
  )
}
```

### Invalid

Marks the switch with `aria-invalid` to show an error state.

**Example**

```tsx
import { Switch } from "@/components/ui/switch"

export default function SwitchInvalid() {
  return <Switch aria-invalid aria-label="Accept terms" />
}
```

## API Reference

### Switch

The switch track and its sliding thumb.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `size` | `"sm" \| "default"` | `"default"` | Sets the switch size: `default` or the smaller `sm`. |

Also accepts the props of the primitive part it wraps.

## Accessibility

- Renders with the `switch` role and `aria-checked`.
- Give it a name with `aria-label` or by wrapping it in a `Label`.
- `Space` toggles the focused switch.
- A disabled switch sets `aria-disabled` and ignores clicks.

## Notes

- Uses `data-slot="switch"` and `data-slot="switch-thumb"`, plus `data-size` and the primitive's state attributes.
- An invalid switch draws a `destructive` border.
- A transparent hit area extends around the track to make it easier to tap.
- The thumb's travel flips in right-to-left layouts.

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/components-switch--default.light.webp)

```tsx
import * as React from "react"
import { Switch } from "@/components/ui/switch"

function SwitchDemo({ initial = false }: { initial?: boolean }) {
  const [checked, setChecked] = React.useState(initial)
  return <Switch checked={checked} onCheckedChange={setChecked} />
}

export function SwitchExample() {
  return (
    <SwitchDemo />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct SwitchExample: View {
  var body: some View {
    SwitchDemo(initial: false)
  }
}

struct SwitchDemo: View {
  var size: SwitchStyle.Size = .default
  var label: LocalizedStringKey?

  @State private var checked: Bool

  init(initial: Bool, size: SwitchStyle.Size = .default, label: LocalizedStringKey? = nil) {
    self.size = size
    self.label = label
    _checked = State(initialValue: initial)
  }

  var body: some View {
    if let label {
      Toggle(isOn: $checked) { EmptyView() }
        .toggleStyle(.switch(size: size))
        .accessibilityLabel(Text(label))
    } else {
      Toggle(isOn: $checked) { EmptyView() }
        .toggleStyle(.switch(size: size))
    }
  }
}
```

```kotlin
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import com.esuiss.ui.components.Switch

@Composable
fun SwitchExample() {
    SwitchDemo(initial = false)
}

@Composable
fun SwitchDemo(initial: Boolean) {
    var checked by rememberSaveable { mutableStateOf(initial) }
    Switch(checked, { checked = it })
}
```

`Switch` is a pressable track with a round thumb that slides between the off and on positions. The thumb animates with Reanimated and stretches slightly while pressed. It comes in two sizes, `default` and `sm`, and has an `invalid` state that adds a destructive border.

Use a switch for settings that take effect immediately. For choices that are submitted later, use Checkbox.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/switch
```

See [Installation](https://ui.suiss.app/docs/installation) for the `components.json` setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add switch
```

**SwiftUI**

```bash title="Terminal"
npx @esuiss/ui@latest add switch --platform swift
```

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add switch --platform kotlin
```

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn react-native-reanimated react-native-worklets
```

2. Add the pieces it builds on: the primitives `components/primitives/switch.tsx`, `components/primitives/use-render.tsx`.

3. Copy the source into `components/ui/switch.tsx`:

```tsx title="uniwind/components/ui/switch.tsx"
import { cn } from "cn"
import * as React from "react"
import type { GestureResponderEvent } from "react-native"
import Animated, {
  Easing,
  ReduceMotion,
  useAnimatedStyle,
  withTiming,
} from "react-native-reanimated"
import * as SwitchPrimitives from "@/components/primitives/switch"
import { resolveClassName } from "@/components/primitives/use-render"

type SwitchSize = "default" | "sm"

const HIT_SLOP = { top: 8, bottom: 8, left: 12, right: 12 }

const THUMB = {
  default: { width: 32, pressedWidth: 36, travel: 16, pressedTravel: 12 },
  sm: { width: 26, pressedWidth: 30, travel: 14, pressedTravel: 10 },
} satisfies Record<SwitchSize, Record<string, number>>

const TIMING = {
  duration: 200,
  easing: Easing.bezier(0.32, 0.72, 0, 1),
  reduceMotion: ReduceMotion.System,
}

function SwitchThumb({
  size,
  pressed,
}: {
  size: SwitchSize
  pressed: boolean
}) {
  const { state } = SwitchPrimitives.useRootContext()
  const metrics = THUMB[size]
  const squeezed = pressed && !state.disabled && !state.readOnly
  const width = squeezed ? metrics.pressedWidth : metrics.width
  const checkedTravel = squeezed ? metrics.pressedTravel : metrics.travel
  const travel = state.checked ? checkedTravel : 0
  const animatedStyle = useAnimatedStyle(() => ({
    width: withTiming(width, TIMING),
    transform: [{ translateX: withTiming(travel, TIMING) }],
  }))
  return (
    <SwitchPrimitives.Thumb
      className={cn(
        "rounded-full bg-background shadow-sm dark:bg-foreground",
        size === "sm" ? "h-4" : "h-5"
      )}
      render={<Animated.View style={animatedStyle} />}
    />
  )
}

function Switch({
  className,
  size = "default",
  invalid,
  onPressIn,
  onPressOut,
  ...props
}: React.ComponentProps<typeof SwitchPrimitives.Root> & {
  size?: SwitchSize
  invalid?: boolean
}) {
  const [pressed, setPressed] = React.useState(false)
  return (
    <SwitchPrimitives.Root
      className={(state) =>
        cn(
          "shrink-0 flex-row items-center rounded-full border-2",
          size === "sm" ? "h-5 w-11" : "h-6 w-13",
          state.checked
            ? "border-primary bg-primary"
            : "border-transparent bg-input",
          state.disabled && "opacity-50",
          invalid && !state.disabled && "border-destructive",
          resolveClassName(className, state)
        )
      }
      hitSlop={HIT_SLOP}
      onPressIn={(event: GestureResponderEvent) => {
        setPressed(true)
        onPressIn?.(event)
      }}
      onPressOut={(event: GestureResponderEvent) => {
        setPressed(false)
        onPressOut?.(event)
      }}
      {...props}
    >
      <SwitchThumb size={size} pressed={pressed} />
    </SwitchPrimitives.Root>
  )
}

export type { SwitchSize }
export { Switch }
```

4. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn react-native-reanimated react-native-worklets
```

2. Add the pieces it builds on: the primitives `components/primitives/switch.tsx`, `components/primitives/use-render.tsx`.

3. Copy the source into `components/ui/switch.tsx`:

```tsx title="nativewind/components/ui/switch.tsx"
import { cn } from "cn"
import * as React from "react"
import type { GestureResponderEvent } from "react-native"
import Animated, {
  Easing,
  ReduceMotion,
  useAnimatedStyle,
  withTiming,
} from "react-native-reanimated"
import * as SwitchPrimitives from "@/components/primitives/switch"
import { resolveClassName } from "@/components/primitives/use-render"

type SwitchSize = "default" | "sm"

const HIT_SLOP = { top: 8, bottom: 8, left: 12, right: 12 }

const THUMB = {
  default: { width: 32, pressedWidth: 36, travel: 16, pressedTravel: 12 },
  sm: { width: 26, pressedWidth: 30, travel: 14, pressedTravel: 10 },
} satisfies Record<SwitchSize, Record<string, number>>

const TIMING = {
  duration: 200,
  easing: Easing.bezier(0.32, 0.72, 0, 1),
  reduceMotion: ReduceMotion.System,
}

function SwitchThumb({
  size,
  pressed,
}: {
  size: SwitchSize
  pressed: boolean
}) {
  const { state } = SwitchPrimitives.useRootContext()
  const metrics = THUMB[size]
  const squeezed = pressed && !state.disabled && !state.readOnly
  const width = squeezed ? metrics.pressedWidth : metrics.width
  const checkedTravel = squeezed ? metrics.pressedTravel : metrics.travel
  const travel = state.checked ? checkedTravel : 0
  const animatedStyle = useAnimatedStyle(() => ({
    width: withTiming(width, TIMING),
    transform: [{ translateX: withTiming(travel, TIMING) }],
  }))
  return (
    <SwitchPrimitives.Thumb
      className={cn(
        "rounded-full bg-background shadow dark:bg-foreground",
        size === "sm" ? "h-4" : "h-5"
      )}
      render={<Animated.View style={animatedStyle} />}
    />
  )
}

function Switch({
  className,
  size = "default",
  invalid,
  onPressIn,
  onPressOut,
  ...props
}: React.ComponentProps<typeof SwitchPrimitives.Root> & {
  size?: SwitchSize
  invalid?: boolean
}) {
  const [pressed, setPressed] = React.useState(false)
  return (
    <SwitchPrimitives.Root
      className={(state) =>
        cn(
          "shrink-0 flex-row items-center rounded-full border-2",
          size === "sm" ? "h-5 w-11" : "h-6 w-[3.25rem]",
          state.checked
            ? "border-primary bg-primary"
            : "border-transparent bg-input",
          state.disabled && "opacity-50",
          invalid && !state.disabled && "border-destructive",
          resolveClassName(className, state)
        )
      }
      hitSlop={HIT_SLOP}
      onPressIn={(event: GestureResponderEvent) => {
        setPressed(true)
        onPressIn?.(event)
      }}
      onPressOut={(event: GestureResponderEvent) => {
        setPressed(false)
        onPressOut?.(event)
      }}
      {...props}
    >
      <SwitchThumb size={size} pressed={pressed} />
    </SwitchPrimitives.Root>
  )
}

export type { SwitchSize }
export { Switch }
```

4. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

```bash title="Terminal"
npx expo install react-native-reanimated react-native-worklets
```

2. Add the pieces it builds on: the primitives `components/primitives/switch.tsx`, `components/primitives/use-render.tsx`.

3. Copy the source into `components/ui/switch.tsx`:

```tsx title="stylesheet/components/ui/switch.tsx"
import * as React from "react"
import { type GestureResponderEvent, StyleSheet } from "react-native"
import Animated, {
  Easing,
  ReduceMotion,
  useAnimatedStyle,
  withTiming,
} from "react-native-reanimated"
import * as SwitchPrimitives from "@/components/primitives/switch"
import { shadow, type ThemeColors, useTheme } from "@/components/ui/theme"

type SwitchSize = "default" | "sm"

const HIT_SLOP = { top: 8, bottom: 8, left: 12, right: 12 }

const THUMB = {
  default: {
    height: 20,
    width: 32,
    pressedWidth: 36,
    travel: 16,
    pressedTravel: 12,
  },
  sm: {
    height: 16,
    width: 26,
    pressedWidth: 30,
    travel: 14,
    pressedTravel: 10,
  },
} satisfies Record<SwitchSize, Record<string, number>>

const TIMING = {
  duration: 200,
  easing: Easing.bezier(0.32, 0.72, 0, 1),
  reduceMotion: ReduceMotion.System,
}

const styles = StyleSheet.create({
  root: {
    flexShrink: 0,
    flexDirection: "row",
    alignItems: "center",
    borderRadius: 9999,
    borderWidth: 2,
  },
  default: { height: 24, width: 52 },
  sm: { height: 20, width: 44 },
  thumb: { borderRadius: 9999, ...shadow.sm },
})

function trackColors(checked: boolean, colors: ThemeColors) {
  if (!checked) {
    return { borderColor: "transparent", backgroundColor: colors.input }
  }
  return { borderColor: colors.primary, backgroundColor: colors.primary }
}

function SwitchThumb({
  size,
  pressed,
}: {
  size: SwitchSize
  pressed: boolean
}) {
  const { colors, isDark } = useTheme()
  const { state } = SwitchPrimitives.useRootContext()
  const metrics = THUMB[size]
  const squeezed = pressed && !state.disabled && !state.readOnly
  const width = squeezed ? metrics.pressedWidth : metrics.width
  let travel = 0
  if (state.checked) travel = squeezed ? metrics.pressedTravel : metrics.travel
  const animatedStyle = useAnimatedStyle(() => ({
    width: withTiming(width, TIMING),
    transform: [{ translateX: withTiming(travel, TIMING) }],
  }))
  return (
    <SwitchPrimitives.Thumb
      style={[
        styles.thumb,
        {
          height: metrics.height,
          backgroundColor: isDark ? colors.foreground : colors.background,
        },
      ]}
      render={<Animated.View style={animatedStyle} />}
    />
  )
}

function Switch({
  style,
  size = "default",
  invalid,
  onPressIn,
  onPressOut,
  ...props
}: React.ComponentProps<typeof SwitchPrimitives.Root> & {
  size?: SwitchSize
  invalid?: boolean
}) {
  const { colors } = useTheme()
  const [pressed, setPressed] = React.useState(false)
  return (
    <SwitchPrimitives.Root
      style={(state) => [
        styles.root,
        styles[size],
        trackColors(state.checked, colors),
        invalid && !state.disabled && { borderColor: colors.destructive },
        state.disabled && { opacity: 0.5 },
        typeof style === "function" ? style(state) : style,
      ]}
      hitSlop={HIT_SLOP}
      onPressIn={(event: GestureResponderEvent) => {
        setPressed(true)
        onPressIn?.(event)
      }}
      onPressOut={(event: GestureResponderEvent) => {
        setPressed(false)
        onPressOut?.(event)
      }}
      {...props}
    >
      <SwitchThumb size={size} pressed={pressed} />
    </SwitchPrimitives.Root>
  )
}

export type { SwitchSize }
export { Switch }
```

4. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Add the Swift package to your app:

```swift title="Package.swift"
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
```

3. Copy the theme files every view uses:

- `swift/Theme/Theme.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Theme.swift)

- `swift/Theme/Tokens.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Tokens.swift)

4. Copy the source and every file it uses:

```swift title="swift/Components/Switch.swift"
import SwiftUI

public struct SwitchStyle: ToggleStyle {
  public enum Size: CaseIterable, Sendable {
    case `default`, sm

    var track: CGSize {
      switch self {
      case .default: CGSize(width: 52, height: 24)
      case .sm: CGSize(width: 44, height: 20)
      }
    }

    var thumbHeight: CGFloat {
      switch self {
      case .default: 20
      case .sm: 16
      }
    }

    func thumbWidth(pressed: Bool) -> CGFloat {
      switch self {
      case .default: pressed ? 36 : 32
      case .sm: pressed ? 30 : 26
      }
    }

    func travel(pressed: Bool) -> CGFloat {
      switch self {
      case .default: pressed ? 12 : 16
      case .sm: pressed ? 10 : 14
      }
    }
  }

  let size: Size

  public init(size: Size = .default) {
    self.size = size
  }

  public func makeBody(configuration: Configuration) -> some View {
    HapticButton(style: PressReader { pressed in
      HStack(spacing: 12) {
        SwitchTrack(isOn: configuration.isOn, pressed: pressed, size: size)
        SwitchLabel(label: configuration.label)
      }
      .contentShape(Rectangle().inset(by: -8))
    }) {
      configuration.isOn.toggle()
      return true
    }
    .accessibilityElement(children: .combine)
    .accessibilityRemoveTraits(.isButton)
    .accessibilityAddTraits(.isToggle)
    .accessibilityValue(configuration.isOn ? Text("On") : Text("Off"))
  }
}

private struct SwitchLabel<Label: View>: View {
  let label: Label

  var body: some View {
    if #available(iOS 18, *) {
      SwitchLabelVisibility(label: label)
    } else {
      label
    }
  }
}

@available(iOS 18, *)
private struct SwitchLabelVisibility<Label: View>: View {
  let label: Label

  @Environment(\.labelsVisibility) private var visibility

  var body: some View {
    if visibility != .hidden {
      label
    }
  }
}

private struct SwitchTrack: View {
  let isOn: Bool
  let pressed: Bool
  let size: SwitchStyle.Size

  @Environment(\.isEnabled) private var isEnabled
  @Environment(\.isInvalid) private var invalid
  @Environment(\.colorScheme) private var colorScheme
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  var body: some View {
    let squeezed = pressed && isEnabled
    ZStack(alignment: .leading) {
      Capsule().fill(fill)
      Capsule().strokeBorder(border, lineWidth: 2)
      Capsule()
        .fill(colorScheme == .dark ? EsuissColor.foreground : EsuissColor.background)
        .frame(width: size.thumbWidth(pressed: squeezed), height: size.thumbHeight)
        .shadow(.thumb)
        .offset(x: 2 + (isOn ? size.travel(pressed: squeezed) : 0))
    }
    .frame(width: size.track.width, height: size.track.height)
    .opacity(isEnabled ? 1 : 0.5)
    .animation(reduceMotion ? nil : .timingCurve(0.32, 0.72, 0, 1, duration: 0.2), value: isOn)
    .animation(reduceMotion ? nil : .timingCurve(0.32, 0.72, 0, 1, duration: 0.2), value: squeezed)
  }

  private var fill: Color {
    isOn ? EsuissColor.primary : EsuissColor.input
  }

  private var border: Color {
    if invalid && isEnabled { return EsuissColor.destructive }
    return isOn ? EsuissColor.primary : .clear
  }
}

extension ToggleStyle where Self == SwitchStyle {
  public static func `switch`(size: SwitchStyle.Size) -> SwitchStyle {
    SwitchStyle(size: size)
  }
}
```

- `swift/Components/Checkbox.swift`: see [Checkbox](https://ui.suiss.app/docs/components/checkbox.md)

- `swift/Components/Field.swift`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `swift/Components/Haptics.swift`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `swift/Components/Separator.swift`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

- `swift/Components/TextVariant.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/TextVariant.swift)

5. Add the files to your app target.

**Jetpack Compose**

1. Set `minSdk` to 24 or higher and add the dependencies:

```kotlin title="build.gradle.kts"
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
```

2. Copy the theme files every view uses:

- `kotlin/theme/Theme.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Theme.kt)

- `kotlin/theme/Tokens.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Tokens.kt)

3. Copy the source and every file it uses:

```kt title="kotlin/components/Switch.kt"
package com.esuiss.ui.components

import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.error
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.toggleableState
import androidx.compose.ui.state.ToggleableState
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.shadow

enum class SwitchSize(
    internal val width: Dp,
    internal val height: Dp,
    internal val thumbHeight: Dp,
    internal val thumbWidth: Dp,
    internal val pressedThumbWidth: Dp,
    internal val travel: Dp,
    internal val pressedTravel: Dp,
) {
    Default(52.dp, 24.dp, 20.dp, 32.dp, 36.dp, 16.dp, 12.dp),
    Sm(44.dp, 20.dp, 16.dp, 26.dp, 30.dp, 14.dp, 10.dp),
}

@Composable
fun Switch(
    checked: Boolean,
    onCheckedChange: ((Boolean) -> Unit)?,
    modifier: Modifier = Modifier,
    size: SwitchSize = SwitchSize.Default,
    enabled: Boolean = LocalFieldState.current.enabled,
    invalid: Boolean = LocalFieldState.current.invalid,
    interactionSource: MutableInteractionSource? = null,
) {
    val colors = EsuissTheme.colors
    val haptics = rememberHaptics()
    val source = interactionSource ?: remember { MutableInteractionSource() }
    val pressed by source.collectIsPressedAsState()
    val squeezed = pressed && enabled && onCheckedChange != null
    val spec = tween<Dp>(200, easing = FormEasing)
    val thumbWidth by animateDpAsState(if (squeezed) size.pressedThumbWidth else size.thumbWidth, spec, label = "thumb")
    val travel by animateDpAsState(
        when {
            !checked -> 0.dp
            squeezed -> size.pressedTravel
            else -> size.travel
        },
        spec,
        label = "travel",
    )
    val track = if (checked) colors.primary else colors.input
    val border = when {
        invalid && enabled -> colors.destructive
        checked -> colors.primary
        else -> Color.Transparent
    }
    val toggle = if (onCheckedChange != null) {
        Modifier
            .toggleable(
                value = checked,
                interactionSource = source,
                indication = null,
                enabled = enabled,
                role = Role.Switch,
                onValueChange = {
                    haptics.toggle(it)
                    onCheckedChange(it)
                },
            )
            .formTouchTarget()
    } else {
        Modifier.semantics {
            role = Role.Switch
            toggleableState = ToggleableState(checked)
            if (!enabled) disabled()
        }
    }
    Box(
        modifier = modifier
            .then(toggle)
            .then(if (invalid) Modifier.semantics { error("Invalid") } else Modifier)
            .size(size.width, size.height)
            .alpha(if (enabled) 1f else 0.5f)
            .background(track, CircleShape)
            .border(2.dp, border, CircleShape)
            .padding(2.dp),
        contentAlignment = Alignment.CenterStart,
    ) {
        Box(
            Modifier
                .offset(x = travel)
                .size(thumbWidth, size.thumbHeight)
                .shadow(FormShadowSm, CircleShape)
                .background(if (colors.isDark) colors.foreground else colors.background, CircleShape),
        )
    }
}
```

- `kotlin/components/Content.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Content.kt)

- `kotlin/components/Field.kt`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `kotlin/components/FormSupport.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/FormSupport.kt)

- `kotlin/components/Haptics.kt`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `kotlin/components/Label.kt`: see [Label](https://ui.suiss.app/docs/components/label.md)

- `kotlin/components/Separator.kt`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

4. Update the `com.esuiss.ui` package names to match your project.

## Usage

```tsx
import { Switch } from "@/components/ui/switch"
```

```tsx
<SwitchDemo />
```

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

A switch has no visible text of its own. Pair it with a `Label` and link them with `accessibilityLabelledBy` and the label's `nativeID`, or set an `accessibilityLabel`. Give the `Label` an `onPress` that toggles the value if the label should be pressable too.

The switch has a hit slop around it, so it is easier to press than its visual size.

## Examples

### Checked

A switch that starts on.

![Preview](https://ui.suiss.app/previews/components-switch--checked.light.webp)

```tsx
import * as React from "react"
import { Switch } from "@/components/ui/switch"

function SwitchDemo({ initial = false }: { initial?: boolean }) {
  const [checked, setChecked] = React.useState(initial)
  return <Switch checked={checked} onCheckedChange={setChecked} />
}

export function SwitchCheckedExample() {
  return (
    <SwitchDemo initial />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct SwitchCheckedExample: View {
  var body: some View {
    SwitchDemo(initial: true)
  }
}

struct SwitchDemo: View {
  var size: SwitchStyle.Size = .default
  var label: LocalizedStringKey?

  @State private var checked: Bool

  init(initial: Bool, size: SwitchStyle.Size = .default, label: LocalizedStringKey? = nil) {
    self.size = size
    self.label = label
    _checked = State(initialValue: initial)
  }

  var body: some View {
    if let label {
      Toggle(isOn: $checked) { EmptyView() }
        .toggleStyle(.switch(size: size))
        .accessibilityLabel(Text(label))
    } else {
      Toggle(isOn: $checked) { EmptyView() }
        .toggleStyle(.switch(size: size))
    }
  }
}
```

```kotlin
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import com.esuiss.ui.components.Switch

@Composable
fun SwitchCheckedExample() {
    SwitchDemo(initial = true)
}

@Composable
fun SwitchDemo(initial: Boolean) {
    var checked by rememberSaveable { mutableStateOf(initial) }
    Switch(checked, { checked = it })
}
```

### Sizes

The `sm` and `default` sizes side by side.

![Preview](https://ui.suiss.app/previews/components-switch--sizes.light.webp)

```tsx
import { View } from "react-native"
import { Switch } from "@/components/ui/switch"

export function SwitchSizesExample() {
  return (
    <View className="flex-row items-center gap-4">
      <Switch size="sm" defaultChecked accessibilityLabel="Wi-Fi" />
      <Switch defaultChecked accessibilityLabel="Bluetooth" />
    </View>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct SwitchSizesExample: View {
  var body: some View {
    HStack(spacing: 16) {
      SwitchDemo(initial: true, size: .sm, label: "Wi-Fi")
      SwitchDemo(initial: true, label: "Bluetooth")
    }
  }
}

struct SwitchDemo: View {
  var size: SwitchStyle.Size = .default
  var label: LocalizedStringKey?

  @State private var checked: Bool

  init(initial: Bool, size: SwitchStyle.Size = .default, label: LocalizedStringKey? = nil) {
    self.size = size
    self.label = label
    _checked = State(initialValue: initial)
  }

  var body: some View {
    if let label {
      Toggle(isOn: $checked) { EmptyView() }
        .toggleStyle(.switch(size: size))
        .accessibilityLabel(Text(label))
    } else {
      Toggle(isOn: $checked) { EmptyView() }
        .toggleStyle(.switch(size: size))
    }
  }
}
```

```kotlin
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Switch
import com.esuiss.ui.components.SwitchSize

fun Modifier.label(text: String): Modifier = semantics { contentDescription = text }

@Composable
fun Modifier.toggles(onToggle: () -> Unit): Modifier = clickable(
    interactionSource = remember { MutableInteractionSource() },
    indication = null,
    onClick = onToggle,
)

@Composable
fun SwitchSizes() {
    var wifi by rememberSaveable { mutableStateOf(true) }
    var bluetooth by rememberSaveable { mutableStateOf(true) }
    Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {
        Switch(wifi, { wifi = it }, Modifier.label("Wi-Fi"), size = SwitchSize.Sm)
        Switch(bluetooth, { bluetooth = it }, Modifier.label("Bluetooth"))
    }
}
```

### With label

A controlled switch next to a pressable label.

![Preview](https://ui.suiss.app/previews/components-switch--with-label.light.webp)

```tsx
import * as React from "react"
import { View } from "react-native"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"

function WithLabelDemo() {
  const [checked, setChecked] = React.useState(false)
  return (
    <View className="flex-row items-center gap-2">
      <Switch
        accessibilityLabelledBy="airplane-mode"
        checked={checked}
        onCheckedChange={setChecked}
      />
      <Label nativeID="airplane-mode" onPress={() => setChecked((v) => !v)}>
        Airplane mode
      </Label>
    </View>
  )
}

export function SwitchWithLabelExample() {
  return (
    <WithLabelDemo />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct WithLabelDemo: View {
  @State private var checked = false

  var body: some View {
    Toggle(isOn: $checked) {
      FieldLabel("Airplane mode")
    }
    .toggleStyle(.switch(size: .default))
  }
}
```

```kotlin
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Label
import com.esuiss.ui.components.Switch

fun Modifier.label(text: String): Modifier = semantics { contentDescription = text }

@Composable
fun Modifier.toggles(onToggle: () -> Unit): Modifier = clickable(
    interactionSource = remember { MutableInteractionSource() },
    indication = null,
    onClick = onToggle,
)

@Composable
fun SwitchWithLabel() {
    var checked by rememberSaveable { mutableStateOf(false) }
    Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
        Switch(checked, { checked = it }, Modifier.label("Airplane mode"))
        Label("Airplane mode", Modifier.toggles { checked = !checked })
    }
}
```

### With description

A switch with a label and a line of supporting text.

![Preview](https://ui.suiss.app/previews/components-switch--with-description.light.webp)

```tsx
import * as React from "react"
import { View } from "react-native"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { Text } from "@/components/ui/text"

function WithDescriptionDemo() {
  const [checked, setChecked] = React.useState(true)
  return (
    <View className="flex-row items-center justify-between gap-4 rounded-2xl bg-muted p-4">
      <View className="flex-1 gap-1">
        <Label nativeID="marketing" onPress={() => setChecked((v) => !v)}>
          Marketing emails
        </Label>
        <Text className="text-sm text-muted-foreground">
          Receive emails about new products.
        </Text>
      </View>
      <Switch
        accessibilityLabelledBy="marketing"
        checked={checked}
        onCheckedChange={setChecked}
      />
    </View>
  )
}

export function SwitchWithDescriptionExample() {
  return (
    <WithDescriptionDemo />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct WithDescriptionDemo: View {
  @State private var checked = true

  var body: some View {
    HStack(spacing: 16) {
      VStack(alignment: .leading, spacing: 4) {
        FieldLabel("Marketing emails")
          .onTapGesture { checked.toggle() }
        Text("Receive emails about new products.")
          .textStyle(.sm)
          .foregroundStyle(EsuissColor.mutedForeground)
      }
      .frame(maxWidth: .infinity, alignment: .leading)
      Toggle(isOn: $checked) { EmptyView() }
        .toggleStyle(.switch(size: .default))
        .accessibilityLabel(Text("Marketing emails"))
    }
    .padding(16)
    .background(EsuissColor.muted, in: RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous))
  }
}
```

```kotlin
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Label
import com.esuiss.ui.components.Switch
import com.esuiss.ui.components.Text
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography

fun Modifier.label(text: String): Modifier = semantics { contentDescription = text }

@Composable
fun Modifier.toggles(onToggle: () -> Unit): Modifier = clickable(
    interactionSource = remember { MutableInteractionSource() },
    indication = null,
    onClick = onToggle,
)

@Composable
fun SwitchWithDescription() {
    var checked by rememberSaveable { mutableStateOf(true) }
    val colors = EsuissTheme.colors
    val shape = RoundedCornerShape(Radius.xl2)
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .background(colors.muted, shape)
            .padding(16.dp),
        horizontalArrangement = Arrangement.spacedBy(16.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
            Label("Marketing emails", Modifier.toggles { checked = !checked })
            Text("Receive emails about new products.", style = Typography.sm.copy(color = colors.mutedForeground))
        }
        Switch(checked, { checked = it }, Modifier.label("Marketing emails"))
    }
}
```

### Disabled

Disabled switches in the off and on states.

![Preview](https://ui.suiss.app/previews/components-switch--disabled.light.webp)

```tsx
import { View } from "react-native"
import { Switch } from "@/components/ui/switch"

export function SwitchDisabledExample() {
  return (
    <View className="gap-4">
      <Switch disabled checked={false} onCheckedChange={() => undefined} />
      <Switch disabled checked onCheckedChange={() => undefined} />
    </View>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct SwitchDisabledExample: View {
  var body: some View {
    VStack(alignment: .leading, spacing: 16) {
      Toggle(isOn: .constant(false)) { EmptyView() }
      Toggle(isOn: .constant(true)) { EmptyView() }
    }
    .toggleStyle(.switch(size: .default))
    .disabled(true)
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Switch

@Composable
fun SwitchDisabled() {
    Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
        Switch(false, {}, enabled = false)
        Switch(true, {}, enabled = false)
    }
}
```

### Invalid

A switch with the `invalid` border next to its label.

![Preview](https://ui.suiss.app/previews/components-switch--invalid.light.webp)

```tsx
import { View } from "react-native"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"

export function SwitchInvalidExample() {
  return (
    <View className="flex-row items-center gap-2">
      <Switch invalid accessibilityLabelledBy="terms-switch" />
      <Label nativeID="terms-switch">Accept terms</Label>
    </View>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct InvalidDemo: View {
  @State private var checked = false

  var body: some View {
    Toggle(isOn: $checked) {
      FieldLabel("Accept terms")
    }
    .toggleStyle(.switch(size: .default))
    .invalid()
  }
}
```

```kotlin
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Label
import com.esuiss.ui.components.Switch

fun Modifier.label(text: String): Modifier = semantics { contentDescription = text }

@Composable
fun Modifier.toggles(onToggle: () -> Unit): Modifier = clickable(
    interactionSource = remember { MutableInteractionSource() },
    indication = null,
    onClick = onToggle,
)

@Composable
fun SwitchInvalid() {
    var checked by rememberSaveable { mutableStateOf(false) }
    Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
        Switch(checked, { checked = it }, Modifier.label("Accept terms"), invalid = true)
        Label("Accept terms")
    }
}
```

## API Reference

### Switch

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `size` | `SwitchSize` | `"default"` | `default` or `sm`. |
| `invalid` | `boolean` | - | Adds a destructive border when not disabled. |

Also accepts the props of `React.ComponentProps<typeof SwitchPrimitives.Root>`.

## Accessibility

- The root has the `switch` role and reports `checked` and `disabled` in its accessibility state, so VoiceOver and TalkBack announce whether it is on.
- The thumb is hidden from the accessibility tree.
- Label every switch with `accessibilityLabel` or `accessibilityLabelledBy`.
- The thumb animation follows the system reduce motion setting.

## Notes

- The track uses `bg-primary` when on and `bg-input` when off, with `opacity-50` when disabled.
- The thumb uses `bg-background`, or `bg-foreground` in dark mode.
- Light and dark colors follow the system appearance.

