# Switch

> A themed on/off Switch, identical on iOS and Android, with an optional label, a thumb that slides (instant under Reduce Motion) and a haptic tick.

Component · forms. Registry item `switch`, shown in the vega Style. JSON: https://nativecn.dev/r/styles/vega/switch.json

## Installation

```sh
npx nativecn-cli@latest add switch
```

Also installs: [theme](https://nativecn.dev/docs/theme/theme), [pressable](https://nativecn.dev/docs/primitives/pressable), [form-field-context](https://nativecn.dev/docs/primitives/form-field-context), [text](https://nativecn.dev/docs/components/text), [use-controllable-state](https://nativecn.dev/docs/primitives/use-controllable-state), [use-motion](https://nativecn.dev/docs/primitives/use-motion).

Packages: `react-native-reanimated`, `react-native-worklets`.

## Usage

### Switch demo

A controlled Switch with a label, an uncontrolled one and a disabled one.

```tsx
import { useState } from "react";
import { View } from "react-native";

import { Switch } from "@/components/switch";
import { createStyles } from "@/theme";

const useStyles = createStyles((t) => ({
  root: { gap: t.spacing[4] },
}));

export default function SwitchDemo() {
  const styles = useStyles();
  const [notifications, setNotifications] = useState(true);

  return (
    <View style={styles.root}>
      <Switch label="Notifications" checked={notifications} onCheckedChange={setNotifications} />
      <Switch label="Airplane mode" />
      <Switch label="Managed by your organisation" defaultChecked disabled />
    </View>
  );
}
```

## Props

| Prop | Type and description |
|---|---|
| `checked` | boolean: the controlled on/off state |
| `defaultChecked` | boolean: the starting state while uncontrolled (default false) |
| `onCheckedChange` | (checked: boolean) => void: called when the user toggles it |
| `label` | string: visible text next to the switch, also the accessible name |
| `disabled` | boolean: dimmed, announced as disabled, no handler runs |
| `style` | layout only, merged last onto the root |

## Accessibility

- role switch with aria-checked.
- The label is the accessible name; without a label, pass aria-label.
- The tap area is at least 48.
- disabled is announced as disabled and no handler runs.
- Inside a FormField the field's label and error become its name and hint.
- The thumb jumps instantly under Reduce Motion.

## Notes

Not the OS Switch: drawn by nativecn so both platforms look the same. The thumb slides with the fast motion Token. A light haptic tick on toggle when config.haptics is on. Inside a FormField it takes the field's disabled. The track uses the switch.track Style Slot and the pressed look switch.pressed.

## Source

### {components}/switch.tsx

```tsx
import { useEffect } from "react";
import { View, type GestureResponderEvent, type StyleProp, type ViewStyle } from "react-native";
import Animated, { useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated";

import { useFormField } from "@/components/primitives/form-field-context";
import { Pressable, type PressableProps } from "@/components/primitives/pressable";
import { Text } from "@/components/text";
import { useControllableState } from "@/hooks/use-controllable-state";
import { useMotion } from "@/hooks/use-motion";
import { createStyles } from "@/theme";

export type SwitchProps = Omit<
  PressableProps,
  "children" | "style" | "size" | "pressedStyle" | "haptic" | "loading" | "role"
> & {
  /** Controlled on/off state. */
  checked?: boolean;
  /** Starting state while uncontrolled (default `false`). */
  defaultChecked?: boolean;
  /** Called with the next state when the user toggles it. */
  onCheckedChange?: (checked: boolean) => void;
  /** Visible text next to the switch; it is also the accessible name. */
  label?: string;
  /** Layout only, merged last onto the root. */
  style?: StyleProp<ViewStyle>;
};

const useStyles = createStyles((t) => {
  const track = { width: t.scaleValue(52), height: t.scaleValue(32) };
  const inset = t.spacing[0.5];
  const thumb = track.height - inset * 2;
  return {
    root: {
      flexDirection: "row",
      alignItems: "center",
      gap: t.spacing[3],
      alignSelf: "flex-start",
    },
    track: {
      ...track,
      justifyContent: "center",
      paddingHorizontal: inset,
      borderRadius: t.radius.full,
      backgroundColor: t.colors.input,
    },
    trackOn: { backgroundColor: t.colors.primary },
    thumb: {
      width: thumb,
      height: thumb,
      borderRadius: t.radius.full,
      backgroundColor: t.colors.background,
      boxShadow: t.elevation.sm,
    },
    pressed: { opacity: 0.7 },
    disabled: { opacity: t.opacity.disabled },
  };
});

/**
 * An on/off switch with an optional label. A themed control drawn by nativecn (not the OS
 * Switch), so it looks the same on iOS and Android. Works controlled (`checked` +
 * `onCheckedChange`) or uncontrolled (`defaultChecked`). The thumb slides with the `fast`
 * motion Token and jumps instantly when Reduce Motion is on. Built on the Pressable Primitive:
 * tap area of at least 48, a light haptic tick when `config.haptics` is on. Inside a FormField
 * it reads the field's label and error and takes its `disabled`.
 */
export function Switch({
  checked: checkedProp,
  defaultChecked = false,
  onCheckedChange,
  label,
  disabled: disabledProp,
  onPress,
  style,
  ...props
}: SwitchProps) {
  const styles = useStyles();
  // Inside a FormField: its label and error become the name and hint, and its `disabled` applies.
  const field = useFormField();
  const disabled = disabledProp ?? field?.disabled ?? false;
  const motion = useMotion();
  const [checked, setChecked] = useControllableState({
    value: checkedProp,
    defaultValue: defaultChecked,
    onChange: onCheckedChange,
  });

  // How far the thumb travels from off to on.
  const travel = styles.track.width - styles.track.paddingHorizontal * 2 - styles.thumb.width;
  const offset = useSharedValue(checked ? travel : 0);
  useEffect(() => {
    offset.value = withTiming(checked ? travel : 0, motion.timing("fast"));
  }, [checked, travel, motion, offset]);
  const thumbStyle = useAnimatedStyle(() => ({ transform: [{ translateX: offset.value }] }));

  const handlePress = (event: GestureResponderEvent) => {
    setChecked(!checked);
    onPress?.(event);
  };

  return (
    <Pressable
      role="switch"
      aria-checked={checked}
      disabled={disabled}
      haptic="selection"
      size={label == null ? { width: styles.track.width, height: styles.track.height } : undefined}
      pressedStyle={styles.pressed}
      onPress={handlePress}
      style={[styles.root, disabled ? styles.disabled : null, style]}
      {...field?.accessibilityProps}
      {...props}
    >
      <View style={[styles.track, checked ? styles.trackOn : null]}>
        <Animated.View style={[styles.thumb, thumbStyle]} />
      </View>
      {label != null ? <Text variant="label">{label}</Text> : null}
    </Pressable>
  );
}
```
