Checkbox
Overview
The Checkbox is a binary form control that lets users toggle an option on or off. It is composed of two recipe parts:
useCheckboxRecipe()— the label wrapper that owns the inline layout (gap, alignment), label typography, and dims itself when the field is disabled.useCheckboxFieldRecipe()— the native styled input, a white SVG checkmark/dash painted as a background image, and the checked, indeterminate, focus, and disabled states.
The field is the real native input, so every state is driven by the browser: :checked and :indeterminate fill the box with @color.primary, :focus-visible shows a focus ring, and :disabled dims it. The color axis sets the unchecked surface (light, dark, neutral) and the checked fill stays @color.primary across all three.
The Checkbox recipes integrate directly with the default design tokens preset and generate type-safe utility classes at build time with zero runtime CSS.
To lay several checkboxes out as a set, see the Checkbox Group recipe.
Why use the Checkbox recipe?
The Checkbox recipe helps you:
- Ship faster with sensible defaults: Get 3 surface colors and 3 sizes out of the box with a single set of composable calls.
- Build on the native input: States ride on native pseudo-classes (
:checked,:indeterminate,:disabled,:focus-visible), so the control stays keyboard-accessible and form-associated with zero runtime JavaScript. - Maintain consistency: The checked fill, checkmark, focus ring, and dark-mode surfaces follow the same design rules everywhere.
- Customize without forking: Override base styles, default variants, or filter out options you don't need — all through the options API.
- Stay type-safe: Full TypeScript support means your editor catches invalid color or size values at compile time.
- Integrate with your tokens: Every value references the design tokens preset, so theme changes propagate automatically.
Usage
Register the recipes
Add the Checkbox recipes to a local Styleframe instance. The global styleframe.config.ts provides design tokens and utilities, while the component-level file registers the recipes themselves:
import { styleframe } from 'virtual:styleframe';
import { useCheckboxRecipe, useCheckboxFieldRecipe } from '@styleframe/theme';
const s = styleframe();
const checkbox = useCheckboxRecipe(s);
const checkboxField = useCheckboxFieldRecipe(s);
export default s;
Build the component
Put the checkbox wrapper on the <label> and the checkboxField box on the native <input type="checkbox">. The indeterminate state is a DOM property (not an HTML attribute), so set it on the element via a ref:
import { useEffect, useRef } from "react";
import { checkbox, checkboxField } from "virtual:styleframe";
interface CheckboxProps {
color?: "light" | "dark" | "neutral";
size?: "sm" | "md" | "lg";
checked?: boolean;
indeterminate?: boolean;
disabled?: boolean;
label?: string;
}
export function Checkbox({
color = "neutral",
size = "md",
checked = false,
indeterminate = false,
disabled = false,
label,
}: CheckboxProps) {
const field = useRef<HTMLInputElement>(null);
useEffect(() => {
if (field.current) field.current.indeterminate = indeterminate;
}, [indeterminate]);
return (
<label className={checkbox({ size })}>
<input
ref={field}
type="checkbox"
className={checkboxField({ color, size })}
checked={checked}
disabled={disabled}
readOnly
/>
<span>{label}</span>
</label>
);
}
See it in action
Colors
The Checkbox field includes 3 color variants: light, dark, and neutral. Like the Input and Card recipes, these are neutral-spectrum surface colors rather than status colors — the color sets the unchecked box background and border, while the checked and indeterminate fill is always @color.primary.
The neutral color adapts automatically: a light surface in light mode and a dark surface in dark mode, making it the safest default for general-purpose forms.
Color Reference
| Color | Token | Use Case |
|---|---|---|
light | @color.white / @color.gray-300 | Light surfaces, stays light in dark mode |
dark | @color.gray-900 / @color.gray-600 | Dark surfaces, stays dark in light mode |
neutral | Adaptive (light ↔ dark) | Default color, adapts to the current color scheme |
neutral as your default checkbox color. It adapts to the user's color scheme automatically, so you don't need to manage light and dark surfaces separately.Sizes
Three size variants from sm to lg control the box dimensions and border radius. The checkmark is a white SVG background-image sized with background-size: contain, so it stays crisp and scales with the box.
Size Reference
| Size | Box Size | Border Radius |
|---|---|---|
sm | 14px | @border-radius.sm |
md | 16px | @border-radius.sm |
lg | 20px | @border-radius.md |
size to the checkbox wrapper and the checkboxField box so the label typography, gap, and box scale together.States
The field's states ride on native pseudo-classes, so they reflect the real <input> without extra wiring.
Checked
:checked fills the box with @color.primary and reveals the white SVG checkmark. Bind the native checked attribute (or v-model / controlled value) as usual.
Indeterminate
:indeterminate fills the box with @color.primary and shows a horizontal dash instead of the checkmark — use it for a "select all" control whose children are partially selected. Indeterminate is a DOM property only, so set el.indeterminate = true on the input via a ref (see Build the component).
Disabled
:disabled dims the box to 0.5 opacity and switches the cursor to not-allowed; the wrapper dims its label to match via :has(). Mirror it with the native disabled attribute so the input is also removed from the tab order.
Anatomy
The Checkbox is composed of two independent recipes: a label wrapper and the native input box.
| Part | Recipe | Role |
|---|---|---|
| Wrapper | useCheckboxRecipe() | The .checkbox <label> — owns inline layout (gap, alignment), label typography, and disabled-label dimming |
| Field | useCheckboxFieldRecipe() | The .checkbox-field native <input type="checkbox"> — owns the box, checkmark, color surface, and checked / indeterminate / disabled / focus states |
<label class="checkbox(...)">
<input type="checkbox" class="checkboxField(...)" />
<span>Accept terms and conditions</span>
</label>
The wrapper dims its own label when it contains a disabled field, using :has(.checkbox-field:disabled), so a disabled checkbox and its text fade together without extra props.
Accessibility
- Label every checkbox. Wrap the input and its text in the
<label>(as the wrapper does) or associate a separate<label for="...">. The recipe styles the control but does not provide a name. - Keep the native input. Styling the real
<input type="checkbox">withappearance: nonepreserves keyboard operation (Space toggles), focus order, and form submission for free. - Set
indeterminateon the element. Indeterminate is a visual + accessibility state exposed only as a DOM property; setel.indeterminate = trueso assistive technology reports "mixed". - Mirror
disabledon the input. Set the realdisabledattribute in addition to the recipe state so the field leaves the tab order. - Don't rely on color alone. The checked state is conveyed by the checkmark, not just the fill color, satisfying WCAG 1.4.1. Keep the adjacent label text descriptive.
- Verify contrast. The checkmark is
@color.whiteon a@color.primaryfill. Default tokens meet WCAG AA; if you override the primary color, verify with the WebAIM Contrast Checker.
Customization
Overriding Defaults
Each checkbox composable accepts an optional second argument to override any part of the recipe configuration. Overrides are deep-merged with the defaults, so you only need to specify the properties you want to change:
import { styleframe } from 'virtual:styleframe';
import { useCheckboxFieldRecipe } from '@styleframe/theme';
const s = styleframe();
const checkboxField = useCheckboxFieldRecipe(s, {
base: {
borderRadius: '@border-radius.md',
},
defaultVariants: {
color: 'neutral',
size: 'lg',
},
});
export default s;
Filtering Variants
If you only need a subset of the available variants, use the filter option to limit which values are generated. This reduces the output CSS and keeps your component API focused:
import { styleframe } from 'virtual:styleframe';
import { useCheckboxFieldRecipe } from '@styleframe/theme';
const s = styleframe();
// Only generate the neutral color
const checkboxField = useCheckboxFieldRecipe(s, {
filter: {
color: ['neutral'],
},
});
export default s;
API Reference
useCheckboxRecipe(s, options?)
Creates the checkbox wrapper recipe — the .checkbox <label> that lays out the box and label and dims the label when the nested field is disabled.
Parameters:
| Parameter | Type | Description |
|---|---|---|
s | Styleframe | The Styleframe instance |
options | DeepPartial<RecipeConfig> | Optional overrides for the recipe configuration |
options.base | VariantDeclarationsBlock | Custom base styles for the wrapper |
options.variants | Variants | Custom variant definitions for the recipe |
options.defaultVariants | Record<keyof Variants, string> | Default variant values for the recipe |
options.filter | Record<string, string[]> | Limit which variant values are generated |
Variants:
| Variant | Options | Default |
|---|---|---|
size | sm, md, lg | md |
useCheckboxFieldRecipe(s, options?)
Creates the checkbox field recipe — the .checkbox-field native <input type="checkbox"> that owns the box, SVG checkmark, surface color, and native states. Accepts the same parameters as useCheckboxRecipe.
Variants:
| Variant | Options | Default |
|---|---|---|
color | light, dark, neutral | neutral |
size | sm, md, lg | md |
Best Practices
- Pass
sizeto both parts: Spread the samesizeto the wrapper and the field so the label and box scale together. - Use
neutralfor general forms: The neutral color adapts to light and dark mode automatically, making it the safest default. - Mirror state on the native input: Set the real
disabledattribute and theindeterminateDOM property in addition to styling. - Group related checkboxes: Use the Checkbox Group recipe to lay out a set with consistent spacing.
- Filter what you don't need: If your forms use only the neutral color, pass a
filteroption to reduce generated CSS.
FAQ
<input type="checkbox"> with appearance: none keeps the control fully native: Space toggles it, it participates in form submission, and :checked / :indeterminate / :disabled / :focus-visible drive the styling with zero runtime JavaScript. A custom <div> would need ARIA roles and keyboard handlers to match.indeterminate is a DOM property, not an HTML attribute, so you can't set it declaratively in markup. Grab the input via a ref and assign el.indeterminate = true (see Build the component). The recipe's :indeterminate styles then render a horizontal dash in the primary-filled box.color axis controls the unchecked box surface, which reflects the form's surface rather than a status. The checked and indeterminate fill is always @color.primary, the conventional accent for a selected control. This mirrors the Input recipe's color model.neutral color sets a dark unchecked surface under &:dark, which gains attribute-selector specificity when you toggle a [data-theme="dark"] theme. To keep the checked box @color.primary in that case, the recipe re-asserts the fill under &:dark:checked and &:dark:indeterminate, so the accent always wins over the dark surface.filter option, compound variants that reference filtered-out colors are automatically removed, and default variants are adjusted if they reference a removed value — so the recipe stays consistent and only emits the CSS you use.Calendar
A date-grid styling recipe with selected, today, range, booked, disabled, and outside-month day states, week numbers, month/year selectors, presets and a time-picker footer, plus a custom cell size through the recipe system.
Checkbox Group
A layout component for arranging a set of checkboxes with shared orientation and spacing. Supports vertical and horizontal layouts and three gap sizes through the recipe system.