Checkbox
Checkbox with an optional label, controlled or uncontrolled, an indeterminate (mixed) state 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 checkboxAlso installs: theme, pressable, form-field-context, text, icon, use-controllable-state.
Packages: lucide-react-native@^1.51.0, react-native-svg.
Usage
Checkbox demo
A labelled Checkbox, a 'select all' with an indeterminate state, and a disabled one.
import { useState } from "react";
import { View } from "react-native";
import { Checkbox } from "@/components/checkbox";
import { createStyles } from "@/theme";
const useStyles = createStyles((t) => ({
root: { gap: t.spacing[4] },
nested: { gap: t.spacing[3], paddingLeft: t.spacing[6] },
}));
const TOPPINGS = ["Cheese", "Olives", "Basil"];
export default function CheckboxDemo() {
const styles = useStyles();
const [picked, setPicked] = useState<string[]>(["Cheese"]);
const all = picked.length === TOPPINGS.length;
return (
<View style={styles.root}>
<Checkbox label="Accept the terms" defaultChecked />
<Checkbox
label="All toppings"
checked={all}
indeterminate={picked.length > 0 && !all}
onCheckedChange={(checked) => setPicked(checked ? TOPPINGS : [])}
/>
<View style={styles.nested}>
{TOPPINGS.map((topping) => (
<Checkbox
key={topping}
label={topping}
checked={picked.includes(topping)}
onCheckedChange={(checked) =>
setPicked((prev) =>
checked ? [...prev, topping] : prev.filter((p) => p !== topping),
)
}
/>
))}
</View>
<Checkbox label="Unavailable" disabled />
</View>
);
}Checkbox demo
A labelled Checkbox, a 'select all' with an indeterminate state, and a disabled one.
import { useState } from "react";
import { View } from "react-native";
import { Checkbox } from "@/components/checkbox";
import { createStyles } from "@/theme";
const useStyles = createStyles((t) => ({
root: { gap: t.spacing[4] },
nested: { gap: t.spacing[3], paddingLeft: t.spacing[6] },
}));
const TOPPINGS = ["Cheese", "Olives", "Basil"];
export default function CheckboxDemo() {
const styles = useStyles();
const [picked, setPicked] = useState<string[]>(["Cheese"]);
const all = picked.length === TOPPINGS.length;
return (
<View style={styles.root}>
<Checkbox label="Accept the terms" defaultChecked />
<Checkbox
label="All toppings"
checked={all}
indeterminate={picked.length > 0 && !all}
onCheckedChange={(checked) => setPicked(checked ? TOPPINGS : [])}
/>
<View style={styles.nested}>
{TOPPINGS.map((topping) => (
<Checkbox
key={topping}
label={topping}
checked={picked.includes(topping)}
onCheckedChange={(checked) =>
setPicked((prev) =>
checked ? [...prev, topping] : prev.filter((p) => p !== topping),
)
}
/>
))}
</View>
<Checkbox label="Unavailable" disabled />
</View>
);
}Props
| Prop | Type and description |
|---|---|
checked | boolean: the controlled checked 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 box, also the accessible name |
indeterminate | boolean: shows a dash and is announced as mixed; pressing it checks the box |
disabled | boolean: dimmed, announced as disabled, no handler runs |
style | layout only, merged last onto the root |
Accessibility
- role checkbox with aria-checked true, false or mixed.
- The label is the accessible name; without a label, pass aria-label.
- The whole row (box and label) is the tap target, 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.
Notes
A light haptic tick on toggle when config.haptics is on. Inside a FormField it takes the field's disabled, and an error outlines the box in destructive. The box uses the checkbox.box Style Slot and the pressed look checkbox.pressed.
Source
The Vega Style's code, exactly as add copies it into your app.
{components}/checkbox.tsx
import { Check, Minus } from "lucide-react-native";
import { View, type GestureResponderEvent, type StyleProp, type ViewStyle } from "react-native";
import { Icon } from "@/components/icon";
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 { createStyles } from "@/theme";
export type CheckboxProps = Omit<
PressableProps,
"children" | "style" | "size" | "pressedStyle" | "haptic" | "loading" | "role"
> & {
/** Controlled checked state. */
checked?: boolean;
/** Starting checked state while uncontrolled (default `false`). */
defaultChecked?: boolean;
/** Called with the next checked state when the user toggles it. */
onCheckedChange?: (checked: boolean) => void;
/** Visible text next to the box; it is also the accessible name. */
label?: string;
/**
* Shows a dash and is announced as "mixed" (e.g. a "select all" with some items checked).
* Pressing it checks the box; the screen decides when to clear it.
*/
indeterminate?: boolean;
/** Layout only, merged last onto the root. */
style?: StyleProp<ViewStyle>;
};
const useStyles = createStyles((t) => ({
root: {
flexDirection: "row",
alignItems: "center",
gap: t.spacing[2],
alignSelf: "flex-start",
},
box: {
...{
width: t.scaleValue(20),
height: t.scaleValue(20),
borderRadius: t.radius.sm,
},
alignItems: "center",
justifyContent: "center",
borderCurve: "continuous",
borderWidth: t.borderWidth.default,
borderColor: t.colors.input,
backgroundColor: t.colors.background,
},
boxOn: { backgroundColor: t.colors.primary, borderColor: t.colors.primary },
boxError: { borderColor: t.colors.destructive },
pressed: { opacity: 0.7 },
disabled: { opacity: t.opacity.disabled },
}));
/**
* A checkbox with an optional label. Works controlled (`checked` + `onCheckedChange`) or
* uncontrolled (`defaultChecked`); `indeterminate` shows a dash and is announced as mixed.
* Built on the Pressable Primitive: the whole row is the tap target (at least 48), it gives a
* light haptic tick when `config.haptics` is on, and nothing fires while disabled. Inside a
* FormField it reads the field's label and error, takes its `disabled`, and an error outlines
* the box in `destructive`.
*/
export function Checkbox({
checked: checkedProp,
defaultChecked = false,
onCheckedChange,
label,
indeterminate = false,
disabled: disabledProp,
onPress,
style,
...props
}: CheckboxProps) {
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 [checked, setChecked] = useControllableState({
value: checkedProp,
defaultValue: defaultChecked,
onChange: onCheckedChange,
});
const on = checked || indeterminate;
const handlePress = (event: GestureResponderEvent) => {
// An indeterminate box always becomes checked.
setChecked(indeterminate ? true : !checked);
onPress?.(event);
};
const box = styles.box;
return (
<Pressable
role="checkbox"
aria-checked={indeterminate ? "mixed" : checked}
disabled={disabled}
haptic="selection"
// Without a label the box is the whole target, so its size is known before layout.
size={label == null ? { width: box.width, height: box.height } : undefined}
pressedStyle={styles.pressed}
onPress={handlePress}
style={[styles.root, disabled ? styles.disabled : null, style]}
{...field?.accessibilityProps}
{...props}
>
<View style={[box, on ? styles.boxOn : field?.status === "error" ? styles.boxError : null]}>
{on ? (
<Icon
icon={indeterminate ? Minus : Check}
size="sm"
color="primaryForeground"
strokeWidth={3}
/>
) : null}
</View>
{label != null ? <Text variant="label">{label}</Text> : null}
</Pressable>
);
}{components}/checkbox.tsx
import { Check, Minus } from "lucide-react-native";
import { View, type GestureResponderEvent, type StyleProp, type ViewStyle } from "react-native";
import { Icon } from "@/components/icon";
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 { createStyles } from "@/theme";
export type CheckboxProps = Omit<
PressableProps,
"children" | "style" | "size" | "pressedStyle" | "haptic" | "loading" | "role"
> & {
/** Controlled checked state. */
checked?: boolean;
/** Starting checked state while uncontrolled (default `false`). */
defaultChecked?: boolean;
/** Called with the next checked state when the user toggles it. */
onCheckedChange?: (checked: boolean) => void;
/** Visible text next to the box; it is also the accessible name. */
label?: string;
/**
* Shows a dash and is announced as "mixed" (e.g. a "select all" with some items checked).
* Pressing it checks the box; the screen decides when to clear it.
*/
indeterminate?: boolean;
/** Layout only, merged last onto the root. */
style?: StyleProp<ViewStyle>;
};
const useStyles = createStyles((t) => ({
root: {
flexDirection: "row",
alignItems: "center",
gap: t.spacing[2],
alignSelf: "flex-start",
},
box: {
...{
width: t.scaleValue(18),
height: t.scaleValue(18),
borderRadius: t.scaleValue(4),
},
alignItems: "center",
justifyContent: "center",
borderCurve: "continuous",
borderWidth: t.borderWidth.default,
borderColor: t.colors.input,
backgroundColor: t.colors.background,
},
boxOn: { backgroundColor: t.colors.primary, borderColor: t.colors.primary },
boxError: { borderColor: t.colors.destructive },
pressed: { opacity: 0.7 },
disabled: { opacity: t.opacity.disabled },
}));
/**
* A checkbox with an optional label. Works controlled (`checked` + `onCheckedChange`) or
* uncontrolled (`defaultChecked`); `indeterminate` shows a dash and is announced as mixed.
* Built on the Pressable Primitive: the whole row is the tap target (at least 48), it gives a
* light haptic tick when `config.haptics` is on, and nothing fires while disabled. Inside a
* FormField it reads the field's label and error, takes its `disabled`, and an error outlines
* the box in `destructive`.
*/
export function Checkbox({
checked: checkedProp,
defaultChecked = false,
onCheckedChange,
label,
indeterminate = false,
disabled: disabledProp,
onPress,
style,
...props
}: CheckboxProps) {
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 [checked, setChecked] = useControllableState({
value: checkedProp,
defaultValue: defaultChecked,
onChange: onCheckedChange,
});
const on = checked || indeterminate;
const handlePress = (event: GestureResponderEvent) => {
// An indeterminate box always becomes checked.
setChecked(indeterminate ? true : !checked);
onPress?.(event);
};
const box = styles.box;
return (
<Pressable
role="checkbox"
aria-checked={indeterminate ? "mixed" : checked}
disabled={disabled}
haptic="selection"
// Without a label the box is the whole target, so its size is known before layout.
size={label == null ? { width: box.width, height: box.height } : undefined}
pressedStyle={styles.pressed}
onPress={handlePress}
style={[styles.root, disabled ? styles.disabled : null, style]}
{...field?.accessibilityProps}
{...props}
>
<View style={[box, on ? styles.boxOn : field?.status === "error" ? styles.boxError : null]}>
{on ? (
<Icon
icon={indeterminate ? Minus : Check}
size="sm"
color="primaryForeground"
strokeWidth={3}
/>
) : null}
</View>
{label != null ? <Text variant="label">{label}</Text> : null}
</Pressable>
);
}
