Checkbox & Radio

Free · MIT

Checkbox and RadioGroup on real inputs with default, filled, outline, circle, neo and whole-card variants, draw, pop and bounce animations, tones, sizes, shadows, indeterminate, invalid and descriptions.

MarkdownOpen in v0
Open in v0

Installation

$ npx shadcn@latest add https://goodui.dev/r/checkbox.json

The file lands in components/goodui/checkbox.tsx and is yours to edit. With the @goodui registry added, npx shadcn@latest add @goodui/checkbox works too.

Usage

import * as React from "react"
import { Checkbox, Radio, RadioGroup } from "@/components/goodui/checkbox"

export function BookingOptions() {
  const [plan, setPlan] = React.useState("monthly")
  const [terms, setTerms] = React.useState(false)

  return (
    <form className="flex max-w-md flex-col gap-5">
      <RadioGroup value={plan} onValueChange={setPlan} variant="card" name="plan" aria-label="Billing period">
        <Radio value="monthly" label="Monthly" description="$29 per month, cancel any time" />
        <Radio value="yearly" label="Yearly" description="$290 per year, two months free">
          <span className="mt-1 text-xs font-medium text-emerald-600 dark:text-emerald-400">Most popular</span>
        </Radio>
      </RadioGroup>
      <Checkbox name="sms" defaultChecked label="SMS reminders" description="Text clients 24 hours before each booking." />
      <Checkbox checked={terms} onCheckedChange={setTerms} label="I accept the terms of service" required />
    </form>
  )
}

When to use

Use it for
  • Choices submitted with a form: accept the terms, pick add-ons, choose notification channels.
  • Select-all in a table header with some rows selected: indeterminate.
  • Plan, time slot or payment method pickers where each option is a card with a price: variant="card" on RadioGroup.
  • One choice from a short visible list (2 to 5 options): RadioGroup with Radio.
Reach for something else
  • A setting that applies immediately: use switch.
  • One choice from a long list: use select.
  • Switching views or filters at the top of a list: use tabs or segmented-control.
If you needUse
Standard form checkboxvariant="default" animation="draw" (defaults)
Options with a price or detailsvariant="card" with description and children
Round checkboxes (to-do lists, image pickers)variant="circle"
Quieter checked statevariant="outline" (tinted box with a colored mark)
Playful or retro brandsvariant="neo" with animation="bounce"
Snappy feedback in dense tablesanimation="pop" size="sm"
Settings list with the box at the endlabelPosition="left"

Examples

Select all with indeterminate

const all = selected.length === rows.length
const some = selected.length > 0 && !all

<Checkbox
  aria-label="Select all bookings"
  checked={all}
  indeterminate={some}
  onCheckedChange={(on) => setSelected(on ? rows.map((r) => r.id) : [])}
  size="sm"
/>

Horizontal radio group

<RadioGroup orientation="horizontal" defaultValue="card" aria-label="Payment method" animation="pop">
  <Radio value="card" label="Card" />
  <Radio value="cash" label="Cash" />
  <Radio value="invoice" label="Invoice" />
</RadioGroup>

API reference

Checkbox

PropTypeDefault
children
Extra content under the description (prices, badges), handy with the card variant.
React.ReactNode—
variant
Box style: default, filled, outline (tinted when checked), circle, neo, or card (the whole card is the hit area).
"default" | "filled" | "outline" | "circle" | "neo" | "card""default"
tone
Color when checked.
"primary" | "neutral" | "success" | "warning" | "danger" | "info""primary"
animation
How the mark appears: draw (stroke, default), pop, bounce or none.
"none" | "draw" | "pop" | "bounce""draw"
shadow
Box shadow; glow only shows when checked.
"none" | "sm" | "glow""none"
size"sm" | "md" | "lg""md"
label
Visible label, linked with aria-labelledby.
React.ReactNode—
description
Muted helper text under the label, linked with aria-describedby.
React.ReactNode—
labelPosition
Which side of the box the text sits on. "left" spreads the row (text start, box end).
"left" | "right"—
invalid
Shows the danger state, e.g. an unaccepted required checkbox.
boolean—
checked
Checked state (controlled).
boolean—
defaultChecked
Initial state when uncontrolled.
booleanfalse
onCheckedChange
Called with the new checked state.
(checked: boolean) => void—
onChange
Native change event of the hidden input.
React.ChangeEventHandler<HTMLInputElement>—
indeterminate
Shows a dash, e.g. a "select all" with some rows selected. Clicking it checks the box.
booleanfalse
sound
Plays "pop" when checked and "tick" when unchecked, when the visitor has sounds on (lib/goodui-sounds); pass a sound name to use one sound for both. Off by default.
boolean | SoundName—

Also accepts every <input> attribute.

RadioGroup

PropTypeDefault
variant
Box style: default, filled, outline (tinted when checked), circle, neo, or card (the whole card is the hit area).
"default" | "filled" | "outline" | "circle" | "neo" | "card""default"
tone
Color when checked.
"primary" | "neutral" | "success" | "warning" | "danger" | "info""primary"
animation
How the mark appears: draw (stroke, default), pop, bounce or none.
"none" | "draw" | "pop" | "bounce""draw"
shadow
Box shadow; glow only shows when checked.
"none" | "sm" | "glow""none"
size"sm" | "md" | "lg""md"
value
Selected radio value (controlled).
string—
defaultValue
Initially selected value when uncontrolled.
string—
onValueChange
Called with the value of the newly selected radio.
(value: string) => void—
name
Form field name. Generated when omitted.
string—
disabled
Disables every radio in the group.
boolean—
orientation
horizontal lays the radios out in equal columns.
"vertical" | "horizontal""vertical"
labelPosition
Default text side for every radio.
"left" | "right"—

Also accepts every <div> attribute.

Radio

PropTypeDefault
value*
Value reported to RadioGroup and submitted with the form.
string—
children
Extra content under the description (prices, badges), handy with the card variant.
React.ReactNode—
variant
Box style: default, filled, outline (tinted when checked), circle, neo, or card (the whole card is the hit area).
"default" | "filled" | "outline" | "circle" | "neo" | "card"—
tone
Color when checked.
"primary" | "neutral" | "success" | "warning" | "danger" | "info"—
animation
How the mark appears: draw (stroke, default), pop, bounce or none.
"none" | "draw" | "pop" | "bounce"—
shadow
Box shadow; glow only shows when checked.
"none" | "sm" | "glow"—
size"sm" | "md" | "lg"—
label
Visible label, linked with aria-labelledby.
React.ReactNode—
description
Muted helper text under the label, linked with aria-describedby.
React.ReactNode—
labelPosition
Which side of the box the text sits on. "left" spreads the row (text start, box end).
"left" | "right"—
invalid
Shows the danger state, e.g. an unaccepted required checkbox.
boolean—

Also accepts every <input> attribute.

Accessibility

SpaceToggles the focused checkbox or selects the focused radio (native <input>).
Arrow keysMove and select within a RadioGroup, because its radios share one name (native behavior).
TabMoves focus; the box (or the whole card) shows a tone-colored focus ring on keyboard focus only.
  • Real <input type="checkbox"> and <input type="radio"> elements sit invisibly on the box, so forms, required, autofill and screen readers work natively.
  • Each option is a <label>, so the text and the whole card are click targets. label and description are linked with aria-labelledby and aria-describedby.
  • indeterminate sets the native indeterminate property, which screen readers announce as mixed.
  • Checkbox invalid sets aria-invalid; on Radio it only changes the color, so also show an error message.
  • RadioGroup renders role="radiogroup"; give it a name with aria-label or aria-labelledby. Without label, give a Checkbox an aria-label.

Motion

  • draw (default): the check stroke draws in over 300 ms after a 50 ms delay and retracts in 260 ms; a radio dot scales in over 200 ms.
  • pop: the box pops from 0.72 past 1.14 back to 1 in 340 ms when checked. bounce: the mark springs in with overshoot over 380 ms while the box squishes for 400 ms.
  • Press: the box shrinks to 0.9 (neo shifts into its shadow). Colors and borders change in 160 ms.
  • pop and bounce only play after the user has interacted, never on first render.
  • Reduced motion: no transitions or keyframes; the check and dot appear fully drawn at once.

Notes for AI

Coding agents read these rules through MCP and llms.txt, or paste this page with Copy page.

  • Callbacks: Checkbox uses onCheckedChange(checked: boolean); RadioGroup uses onValueChange(value: string). onChange still gets the native event.
  • Radio must be inside RadioGroup and needs a value. Set variant, tone, animation, shadow and size once on RadioGroup; a Radio can override them.
  • Compute indeterminate from your data (some selected, not all); clicking calls onCheckedChange with the new checked value, so clear indeterminate there. If you keep it true, the native indeterminate flag is re-applied after the click, so the box and screen readers stay in sync.
  • ref reaches the hidden native <input type="checkbox">, and an uncontrolled Checkbox stays native (defaultChecked), so react-hook-form's register() works.
  • children render under the description, inside the label: put prices or badges there, not interactive elements like links or buttons.
  • There is no built-in text, so no labels prop. RadioGroup generates name when you omit it; pass name for native form submission.

FAQ

Does it work with react-hook-form?

Yes. Spread register("terms") onto a Checkbox (the ref reaches the native input), or use a Controller: pass field.value to checked (or value on RadioGroup) and field.onChange to onCheckedChange (or onValueChange).

How do I make the whole card selectable?

Use variant="card". The card is the <label>, so clicking anywhere on it selects the option, and it gets a tone-colored border when checked.

Related