Button-style toggle with pressed state and haptics.
components/ui/toggle/toggle.tsx@lumioui/core@latestreact-native-reanimated@*Live preview — the real component, driven by the props below.
import { Toggle } from "@/components/ui/toggle/toggle";
export function Example() {
return (
<Toggle />
);
} 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, createVariant, useHaptic, usePressAnimation, useTheme, useReducedMotion, spring, timing, AnimatedPressable } from "@lumioui/core";
import { Glass } from "../glass";
export interface ToggleProps extends Omit<PressableProps, "onPress" | "children" | "style"> {
/** Controlled pressed/checked state. Omit with defaultPressed for
* uncontrolled use. */
pressed?: boolean;
/** Initial pressed state (uncontrolled). Default false. */
defaultPressed?: boolean;
/** Called when the user toggles. */
onPressedChange?: (pressed: boolean) => void;
/** Toggle style — "button" renders a pill toggle, "switch" renders an iOS switch. */
style?: "button" | "switch";
/** Liquid-glass pill (button style only). */
glass?: boolean;
/** Haptic feedback. Default "selection". Set false to disable. */
haptic?: "selection" | "light" | false;
/** Icon rendered before the label (button style only). */
icon?: React.ReactNode;
/** Extra classes for the label. */
textClassName?: string;
/** Extra classes for the container. */
className?: string;
/** Optional label text. In switch style, renders to the left of the switch. */
children?: React.ReactNode;
/** Disable interaction. */
disabled?: boolean;
}
const toggleVariants = createVariant({
// Color rides an animated tint layer (stateT below) — the container keeps
// a constant secondary bg so the pressed transition cross-fades instead
// of snapping. overflow-hidden clips the tint to the pill.
// min-h-[44px] keeps the whole pill inside the 44pt touch-target floor —
// hitSlop below then adds margin instead of patching a sub-44 target.
base: "flex-row min-h-[44px] items-center justify-center rounded-full px-4 py-2.5 gap-2 bg-secondary overflow-hidden",
variants: {
pressed: {
true: "",
false: "",
},
},
defaultVariants: { pressed: "false" },
});
const toggleTextVariants = createVariant({
// Text color is animated via interpolateColor (see textStyle) — the
// variant only carries typography so the swap doesn't snap.
base: "text-base font-medium text-secondary-foreground",
variants: {
pressed: {
true: "",
false: "",
},
},
defaultVariants: { pressed: "false" },
});
/** Selected = solid primary fill (solid fill like Button). */
const TINT_OPACITY = 1;
// iOS switch dimensions (shared with Switch component)
const TRACK_W = 51;
const TRACK_H = 31;
const THUMB = 27;
const PAD = (TRACK_H - THUMB) / 2;
/** Disabled-tap wiggle — tiny 60ms segments; same choreography as Switch. */
const SHAKE_SEGMENT = { duration: 60 };
/**
* Toggle — a two-state control. Matches SwiftUI's `Toggle` API.
*
* - `style="switch"` (default): renders an iOS-style sliding switch.
* If `children` is provided, it renders as a label to the left
* (SwiftUI's `Toggle("Label", isOn:)` pattern).
*
* - `style="button"`: renders a pill-shaped toggle button that
* changes color when active. Matches SwiftUI's `.button` toggle style.
*
* Both styles support controlled and uncontrolled usage, haptics,
* reduced motion, and disabled state with a shake feedback.
*/
export const Toggle = forwardRef<React.ElementRef<typeof Pressable>, ToggleProps>(
function Toggle(
{
pressed,
defaultPressed = false,
onPressedChange,
style: toggleStyle = "switch",
glass = false,
haptic = "selection",
icon,
textClassName,
className,
children,
disabled,
...props
},
ref
) {
// Hooks must run before the style branch — `style` is a prop that can
// change between renders, and a conditional early return would break
// hook ordering.
const haptics = useHaptic();
const press = usePressAnimation({ pressedScale: 0.94 });
const reduceMotion = useReducedMotion();
const { colors } = useTheme();
// Resolve pressed during render — controlled wins, else internal state.
const isControlled = pressed !== undefined;
const [internalPressed, setInternalPressed] = React.useState(defaultPressed);
const isOn = isControlled ? pressed : internalPressed;
// Pressed-state transition (button style): 0 = resting, 1 = active.
// Drives the tint layer + label color so the swap cross-fades.
const stateT = useSharedValue(isOn ? 1 : 0);
// Declared up here (not in the button branch) so hook order survives
// the switch-style early return below.
// Activation pop — a quick overshoot when the toggle lands on.
const actPop = useSharedValue(1);
const firstRender = React.useRef(true);
React.useEffect(() => {
stateT.value = reduceMotion ? (isOn ? 1 : 0) : withTiming(isOn ? 1 : 0, timing.fast);
if (firstRender.current) {
firstRender.current = false;
return;
}
if (isOn && !reduceMotion) {
actPop.value = withSequence(
withTiming(1.06, timing.instant),
withSpring(1, spring.bouncy)
);
}
}, [isOn, stateT, actPop, reduceMotion]);
// press.scale and the activation pop share one transform slot.
const containerAnim = useAnimatedStyle(() => ({
transform: [{ scale: press.scale.value * actPop.value }],
}));
const tintStyle = useAnimatedStyle(() => ({
opacity: stateT.value * TINT_OPACITY,
}));
const textStyle = useAnimatedStyle(() => ({
color: interpolateColor(stateT.value, [0, 1], [colors.secondaryForeground, colors.primaryForeground]),
}));
// --- Switch style (default) ---
if (toggleStyle === "switch") {
return (
<SwitchToggle
ref={ref}
isOn={isOn}
onToggle={() => {
// Haptics handled inside SwitchToggle.
if (!isControlled) setInternalPressed(!isOn);
onPressedChange?.(!isOn);
}}
disabled={disabled}
haptic={haptic}
label={typeof children === "string" ? children : undefined}
className={className}
{...props}
/>
);
}
// --- Button style ---
const pressedKey = isOn ? "true" : ("false" as const);
const handlePress = () => {
if (disabled) return;
if (haptic) haptics[haptic === "selection" ? "selection" : "light"]();
if (!isControlled) setInternalPressed(!isOn);
onPressedChange?.(!isOn);
};
const handlePressIn = () => {
press.onPressIn();
};
const handlePressOut = () => {
press.onPressOut();
};
return (
<AnimatedPressable
ref={ref}
disabled={disabled}
onPress={handlePress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
style={containerAnim}
className={cn(
toggleVariants({ pressed: pressedKey }),
glass && "bg-transparent",
disabled && "opacity-50",
className
)}
accessibilityRole="button"
accessibilityState={{ selected: isOn, disabled: disabled ?? false }}
// accessibilityState isn't forwarded to the DOM on web — mirror it.
// role=button takes aria-pressed; checkbox/radio callers (e.g.
// ToggleGroupItem) take aria-checked instead.
aria-pressed={
props.accessibilityRole == null || props.accessibilityRole === "button"
? isOn
: undefined
}
aria-checked={
props.accessibilityRole === "checkbox" || props.accessibilityRole === "radio"
? isOn
: undefined
}
aria-disabled={disabled ?? false}
accessibilityLabel={typeof children === "string" ? children : props.accessibilityLabel}
hitSlop={6}
{...props}
>
{glass ? (
<Glass
effect="regular"
sheen={0.5}
className="absolute inset-0 rounded-full"
pointerEvents="none"
/>
) : null}
{/* Ink tint — fades in when the toggle is on (the "ink" idiom). */}
<Animated.View
pointerEvents="none"
style={[
{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0, backgroundColor: colors.primary },
tintStyle,
]}
/>
{icon}
{typeof children === "string" ? (
<Animated.Text
style={textStyle}
className={cn(toggleTextVariants({ pressed: pressedKey }), textClassName)}
>
{children}
</Animated.Text>
) : (
children
)}
</AnimatedPressable>
);
}
);
// --- Internal switch-style toggle (reuses Switch logic) ---
const SwitchToggle = forwardRef<
React.ElementRef<typeof Pressable>,
Omit<PressableProps, "onPress" | "children" | "style"> & {
isOn: boolean;
onToggle: () => void;
disabled?: boolean;
haptic?: "selection" | "light" | false;
label?: string;
className?: string;
}
>(function SwitchToggle({ isOn, onToggle, disabled, haptic = "selection", label, className, ...props }, ref) {
const { colors } = useTheme();
const haptics = useHaptic();
const reduceMotion = useReducedMotion();
const progress = useSharedValue(isOn ? 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 the track instead of springing it.
if (reduceMotion) progress.value = isOn ? 1 : 0;
else progress.value = withSpring(isOn ? 1 : 0, spring.switch);
}, [isOn, progress, reduceMotion]);
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],
[colors.input, colors.primary]
),
}));
const thumbStyle = useAnimatedStyle(() => {
const base = PAD + progress.value * (TRACK_W - THUMB - PAD * 2);
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 },
],
};
});
const handlePress = () => {
if (disabled) {
triggerShake();
haptics.warning();
return;
}
if (haptic) haptics[haptic === "selection" ? "selection" : "light"]();
onToggle();
};
const handlePressIn = () => {
if (disabled || reduceMotion) return;
pressed.value = withSpring(1, spring.press);
};
const handlePressOut = () => {
if (disabled || reduceMotion) return;
pressed.value = withSpring(0, spring.press);
};
const switchEl = (
<Pressable
ref={ref}
// Deliberately not `disabled` — a tap on a disabled switch must still
// fire so it can shake "nope" + warn. a11y reports disabled below.
onPress={handlePress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
accessibilityRole="switch"
accessibilityState={{ checked: isOn, disabled: disabled ?? false }}
aria-checked={isOn}
aria-disabled={disabled ?? false}
aria-label={label}
hitSlop={8}
{...props}
>
<Animated.View
style={[
{ width: TRACK_W, height: TRACK_H, borderRadius: TRACK_H / 2, opacity: disabled ? 0.4 : 1 },
trackStyle,
]}
>
<Animated.View
style={[
{
width: THUMB,
height: THUMB,
borderRadius: THUMB / 2,
// Physical thumb — iOS switches ship a white knob in both themes.
backgroundColor: "#FFFFFF",
position: "absolute",
top: PAD,
// Physical-thumb drop shadow — raw black, not a theme token.
shadowColor: "#000",
shadowOpacity: 0.15,
shadowRadius: 2,
shadowOffset: { width: 0, height: 1 },
elevation: 2,
},
thumbStyle,
]}
/>
</Animated.View>
</Pressable>
);
if (label) {
return (
<Pressable
onPress={handlePress}
onPressIn={() => {
handlePressIn();
rowPress.onPressIn();
}}
onPressOut={() => {
handlePressOut();
rowPress.onPressOut();
}}
// Row is ≥44pt tall (switch 31 + py-2), hitSlop adds margin around it.
hitSlop={8}
className={cn("min-h-[44px] py-2", disabled && "opacity-50", className)}
accessibilityRole="switch"
accessibilityState={{ checked: isOn, disabled: disabled ?? false }}
aria-checked={isOn}
aria-disabled={disabled ?? false}
aria-label={label}
>
{/* 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="text-base text-foreground">{label}</Text>
{switchEl}
</Animated.View>
</Pressable>
);
}
return (
<View className={cn(disabled && "opacity-50", className)}>{switchEl}</View>
);
});
SwitchToggle.displayName = "SwitchToggle";
Toggle.displayName = "Toggle";
| pressed? | boolean | Controlled pressed/checked state. Omit with defaultPressed for uncontrolled use. | |
| defaultPressed? | boolean | Initial pressed state (uncontrolled). Default false. | |
| onPressedChange? | (pressed: boolean) => void | Called when the user toggles. | |
| style? | "button" | "switch" | Toggle style — "button" renders a pill toggle, "switch" renders an iOS switch. | |
| glass? | boolean | Liquid-glass pill (button style only). | |
| haptic? | "selection" | "light" | false | Haptic feedback. Default "selection". Set false to disable. | |
| icon? | React.ReactNode | Icon rendered before the label (button style only). | |
| textClassName? | string | Extra classes for the label. | |
| className? | string | Extra classes for the container. | |
| children? | React.ReactNode | Optional label text. In switch style, renders to the left of the switch. | |
| disabled? | boolean | Disable interaction. |
Button-style toggle with pressed state and haptics.
import { ToggleButton } from "@/components/ui/toggle/variants/button";
export function Example() {
return (
<ToggleButton />
);
} import * as React from "react";
import { View } from "react-native";
import { Toggle } from "../toggle";
import { Icon } from "../../icon/icon";
import { Text } from "../../text/text";
import { Glass } from "../../glass/glass";
import { cn } from "@lumioui/core";
/** ToggleButton — button-style toggles staged as smart-home device controls. */
export function ToggleButton() {
const [lamp, setLamp] = React.useState(true);
const [blanket, setBlanket] = React.useState(false);
const [noise, setNoise] = React.useState(false);
return (
<View className="w-full gap-3">
<Glass interactive curvature="edge" className="w-full gap-3 rounded-2xl p-4">
<View className="flex-row items-center justify-between">
<Text variant="small" className="font-semibold">Bedroom scene</Text>
<Text variant="muted" className="text-xs">Casa Miradouro · upstairs</Text>
</View>
<View className="flex-row gap-2">
{/* Device toggles — the Home-app accessory idiom. */}
<Toggle pressed={lamp} onPressedChange={setLamp} style="button">
<Icon name="moon" size="sm" color={lamp ? "primary" : "mutedForeground"} />
</Toggle>
<Toggle pressed={blanket} onPressedChange={setBlanket} style="button">
<Icon name="flame" size="sm" color={blanket ? "primary" : "mutedForeground"} />
</Toggle>
<Toggle pressed={noise} onPressedChange={setNoise} style="button">
<Icon name="volume" size="sm" color={noise ? "primary" : "mutedForeground"} />
</Toggle>
</View>
<Glass interactive curvature="edge" className="rounded-xl px-3 py-2.5">
<Text
className={cn(
"text-sm text-foreground",
(lamp || blanket || noise) && "font-medium"
)}
>
{[
lamp && "Bedside lamp · 40%",
blanket && "Blanket heat · low",
noise && "White noise · rain",
].filter(Boolean).join(" · ") || "All bedroom devices off"}
</Text>
</Glass>
</Glass>
</View>
);
}
Button-style toggle with pressed state and haptics.
import { ToggleConnectivity } from "@/components/ui/toggle/variants/connectivity";
export function Example() {
return (
<ToggleConnectivity />
);
} import * as React from "react";
import { View } from "react-native";
import { Toggle } from "../toggle";
import { Form } from "../../form/form";
import { Glass } from "../../glass/glass";
import { Icon } from "../../icon/icon";
import { Text } from "../../text/text";
/** ToggleConnectivity — switch-style toggles in a grouped glass settings section. */
export function ToggleConnectivity() {
const [airplane, setAirplane] = React.useState(false);
const [wifi, setWifi] = React.useState(true);
const [bluetooth, setBluetooth] = React.useState(true);
return (
<View className="w-full gap-3">
<Text className="text-xs font-medium uppercase tracking-wide text-white drop-shadow">
Connectivity
</Text>
<Glass interactive curvature="edge" className="w-full overflow-hidden rounded-2xl">
<Form.Row
label="Airplane Mode"
description="Disable all wireless connections"
leading={<Icon name="plane" size="sm" color="primary" />}
trailing={<Toggle pressed={airplane} onPressedChange={setAirplane} accessibilityLabel="Airplane Mode" />}
/>
<Form.Separator />
<Form.Row
label="Wi-Fi"
description={wifi ? "Connected to Home Network" : "Off"}
leading={<Icon name="wifi" size="sm" color="primary" />}
trailing={<Toggle pressed={wifi} onPressedChange={setWifi} accessibilityLabel="Wi-Fi" />}
/>
<Form.Separator />
<Form.Row
label="Bluetooth"
description={bluetooth ? "On" : "Off"}
leading={<Icon name="bluetooth" size="sm" color="primary" />}
trailing={<Toggle pressed={bluetooth} onPressedChange={setBluetooth} accessibilityLabel="Bluetooth" />}
/>
</Glass>
</View>
);
}
Button-style toggle with pressed state and haptics.
import { ToggleGlass } from "@/components/ui/toggle/variants/glass";
export function Example() {
return (
<ToggleGlass />
);
} import * as React from "react";
import { View, Text } from "react-native";
import { Toggle } from "../toggle";
import { Icon } from "../../icon/icon";
import { Ambient } from "../../glass/ambient";
/** ToggleGlass — glass pill toggles over a portrait backdrop. */
export function ToggleGlass() {
const [on, setOn] = React.useState(true);
const [on2, setOn2] = React.useState(false);
return (
<View className="relative h-[420px] w-full overflow-hidden rounded-3xl">
<Ambient seed="toggle" />
<View className="absolute inset-x-5 bottom-7 flex-row gap-2">
<Toggle glass style="button" pressed={on} onPressedChange={setOn} icon={<Icon name="bell" size="sm" color="#fff" />}>
Alerts
</Toggle>
<Toggle glass style="button" pressed={on2} onPressedChange={setOn2} icon={<Icon name="moon" size="sm" color="#fff" />}>
Quiet
</Toggle>
</View>
<View className="absolute left-5 top-5">
<Text className="text-[10px] font-semibold uppercase tracking-widest text-white/90">
GLASS PILLS
</Text>
</View>
</View>
);
}