Switch
Overview
The Switch is a binary form control that lets users switch an option on or off, rendered as a sliding switch. It is composed of two recipe parts:
useSwitchRecipe()— the label wrapper that owns the inline layout (gap, alignment), label typography, and dims itself when the field is disabled.useSwitchFieldRecipe()— the native styled input rendered as a pill track with a white knob, plus the on (checked), focus, and disabled states.
The field is the real native <input type="checkbox" role="switch">, so every state is driven by the browser: :checked recolors the track to @color.primary and slides the knob to the right, :focus-visible shows a focus ring, and :disabled dims it. The color axis sets the off track surface (light, dark, neutral) and the on-track fill stays @color.primary across all three.
The Switch recipes integrate directly with the default design tokens preset and generate type-safe utility classes at build time with zero runtime CSS.
Why use the Switch recipe?
The Switch 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,:disabled,:focus-visible), so the control stays keyboard-accessible and form-associated with zero runtime JavaScript. - Maintain consistency: The on-track fill, knob, 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 Switch 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 { useSwitchRecipe, useSwitchFieldRecipe } from '@styleframe/theme';
const s = styleframe();
const switchRecipe = useSwitchRecipe(s);
const switchField = useSwitchFieldRecipe(s);
export default s;
Build the component
Put the switchRecipe wrapper on the <label> and the switchField switch on a native <input type="checkbox" role="switch">. The role="switch" tells assistive technology to announce the control as on/off rather than checked/unchecked:
import { switchRecipe, switchField } from "virtual:styleframe";
interface SwitchProps {
color?: "light" | "dark" | "neutral";
size?: "sm" | "md" | "lg";
checked?: boolean;
disabled?: boolean;
label?: string;
}
export function Switch({
color = "neutral",
size = "md",
checked = false,
disabled = false,
label,
}: SwitchProps) {
return (
<label className={switchRecipe({ size })}>
<input
type="checkbox"
role="switch"
className={switchField({ color, size })}
checked={checked}
disabled={disabled}
readOnly
/>
<span>{label}</span>
</label>
);
}
See it in action
Colors
The Switch field includes 3 color variants: light, dark, and neutral. Like the Checkbox and Input recipes, these are neutral-spectrum surface colors rather than status colors — the color sets the off track background, while the white knob and the on-track fill (@color.primary) stay the same across all three.
The neutral color adapts automatically: a light track in light mode and a dark track in dark mode, making it the safest default for general-purpose forms.
Color Reference
| Color | Token | Use Case |
|---|---|---|
light | @color.gray-300 | Light surfaces, stays light in dark mode |
dark | @color.gray-700 | Dark surfaces, stays dark in light mode |
neutral | Adaptive (light ↔ dark) | Default color, adapts to the current color scheme |
neutral as your default switch color. It adapts to the user's color scheme automatically, so you don't need to manage light and dark tracks separately.Sizes
Three size variants from sm to lg control the track dimensions and the knob. The knob is a white SVG background-image that slides from the left of the track to the right when the switch turns on, with the travel expressed as calc(100% - 2px) so it stays a uniform inset at every size.
Size Reference
| Size | Track (W×H) | Knob |
|---|---|---|
sm | 32px × 18px | 14px |
md | 36px × 20px | 16px |
lg | 44px × 24px | 20px |
size to the switchRecipe wrapper and the switchField switch so the label typography, gap, and track scale together. The track radius is always @border-radius.full, so it stays a pill at every size.States
The field's states ride on native pseudo-classes, so they reflect the real <input> without extra wiring.
On
:checked recolors the track to @color.primary and slides the white knob to the right. Bind the native checked attribute (or v-model / controlled value) as usual.
Disabled
:disabled dims the switch 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 Switch is composed of two independent recipes: a label wrapper and the native input switch.
| Part | Recipe | Role |
|---|---|---|
| Wrapper | useSwitchRecipe() | The .switch <label> — owns inline layout (gap, alignment), label typography, and disabled-label dimming |
| Field | useSwitchFieldRecipe() | The .switch-field native <input type="checkbox" role="switch"> — owns the track, knob, color surface, and on / disabled / focus states |
<label class="switchRecipe(...)">
<input type="checkbox" role="switch" class="switchField(...)" />
<span>Enable notifications</span>
</label>
The wrapper dims its own label when it contains a disabled field, using :has(.switch-field:disabled), so a disabled switch and its text fade together without extra props.
Accessibility
- Label every switch. 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. - Add
role="switch". It keeps the native checkbox semantics (Space toggles, form submission) while telling assistive technology to announce "on / off" rather than "checked / unchecked". - Keep the native input. Styling the real
<input type="checkbox">withappearance: nonepreserves keyboard operation, focus order, and form submission for free. - 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 on state is conveyed by the knob's position, not just the track fill, satisfying WCAG 1.4.1. Keep the adjacent label text descriptive.
- Verify contrast. The on-track is
@color.primaryunder a white knob. Default tokens meet WCAG AA; if you override the primary color, verify with the WebAIM Contrast Checker.
Customization
Overriding Defaults
Each switch 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 { useSwitchFieldRecipe } from '@styleframe/theme';
const s = styleframe();
const switchField = useSwitchFieldRecipe(s, {
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 { useSwitchFieldRecipe } from '@styleframe/theme';
const s = styleframe();
// Only generate the neutral color
const switchField = useSwitchFieldRecipe(s, {
filter: {
color: ['neutral'],
},
});
export default s;
API Reference
useSwitchRecipe(s, options?)
Creates the switch wrapper recipe — the .switch <label> that lays out the switch 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 |
useSwitchFieldRecipe(s, options?)
Creates the switch field recipe — the .switch-field native <input type="checkbox" role="switch"> that owns the track, sliding knob, surface color, and native states. Accepts the same parameters as useSwitchRecipe.
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 switch scale together. - Use
neutralfor general forms: The neutral color adapts to light and dark mode automatically, making it the safest default. - Reach for a switch, not a checkbox, for instant actions: Use a switch when flipping it takes effect immediately (settings); use a Checkbox when the choice is submitted with a form.
- Mirror state on the native input: Set the real
disabledattribute in addition to styling.
FAQ
<input type="checkbox" role="switch"> with appearance: none keeps the control fully native: Space toggles it, it participates in form submission, and :checked / :disabled / :focus-visible drive the styling with zero runtime JavaScript. A custom <div> would need ARIA roles and keyboard handlers to match.neutral color sets a dark off-track under &:dark, which gains attribute-selector specificity when you toggle a [data-theme="dark"] theme. To keep the on-track @color.primary in that case, the recipe re-asserts the fill under &:dark:checked, 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.Slider
An input control for selecting a value from a range by dragging a thumb along a track. Composed of a root, track, range, and thumb recipe, with color, size, and orientation options through the recipe system.
Textarea
A multi-line text-field component with a wrapper-owned visual surface, inline prefix/suffix addons, a resize axis, and invalid/disabled/readonly states. Supports light, dark, and neutral colors, default/soft/ghost styles, and three sizes through the recipe system.