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.
Light
Dark
Your app is ready.
Inter body text, Inter headings.
a0: Vega, neutral, neutral accent, default radius, Inter.Change PresetInstallation
npx nativecn-cli@latest add switchAlso installs: theme, pressable, form-field-context, text, use-controllable-state, 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.
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>
);
}Switch demo
A controlled Switch with a label, an uncontrolled one and a disabled one.
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
The Vega Style's code, exactly as add copies it into your app.
{components}/switch.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>
);
}{components}/switch.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(44), height: t.scaleValue(26) };
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>
);
}
