Styleframe Logo
Forms

Radio

A custom radio built on the native input, with a CSS dot indicator, checked, disabled, and focus states, light, dark, and neutral surface colors, and three sizes through the recipe system.

Overview

The Radio is a form control that lets users select a single option from a set. It is composed of two recipe parts:

  • useRadioRecipe() — the label wrapper that owns the inline layout (gap, alignment), label typography, and dims itself when the field is disabled.
  • useRadioFieldRecipe() — the native styled input, a white SVG dot painted as a background image, and the checked, focus, and disabled states.

The field is the real native input, so every state is driven by the browser: :checked fills the circle 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.

Radios become mutually exclusive when they share a name attribute — the browser then allows only one selection per group and wires up arrow-key navigation. The recipe is purely presentational; grouping is native.

The Radio 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 radios out as a set, see the Radio Group recipe.

Why use the Radio recipe?

The Radio 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 checked fill, dot, 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 Radio 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:

src/components/radio.styleframe.ts
import { styleframe } from 'virtual:styleframe';
import { useRadioRecipe, useRadioFieldRecipe } from '@styleframe/theme';

const s = styleframe();

const radio = useRadioRecipe(s);
const radioField = useRadioFieldRecipe(s);

export default s;

Build the component

Put the radio wrapper on the <label> and the radioField circle on the native <input type="radio">. Give every radio in the same set the same name so the browser keeps the selection mutually exclusive:

src/components/Radio.tsx
import { radio, radioField } from "virtual:styleframe";

interface RadioProps {
    color?: "light" | "dark" | "neutral";
    size?: "sm" | "md" | "lg";
    checked?: boolean;
    disabled?: boolean;
    name?: string;
    label?: string;
}

export function Radio({
    color = "neutral",
    size = "md",
    checked = false,
    disabled = false,
    name,
    label,
}: RadioProps) {
    return (
        <label className={radio({ size })}>
            <input
                type="radio"
                className={radioField({ color, size })}
                name={name}
                checked={checked}
                disabled={disabled}
                readOnly
            />
            <span>{label}</span>
        </label>
    );
}

See it in action

Colors

The Radio 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 circle background and border, while the checked 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

ColorTokenUse Case
light@color.white / @color.gray-300Light surfaces, stays light in dark mode
dark@color.gray-900 / @color.gray-600Dark surfaces, stays dark in light mode
neutralAdaptive (light ↔ dark)Default color, adapts to the current color scheme
Pro tip: Use neutral as your default radio 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 circle dimensions. The dot is a white SVG background-image sized with background-size: contain, so it stays crisp and scales with the circle. Every size uses a fully rounded border radius (@border-radius.full) so the control renders as a circle.

Size Reference

SizeCircle SizeBorder Radius
sm14px@border-radius.full
md16px@border-radius.full
lg20px@border-radius.full
Good to know: Pass the same size to the radio wrapper and the radioField circle so the label typography, gap, and circle 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 circle with @color.primary and reveals the white SVG dot. Bind the native checked attribute (or v-model / controlled value) as usual, and give radios in the same set a shared name so only one stays selected.

Disabled

:disabled dims the circle 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 Radio is composed of two independent recipes: a label wrapper and the native input circle.

PartRecipeRole
WrapperuseRadioRecipe()The .radio <label> — owns inline layout (gap, alignment), label typography, and disabled-label dimming
FielduseRadioFieldRecipe()The .radio-field native <input type="radio"> — owns the circle, dot, color surface, and checked / disabled / focus states
<label class="radio(...)">
    <input type="radio" name="plan" class="radioField(...)" />
    <span>Free plan</span>
</label>

The wrapper dims its own label when it contains a disabled field, using :has(.radio-field:disabled), so a disabled radio and its text fade together without extra props.

Accessibility

  • Label every radio. 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.
  • Group with a shared name. Radios that share a name form one group: the browser enforces single selection and moves focus between them with the arrow keys. Without a shared name, each radio toggles on its own.
  • Keep the native input. Styling the real <input type="radio"> with appearance: none preserves keyboard operation (arrow keys move within the group, Space selects), focus order, and form submission for free.
  • Mirror disabled on the input. Set the real disabled attribute in addition to the recipe state so the field leaves the tab order.
  • Don't rely on color alone. The selected state is conveyed by the dot, not just the fill color, satisfying WCAG 1.4.1. Keep the adjacent label text descriptive.
  • Verify contrast. The dot is @color.white on a @color.primary fill. Default tokens meet WCAG AA; if you override the primary color, verify with the WebAIM Contrast Checker.

Customization

Overriding Defaults

Each radio 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:

src/components/radio.styleframe.ts
import { styleframe } from 'virtual:styleframe';
import { useRadioFieldRecipe } from '@styleframe/theme';

const s = styleframe();

const radioField = useRadioFieldRecipe(s, {
    base: {
        borderWidth: '@border-width.medium',
    },
    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:

src/components/radio.styleframe.ts
import { styleframe } from 'virtual:styleframe';
import { useRadioFieldRecipe } from '@styleframe/theme';

const s = styleframe();

// Only generate the neutral color
const radioField = useRadioFieldRecipe(s, {
    filter: {
        color: ['neutral'],
    },
});

export default s;
Good to know: Filtering also removes compound variants and adjusts default variants that reference filtered-out values, so your recipe stays consistent.

API Reference

useRadioRecipe(s, options?)

Creates the radio wrapper recipe — the .radio <label> that lays out the circle and label and dims the label when the nested field is disabled.

Parameters:

ParameterTypeDescription
sStyleframeThe Styleframe instance
optionsDeepPartial<RecipeConfig>Optional overrides for the recipe configuration
options.baseVariantDeclarationsBlockCustom base styles for the wrapper
options.variantsVariantsCustom variant definitions for the recipe
options.defaultVariantsRecord<keyof Variants, string>Default variant values for the recipe
options.filterRecord<string, string[]>Limit which variant values are generated

Variants:

VariantOptionsDefault
sizesm, md, lgmd

useRadioFieldRecipe(s, options?)

Creates the radio field recipe — the .radio-field native <input type="radio"> that owns the circle, SVG dot, surface color, and native states. Accepts the same parameters as useRadioRecipe.

Variants:

VariantOptionsDefault
colorlight, dark, neutralneutral
sizesm, md, lgmd

Learn more about recipes →

Best Practices

  • Pass size to both parts: Spread the same size to the wrapper and the field so the label and circle scale together.
  • Use neutral for general forms: The neutral color adapts to light and dark mode automatically, making it the safest default.
  • Give grouped radios a shared name: Radios only become mutually exclusive when they share a name attribute — set it on every option in a set.
  • Group related radios: Use the Radio 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 filter option to reduce generated CSS.

FAQ