Animated check box with haptic tick and error state.
components/ui/checkbox/checkbox.tsx@lumioui/core@latestreact-native-reanimated@*react-native-svg@*Live preview — the real component, driven by the props below.
import { Checkbox } from "@/components/ui/checkbox/checkbox";
export function Example() {
return (
<Checkbox />
);
} import * as React from "react";
import { forwardRef } from "react";
import { Pressable, Text, View, type PressableProps, type GestureResponderEvent } from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
useAnimatedProps,
withDelay,
withSpring,
withTiming,
interpolateColor,
} from "react-native-reanimated";
import Svg from "react-native-svg";
import { cn, useHaptic, usePressAnimation, useTheme, useReducedMotion, spring, timing, AnimatedPath } from "@lumioui/core";
import { Icon, type IconName } from "../icon";
/** Checkmark stroke — drawn on via strokeDashoffset (~20 units, padded to 24). */
const CHECK_PATH = "M5 13l4 4L19 7";
/** Indeterminate dash. */
const DASH_PATH = "M6 12h12";
/** Upper bound of either mark's path length — used for the dasharray trick. */
const CHECK_LEN = 24;
/** beui draw choreography — pop lands first (40ms delay), then the mark draws itself. */
const DRAW_IN_TIMING = { duration: 300 };
/** Undraw ~2x quicker than the draw so clearing feels decisive. */
const DRAW_OUT_TIMING = { duration: 140 };
export interface CheckboxProps extends Omit<PressableProps, "onPress" | "children"> {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
/** Renders a dash instead of a check (partial selection). Implies the filled state. */
indeterminate?: boolean;
/** Text label rendered beside the box — tapping it toggles too, and the
* box announces it via accessibilityLabelledBy/aria-labelledby. */
label?: string;
/** Leading icon rendered between the box and the label. */
icon?: IconName;
/** Secondary text under the label. */
subtitle?: string;
disabled?: boolean;
className?: string;
}
/** Checkbox — SVG draw-on checkmark, indeterminate dash, spring scale on tap, haptic. */
export const Checkbox = forwardRef<React.ElementRef<typeof Pressable>, CheckboxProps>(
function Checkbox({ checked, onCheckedChange, indeterminate = false, label, icon, subtitle, disabled, className, ...props }, ref) {
const { colors } = useTheme();
const haptics = useHaptic();
const reducedMotion = useReducedMotion();
const active = checked || indeterminate;
// Label association — the rendered text carries a nativeID the
// Pressable points back to (accessibilityLabelledBy / aria-labelledby).
const autoId = React.useId();
const labelId = `${autoId}-label`;
const press = usePressAnimation({ pressedScale: 0.9 });
/** 0 → mark hidden, 1 → mark fully drawn. */
const draw = useSharedValue(active ? 1 : 0);
/** Pop scale for the mark. */
const pop = useSharedValue(active ? 1 : 0.5);
/** 0 → empty box, 1 → filled box. Drives the fill + border crossfade so
* the check lands on a surface that's already arriving, not snapping. */
const fill = useSharedValue(active ? 1 : 0);
React.useEffect(() => {
if (reducedMotion) {
draw.value = active ? 1 : 0;
pop.value = active ? 1 : 0.5;
fill.value = active ? 1 : 0;
return;
}
// The fill races ahead of the mark draw so the check reads as ink
// landing on a filled surface (beui: bg transition-colors + draw-on).
fill.value = withTiming(active ? 1 : 0, timing.fast);
if (active) {
pop.value = 0.5;
// spring.pop — the mark lands with a bounce after the box fills.
pop.value = withSpring(1, spring.pop);
draw.value = withDelay(40, withTiming(1, DRAW_IN_TIMING));
} else {
draw.value = withTiming(0, DRAW_OUT_TIMING);
pop.value = 0.5;
}
}, [active, draw, pop, fill, reducedMotion]);
const boxStyle = useAnimatedStyle(() => ({
transform: [{ scale: press.scale.value }],
borderColor: interpolateColor(fill.value, [0, 1], [colors.muted, colors.primary]),
shadowColor: colors.primary,
shadowOpacity: fill.value * 0.2,
shadowRadius: 3,
shadowOffset: { width: 0, height: 1 },
elevation: fill.value * 2,
}));
// Primary fill lives on its own layer so it can fade in — interpolating
// a backgroundColor to "transparent" muddies the midpoint.
const fillStyle = useAnimatedStyle(() => ({
opacity: fill.value,
}));
const popStyle = useAnimatedStyle(() => ({
transform: [{ scale: pop.value }],
opacity: draw.value === 0 ? 0 : 1,
}));
const pathProps = useAnimatedProps(() => ({
strokeDashoffset: (1 - draw.value) * CHECK_LEN,
}));
const handlePress = (_e: GestureResponderEvent) => {
haptics.selection();
onCheckedChange(!checked);
};
return (
<Pressable
ref={ref}
disabled={disabled}
onPress={handlePress}
onPressIn={press.onPressIn}
onPressOut={press.onPressOut}
className={cn(label != null && "flex-row items-center gap-2", disabled && "opacity-50", className)}
accessibilityRole="checkbox"
accessibilityLabel={label ?? props.accessibilityLabel}
accessibilityLabelledBy={label != null ? labelId : undefined}
aria-labelledby={label != null ? labelId : undefined}
accessibilityState={{ checked: indeterminate ? "mixed" : checked, disabled }}
// accessibilityState isn't forwarded to the DOM on web — mirror it
// with explicit aria-* props like Switch/Toggle do.
aria-checked={indeterminate ? "mixed" : checked}
aria-disabled={disabled ?? false}
hitSlop={10}
{...props}
>
<Animated.View
style={[{ width: 24, height: 24, borderRadius: 6, borderWidth: 2, overflow: "hidden" }, boxStyle]}
className="items-center justify-center"
>
{/* Fill layer — fades in beneath the mark */}
<Animated.View
pointerEvents="none"
style={[
{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0, borderRadius: 4, backgroundColor: colors.primary },
fillStyle,
]}
/>
<Animated.View style={popStyle}>
<Svg width={16} height={16} viewBox="0 0 24 24" fill="none">
<AnimatedPath
d={indeterminate ? DASH_PATH : CHECK_PATH}
stroke={colors.primaryForeground}
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray={CHECK_LEN}
animatedProps={pathProps}
/>
</Svg>
</Animated.View>
</Animated.View>
{icon ? (
<View className="h-8 w-8 items-center justify-center rounded-lg bg-muted">
<Icon name={icon} size="sm" color="foreground" />
</View>
) : null}
{label != null ? (
<View className="gap-0.5">
<Text nativeID={labelId} className="text-base text-foreground">
{label}
</Text>
{subtitle ? (
<Text className="text-sm text-muted-foreground" numberOfLines={1}>{subtitle}</Text>
) : null}
</View>
) : null}
</Pressable>
);
}
);
Checkbox.displayName = "Checkbox";
| checked | boolean | ||
| onCheckedChange | (checked: boolean) => void | ||
| indeterminate? | boolean | Renders a dash instead of a check (partial selection). Implies the filled state. | |
| label? | string | Text label rendered beside the box — tapping it toggles too, and the box announces it via accessibilityLabelledBy/aria-labelledby. | |
| icon? | IconName | Leading icon rendered between the box and the label. | |
| subtitle? | string | Secondary text under the label. | |
| disabled? | boolean | ||
| className? | string |
Animated check box with haptic tick and error state.
import { CheckboxList } from "@/components/ui/checkbox/variants/list";
export function Example() {
return (
<CheckboxList />
);
} import * as React from "react";
import { View } from "react-native";
import { Checkbox } from "../checkbox";
import { Text } from "../../text/text";
import { Glass } from "../../glass/glass";
/** CheckboxList — grocery checklist with icons, subtitles, and per-row price. */
export function CheckboxList() {
const [items, setItems] = React.useState({ coffee: true, bread: false, eggs: false });
const toggle = (key: "coffee" | "bread" | "eggs") =>
setItems((s) => ({ ...s, [key]: !s[key] }));
const rows: {
key: "coffee" | "bread" | "eggs";
label: string;
icon: "coffee" | "wheat" | "package";
subtitle: string;
price: string;
}[] = [
{ key: "coffee", label: "Single-origin beans", icon: "coffee", subtitle: "250g bag · Ethiopia", price: "$18.00" },
{ key: "bread", label: "Sourdough loaf", icon: "wheat", subtitle: "Baked this morning", price: "$7.50" },
{ key: "eggs", label: "Eggs — 5 of 12", icon: "package", subtitle: "Partial selection", price: "" },
];
return (
<View className="w-full gap-3">
<Glass interactive curvature="edge" className="w-full gap-1 rounded-2xl p-2">
{rows.map((row, i) => (
<View
key={row.key}
className={`flex-row items-center rounded-lg px-2 py-2.5 ${i > 0 ? "border-t border-border" : ""}`}
>
<Checkbox
checked={row.key === "eggs" ? false : items[row.key]}
indeterminate={row.key === "eggs"}
onCheckedChange={row.key === "eggs" ? () => {} : () => toggle(row.key)}
icon={row.icon}
label={row.label}
subtitle={row.subtitle}
/>
{row.price ? <Text variant="muted" className="ml-auto">{row.price}</Text> : null}
</View>
))}
</Glass>
</View>
);
}
Animated check box with haptic tick and error state.
import { CheckboxStates } from "@/components/ui/checkbox/variants/states";
export function Example() {
return (
<CheckboxStates />
);
} import * as React from "react";
import { View } from "react-native";
import { Checkbox } from "../checkbox";
import { Text } from "../../text/text";
import { Glass } from "../../glass/glass";
/** CheckboxStates — indeterminate and disabled states side by side. */
export function CheckboxStates() {
return (
<View className="w-full gap-3">
<Glass interactive curvature="edge" className="w-full gap-1 rounded-2xl p-2">
<View className="flex-row items-center gap-3 rounded-lg px-2 py-2.5">
<Checkbox checked={false} indeterminate onCheckedChange={() => {}} />
<Text className="flex-1">Partial — 5 of 12 items selected</Text>
</View>
<View className="flex-row items-center gap-3 rounded-lg border-t border-border px-2 py-2.5">
<Checkbox checked={false} onCheckedChange={() => {}} disabled />
<Text variant="muted" className="flex-1">Out of stock — Oat milk</Text>
</View>
</Glass>
</View>
);
}