iOS-size toggle with spring thumb and haptics.
components/ui/switch/switch.tsx@lumioui/core@latestreact-native-reanimated@*Live preview — the real component, driven by the props below.
import { Switch } from "@/components/ui/switch/switch";
export function Example() {
return (
<Switch />
);
} import * as React from "react";
import { forwardRef } from "react";
import { Pressable, View, Text, type PressableProps } from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withSequence,
withTiming,
interpolateColor,
} from "react-native-reanimated";
import { cn, useHaptic, spring, useTheme, useReducedMotion, usePressAnimation } from "@lumioui/core";
export interface SwitchProps extends Omit<PressableProps, "onPress" | "children"> {
/** Whether the switch is on. */
checked: boolean;
/** Called when the user toggles the switch. */
onCheckedChange: (checked: boolean) => void;
/** Disable interaction. */
disabled?: boolean;
/** Extra classes for the outer wrapper. */
className?: string;
/** Haptic on toggle — default "selection". Set false to disable. */
haptic?: "selection" | "light" | false;
/** Optional label rendered to the left (SwiftUI Toggle style). */
label?: string;
/** Optional icon rendered inside the thumb when on. */
iconOn?: React.ReactNode;
}
// iOS-standard dimensions — matches UISwitch
const TRACK_W = 51;
const TRACK_H = 31;
const THUMB = 27;
const PAD = (TRACK_H - THUMB) / 2;
/** Disabled-tap wiggle — 4 tiny 60ms segments; a full shake sequence would feel punishing for a "nope". */
const SHAKE_SEGMENT = { duration: 60 };
/**
* Switch — iOS-style toggle switch with spring thumb animation.
*
* This is the primary toggle component for LumioUI, matching SwiftUI's
* `Toggle(.switch)` style. The thumb slides with a spring, squishes
* while held, and leans toward the destination — just like iOS.
*
* For a button-style toggle (SwiftUI's `.button` toggle style),
* use the `Toggle` component instead.
*/
export const Switch = forwardRef<React.ElementRef<typeof Pressable>, SwitchProps>(
function Switch(
{ checked, onCheckedChange, disabled, className, haptic = "selection", label, iconOn, ...props },
ref
) {
const { colors } = useTheme();
const haptics = useHaptic();
const reduceMotion = useReducedMotion();
const progress = useSharedValue(checked ? 1 : 0);
const pressed = useSharedValue(0);
const shakeX = useSharedValue(0);
// Row press feedback for the label variant — opacity dip only.
const rowPress = usePressAnimation({ pressedScale: 1, pressedOpacity: 0.65 });
React.useEffect(() => {
// Reduced motion: snap — no thumb travel, the track color still lands.
if (reduceMotion) progress.value = checked ? 1 : 0;
else progress.value = withSpring(checked ? 1 : 0, spring.switch);
}, [checked, progress, reduceMotion]);
// Disabled shake — when you tap a disabled switch, the thumb wiggles
// to say "nope". Matches beui's disabled switch behavior.
const triggerShake = React.useCallback(() => {
if (!reduceMotion) {
shakeX.value = withSequence(
withTiming(4, SHAKE_SEGMENT),
withTiming(-4, SHAKE_SEGMENT),
withTiming(3, SHAKE_SEGMENT),
withTiming(0, SHAKE_SEGMENT)
);
}
}, [shakeX, reduceMotion]);
const trackStyle = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(
progress.value,
[0, 1],
[
// Token-driven off state — hardcoded #E9E9EA/#39393D clashed with theme borders.
colors.input,
colors.primary,
]
),
}));
const thumbStyle = useAnimatedStyle(() => {
const base = PAD + progress.value * (TRACK_W - THUMB - PAD * 2);
// While held, squish the thumb and lean it toward the destination.
const squish = 1 - pressed.value * 0.12;
const stretch = 1 + pressed.value * 0.18;
const lean = pressed.value * (progress.value < 0.5 ? 3 : -3);
return {
transform: [
{ translateX: base + lean + shakeX.value },
{ scaleX: stretch },
{ scaleY: squish },
],
};
});
// iconOn pops in with the toggle — scales up as the thumb lands.
const thumbIconStyle = useAnimatedStyle(() => ({
opacity: progress.value,
transform: [{ scale: reduceMotion ? 1 : 0.5 + progress.value * 0.5 }],
}));
const handlePress = () => {
if (disabled) {
triggerShake();
haptics.warning();
return;
}
if (haptic) haptics[haptic === "selection" ? "selection" : "light"]();
onCheckedChange(!checked);
};
const handlePressIn = () => {
// Squish is scale travel — skip it under reduced motion.
if (disabled || reduceMotion) return;
pressed.value = withSpring(1, spring.press);
};
const handlePressOut = () => {
if (disabled || reduceMotion) return;
pressed.value = withSpring(0, spring.press);
};
const track = (
<Animated.View
style={[
{
width: TRACK_W,
height: TRACK_H,
borderRadius: TRACK_H / 2,
opacity: disabled ? 0.4 : 1,
// Physical-thumb drop shadow — raw black reads correctly under
// both schemes (matches iOS UISwitch), so not a theme token.
shadowColor: "#000",
shadowOpacity: 0.12,
shadowRadius: 2,
shadowOffset: { width: 0, height: 1 },
elevation: 1,
},
trackStyle,
]}
>
<Animated.View
style={[
{
width: THUMB,
height: THUMB,
borderRadius: THUMB / 2,
// Physical thumb — iOS switches ship a white knob in both
// light and dark mode, so this is intentionally not a token.
backgroundColor: "#FFFFFF",
position: "absolute",
top: PAD,
alignItems: "center",
justifyContent: "center",
// Same physical-shadow exception as the track above.
shadowColor: "#000",
shadowOpacity: 0.2,
shadowRadius: 3,
shadowOffset: { width: 0, height: 1 },
elevation: 3,
},
thumbStyle,
]}
>
{iconOn ? (
<Animated.View style={thumbIconStyle} pointerEvents="none">
{iconOn}
</Animated.View>
) : null}
</Animated.View>
</Animated.View>
);
const switchElement = (
<Pressable
ref={ref}
// Deliberately never disabled at the responder level — a tap on a
// disabled switch must still fire so it can shake "nope" + warn.
// a11y still reports disabled via accessibilityState below.
disabled={false}
onPress={handlePress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
accessibilityRole="switch"
accessibilityState={{ checked, disabled: disabled ?? false }}
aria-checked={checked}
aria-disabled={disabled ?? false}
aria-label={label}
hitSlop={8}
{...props}
>
{track}
</Pressable>
);
// SwiftUI-style: label on the left, switch on the right.
// The outer row owns the press and the single switch node — the track
// renders inert so haptics and screen-reader nodes don't double up.
if (label) {
return (
<Pressable
ref={ref}
onPress={handlePress}
onPressIn={() => {
handlePressIn();
rowPress.onPressIn();
}}
onPressOut={() => {
handlePressOut();
rowPress.onPressOut();
}}
hitSlop={4}
className={cn("py-2", className)}
accessibilityRole="switch"
accessibilityState={{ checked, disabled: disabled ?? false }}
aria-checked={checked}
aria-disabled={disabled ?? false}
aria-label={label}
{...props}
>
{/* Row dip — the whole row confirms contact like an iOS settings row. */}
<Animated.View
style={rowPress.animatedStyle}
className="flex-1 flex-row items-center justify-between"
>
<Text className={cn("text-base leading-tight text-foreground", disabled && "opacity-50")}>{label}</Text>
<View pointerEvents="none">{track}</View>
</Animated.View>
</Pressable>
);
}
return (
<View className={cn(className)}>
{switchElement}
</View>
);
}
);
Switch.displayName = "Switch";
| checked | boolean | Whether the switch is on. | |
| onCheckedChange | (checked: boolean) => void | Called when the user toggles the switch. | |
| disabled? | boolean | Disable interaction. | |
| className? | string | Extra classes for the outer wrapper. | |
| haptic? | "selection" | "light" | false | Haptic on toggle — default "selection". Set false to disable. | |
| label? | string | Optional label rendered to the left (SwiftUI Toggle style). | |
| iconOn? | React.ReactNode | Optional icon rendered inside the thumb when on. |
iOS-size toggle with spring thumb and haptics.
import { SwitchSettings } from "@/components/ui/switch/variants/settings";
export function Example() {
return (
<SwitchSettings />
);
} import * as React from "react";
import { View } from "react-native";
import { Switch } from "../switch";
import { Form } from "../../form/form";
import { Icon } from "../../icon/icon";
import { Text } from "../../text/text";
import { Glass } from "../../glass/glass";
/** SwitchSettings — switches embedded in an iOS-style grouped settings form. */
export function SwitchSettings() {
const [on1, setOn1] = React.useState(true);
const [on2, setOn2] = React.useState(false);
const [on3, setOn3] = React.useState(true);
return (
<View className="w-full gap-3">
<View className="w-full">
<Text className="mx-4 mb-2 text-xs font-medium uppercase tracking-wide text-white drop-shadow">
General
</Text>
<Glass interactive curvature="edge" className="overflow-hidden rounded-2xl">
<Form.Row
label="Airplane Mode"
leading={<Icon name="plane" size="sm" color="primary" />}
trailing={<Switch checked={on1} onCheckedChange={setOn1} />}
/>
<Form.Separator />
<Form.Row
label="Notifications"
description="Allow push notifications"
leading={<Icon name="bell" size="sm" color="primary" />}
trailing={<Switch checked={on2} onCheckedChange={setOn2} />}
/>
<Form.Separator />
<Form.Row
label="Dark Mode"
description="Use dark appearance"
leading={<Icon name="moon" size="sm" color="primary" />}
trailing={<Switch checked={on3} onCheckedChange={setOn3} />}
/>
</Glass>
</View>
</View>
);
}
iOS-size toggle with spring thumb and haptics.
import { SwitchStandalone } from "@/components/ui/switch/variants/standalone";
export function Example() {
return (
<SwitchStandalone />
);
} import * as React from "react";
import { View } from "react-native";
import { Switch } from "../switch";
import { Glass } from "../../glass/glass";
/** SwitchStandalone — label prop usage plus disabled shake feedback. */
export function SwitchStandalone() {
const [on, setOn] = React.useState(true);
return (
<View className="w-full gap-3">
<Glass interactive curvature="edge" className="w-full gap-3 rounded-2xl p-4">
<Switch checked={on} onCheckedChange={setOn} label="Airplane Mode" />
<Switch
checked={false}
onCheckedChange={() => {}}
disabled
label="Disabled — tap for shake feedback"
/>
</Glass>
</View>
);
}