Components
Radio Group
A set of mutually exclusive options. Native <input type="radio"> elements share a name attribute — the browser handles arrow-key navigation and one-selected-at-a-time for free.
Installation
1. Install via the shadcn CLI
npx shadcn@latest add http://localhost/r/radio-group.json2. Use it
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Label } from "@/components/ui/label"
<RadioGroup name="plan" ariaLabel="Plan">
<div class="flex items-center gap-2">
<RadioGroupItem value="free" name="plan" id="plan-free" checked />
<Label htmlFor="plan-free">Free</Label>
</div>
<div class="flex items-center gap-2">
<RadioGroupItem value="pro" name="plan" id="plan-pro" />
<Label htmlFor="plan-pro">Pro</Label>
</div>
</RadioGroup>Or copy the source manually
/** @jsxImportSource hono/jsx */
import type { PropsWithChildren } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"
// Radio group — shadcn-htmx, htmx v4 + Tailwind v4.
//
// Source of truth:
// repos/shadcn-ui/apps/v4/registry/new-york-v4/ui/radio-group.tsx
//
// Upstream uses Radix RadioGroup. We use native <input type="radio"> grouped
// by name — the browser handles arrow-key navigation, focus management, and
// auto-activation for free. The styling layers a custom indicator (a filled
// dot) on top of the appearance-none input via the peer-checked variant.
//
// APG: WAI-ARIA Radio Group pattern.
// repos/aria-practices/content/patterns/radio/
type RadioGroupProps = PropsWithChildren<{
name: string
defaultValue?: string
// ARIA: required is a *group-level* concept — the requirement is "one of
// these must be selected", not "this specific radio must be selected".
// We set aria-required on the wrapper. For native browser validation, pass
// the HTML `required` attribute to one (or all) RadioGroupItem(s) — the
// browser treats any required radio in a name-group as making the whole
// group required. See repos/mdn/files/en-us/web/html/reference/elements/input/radio/index.md
required?: boolean
disabled?: boolean
// Layout hint to assistive tech. Default is vertical; set "horizontal" if
// your radios sit side by side so arrow-key announcements match.
// See repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-orientation/index.md
orientation?: "horizontal" | "vertical"
// Linked error message element id. Pair with aria-invalid on items + a
// visible error text whose id matches.
// See repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-errormessage/index.md
ariaErrormessage?: string
ariaInvalid?: boolean
ariaLabel?: string
ariaLabelledby?: string
ariaDescribedby?: string
// Surface element controlled by this group's value (e.g. a panel shown
// when "Pro" is picked). Reference its id.
ariaControls?: string
class?: ClassValue
}>
export function RadioGroup(props: RadioGroupProps) {
// The wrapper is just a layout grid + role; the radios inside share the
// `name` so the browser groups them and handles arrow-key navigation.
return (
<div
role="radiogroup"
aria-label={props.ariaLabel}
aria-labelledby={props.ariaLabelledby}
aria-describedby={props.ariaDescribedby}
aria-orientation={props.orientation}
aria-required={props.required ? "true" : undefined}
aria-disabled={props.disabled ? "true" : undefined}
aria-invalid={props.ariaInvalid === undefined ? undefined : String(props.ariaInvalid)}
aria-errormessage={props.ariaErrormessage}
aria-controls={props.ariaControls}
data-slot="radio-group"
data-name={props.name}
data-default-value={props.defaultValue}
class={cn(
"grid gap-3 data-[orientation=horizontal]:grid-flow-col data-[orientation=horizontal]:auto-cols-max",
props.class,
)}
data-orientation={props.orientation}
>
{props.children}
</div>
)
}
const inputBase =
"peer aspect-square size-4 shrink-0 cursor-pointer appearance-none rounded-full border border-input bg-background shadow-xs transition-[color,box-shadow] outline-none " +
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " +
"disabled:cursor-not-allowed disabled:opacity-50 " +
"checked:border-primary " +
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 " +
"dark:bg-input/30"
const dotBase =
"pointer-events-none absolute inset-0 m-auto size-2 hidden rounded-full bg-primary peer-checked:block"
export function radioGroupItemClasses(opts?: { class?: ClassValue }): string {
return cn(inputBase, opts?.class)
}
type RadioGroupItemProps = {
// The value submitted when this radio is the selected one in the group.
value: string
id?: string
// The parent RadioGroup sets the group name; pass it through if you're not
// rendering inside a <RadioGroup> wrapper.
name?: string
// Pre-select this item.
checked?: boolean
defaultChecked?: boolean
disabled?: boolean
required?: boolean
// Associate this radio with a <form> by its id when it's rendered outside
// that form (common in SSR/htmx swaps). The form owner participates in how
// the radio button group is reconciled.
// See repos/whatwg-html/source ("The element's form owner changes").
form?: string
// Cross-load checked-state persistence control. Pass "off" to stop the
// browser re-applying a prior selection on reload / back-forward.
// See repos/mdn/files/en-us/web/html/reference/elements/input/radio/index.md (autocomplete)
autocomplete?: string
ariaLabel?: string
ariaLabelledby?: string
ariaDescribedby?: string
ariaInvalid?: boolean
class?: ClassValue
// htmx — fire on the input's change event, e.g. live filters or revealing
// dependent options when a choice is made.
// See repos/htmx/www/content/attributes/hx-trigger.md
"hx-get"?: string
"hx-post"?: string
"hx-put"?: string
"hx-patch"?: string
"hx-target"?: string
"hx-swap"?: string
"hx-trigger"?: string
"hx-vals"?: string
"hx-include"?: string
}
export function RadioGroupItem(props: RadioGroupItemProps) {
const {
class: className,
ariaLabel,
ariaLabelledby,
ariaDescribedby,
ariaInvalid,
...rest
} = props
return (
<span class="relative inline-flex size-4 shrink-0 align-middle">
<input
type="radio"
class={radioGroupItemClasses({ class: className })}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-describedby={ariaDescribedby}
aria-invalid={ariaInvalid === undefined ? undefined : String(ariaInvalid)}
data-slot="radio-group-item"
{...rest}
/>
<span class={dotBase} data-slot="radio-group-indicator" aria-hidden="true" />
</span>
)
}
1. Save the file
Copy radio-group.html into templates/components/.
2. Use it
{% from "components/radio-group.html" import radio_group_open, radio_group_close, radio_group_item %}
{% from "components/label.html" import label %}
{{ radio_group_open(aria_label="Plan") }}
<div class="flex items-center gap-2">
{{ radio_group_item(value="free", name="plan", id="plan-free", checked=true) }}
{{ label("Free", for_="plan-free") }}
</div>
<div class="flex items-center gap-2">
{{ radio_group_item(value="pro", name="plan", id="plan-pro") }}
{{ label("Pro", for_="plan-pro") }}
</div>
{{ radio_group_close() }}View source
{# RadioGroup macros — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/radio-group.tsx. Native <input type="radio"> grouped
by `name` — the browser handles arrow-key navigation + focus management.
Usage:
{% from "components/radio-group.html" import radio_group_open, radio_group_close, radio_group_item %}
{% from "components/label.html" import label %}
{{ radio_group_open(aria_label="Plan") }}
<div class="flex items-center gap-2">
{{ radio_group_item(value="free", name="plan", id="plan-free", checked=true) }}
{{ label("Free", for_="plan-free") }}
</div>
<div class="flex items-center gap-2">
{{ radio_group_item(value="pro", name="plan", id="plan-pro") }}
{{ label("Pro", for_="plan-pro") }}
</div>
{{ radio_group_close() }} #}
{% macro radio_group_open(
aria_label=none,
aria_labelledby=none,
aria_describedby=none,
aria_errormessage=none,
aria_invalid=none,
aria_controls=none,
orientation=none,
required=false,
disabled=false,
extra_class=""
) -%}
<div role="radiogroup"
{%- if aria_label %} aria-label="{{ aria_label }}"{% endif %}
{%- if aria_labelledby %} aria-labelledby="{{ aria_labelledby }}"{% endif %}
{%- if aria_describedby %} aria-describedby="{{ aria_describedby }}"{% endif %}
{%- if aria_errormessage %} aria-errormessage="{{ aria_errormessage }}"{% endif %}
{%- if aria_invalid is not none %} aria-invalid="{{ aria_invalid|string|lower }}"{% endif %}
{%- if aria_controls %} aria-controls="{{ aria_controls }}"{% endif %}
{%- if orientation %} aria-orientation="{{ orientation }}" data-orientation="{{ orientation }}"{% endif %}
{%- if required %} aria-required="true"{% endif %}
{%- if disabled %} aria-disabled="true"{% endif %}
data-slot="radio-group"
class="grid gap-3 data-[orientation=horizontal]:grid-flow-col data-[orientation=horizontal]:auto-cols-max {{ extra_class }}">
{%- endmacro %}
{% macro radio_group_close() %}</div>{% endmacro %}
{% macro radio_group_item(
value,
name,
id=none,
checked=false,
disabled=false,
required=false,
aria_label=none,
aria_labelledby=none,
aria_describedby=none,
aria_invalid=none,
extra_class="",
**attrs
) %}
{# **attrs forwards any extra attribute onto the <input> (underscores -> dashes),
e.g. hx_get -> hx-get (repos/htmx/www/content/attributes/hx-trigger.md),
plus form (associate with a <form> rendered in a separate swap) and
autocomplete="off" (control cross-load checked-state persistence). #}
{%- set base -%}
peer aspect-square size-4 shrink-0 cursor-pointer appearance-none rounded-full border border-input bg-background shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:bg-input/30
{%- endset -%}
<span class="relative inline-flex size-4 shrink-0 align-middle">
<input type="radio"
class="{{ base }} {{ extra_class }}"
value="{{ value }}"
name="{{ name }}"
{%- if id %} id="{{ id }}"{% endif %}
{%- if checked %} checked{% endif %}
{%- if disabled %} disabled{% endif %}
{%- if required %} required{% endif %}
{%- if aria_label %} aria-label="{{ aria_label }}"{% endif %}
{%- if aria_labelledby %} aria-labelledby="{{ aria_labelledby }}"{% endif %}
{%- if aria_describedby %} aria-describedby="{{ aria_describedby }}"{% endif %}
{%- if aria_invalid is not none %} aria-invalid="{{ aria_invalid|string|lower }}"{% endif %}
data-slot="radio-group-item"
{%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
>
<span class="pointer-events-none absolute inset-0 m-auto size-2 hidden rounded-full bg-primary peer-checked:block"
data-slot="radio-group-indicator" aria-hidden="true"></span>
</span>
{% endmacro %}
1. Save the file
Add radio-group.tmpl alongside button.tmpl.
2. Use it
{{template "radio_group" (dict
"AriaLabel" "Plan"
"Body" (htmlSafe `
<div class="flex items-center gap-2">
{{template "radio_group_item" (dict "Value" "free" "Name" "plan" "ID" "plan-free" "Checked" true)}}
{{template "label" (dict "For" "plan-free" "Text" "Free")}}
</div>
<div class="flex items-center gap-2">
{{template "radio_group_item" (dict "Value" "pro" "Name" "plan" "ID" "plan-pro")}}
{{template "label" (dict "For" "plan-pro" "Text" "Pro")}}
</div>`)
)}}View source
{{/*
RadioGroup templates — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/radio-group.tsx.
Three templates:
- "radio_group" — opens / closes the role="radiogroup" wrapper (use
with a Body field containing the items).
- "radio_group_item" — one radio input + indicator.
*/}}
{{define "radio_group"}}
<div role="radiogroup"
{{- if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}
{{- if .AriaLabelledby}} aria-labelledby="{{.AriaLabelledby}}"{{end}}
{{- if .Orientation}} aria-orientation="{{.Orientation}}" data-orientation="{{.Orientation}}"{{end}}
{{- if .Required}} aria-required="true"{{end}}
{{- if .Disabled}} aria-disabled="true"{{end}}
data-slot="radio-group"
class="grid gap-3 data-[orientation=horizontal]:grid-flow-col data-[orientation=horizontal]:auto-cols-max">
{{.Body}}
</div>
{{end}}
{{define "radio_group_item"}}
{{- $base := "peer aspect-square size-4 shrink-0 cursor-pointer appearance-none rounded-full border border-input bg-background shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:bg-input/30" -}}
<span class="relative inline-flex size-4 shrink-0 align-middle">
<input type="radio" class="{{$base}}"
value="{{.Value}}" name="{{.Name}}"
{{- if .ID}} id="{{.ID}}"{{end}}
{{- if .Checked}} checked{{end}}
{{- if .Disabled}} disabled{{end}}
{{- if .Required}} required{{end}}
{{- if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}
{{- if .AriaLabelledby}} aria-labelledby="{{.AriaLabelledby}}"{{end}}
{{- if .AriaDescribedby}} aria-describedby="{{.AriaDescribedby}}"{{end}}
{{- if .AriaInvalid}} aria-invalid="{{.AriaInvalid}}"{{end}}
data-slot="radio-group-item"
{{- /* .Attrs forwards any extra attribute onto the <input>: hx-* fire on
change (repos/htmx/www/content/attributes/hx-trigger.md), form to
associate with a <form> in a separate swap, autocomplete="off" for
cross-load checked-state persistence. */ -}}
{{- range $k, $v := .Attrs}} {{$k}}="{{$v}}"{{end -}}
>
<span class="pointer-events-none absolute inset-0 m-auto size-2 hidden rounded-full bg-primary peer-checked:block"
data-slot="radio-group-indicator" aria-hidden="true"></span>
</span>
{{end}}
1. Save the file
Drop radio_group.ex into lib/my_app_web/components/.
2. Use it
<.radio_group aria-label="Plan">
<div class="flex items-center gap-2">
<.radio_group_item value="free" name="plan" id="plan-free" checked />
<.label for="plan-free">Free</.label>
</div>
<div class="flex items-center gap-2">
<.radio_group_item value="pro" name="plan" id="plan-pro" />
<.label for="plan-pro">Pro</.label>
</div>
</.radio_group>View source
defmodule ShadcnHtmx.Components.RadioGroup do
@moduledoc """
Radio group — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.
Mirrors registry/ui/radio-group.tsx. Native `<input type="radio">` grouped
by `name` so the platform handles arrow-key navigation + focus management.
APG: repos/aria-practices/content/patterns/radio/.
## Examples
<.radio_group aria-label="Plan">
<div class="flex items-center gap-2">
<.radio_group_item value="free" name="plan" id="plan-free" checked />
<.label for="plan-free">Free</.label>
</div>
<div class="flex items-center gap-2">
<.radio_group_item value="pro" name="plan" id="plan-pro" />
<.label for="plan-pro">Pro</.label>
</div>
</.radio_group>
"""
use Phoenix.Component
@input_base "peer aspect-square size-4 shrink-0 cursor-pointer appearance-none rounded-full border border-input bg-background shadow-xs transition-[color,box-shadow] outline-none " <>
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " <>
"disabled:cursor-not-allowed disabled:opacity-50 " <>
"checked:border-primary " <>
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 " <>
"dark:bg-input/30"
attr :required, :boolean, default: false
attr :disabled, :boolean, default: false
attr :orientation, :string, default: nil, values: [nil, "horizontal", "vertical"]
attr :class, :string, default: nil
attr :rest, :global
slot :inner_block, required: true
def radio_group(assigns) do
~H"""
<div
role="radiogroup"
aria-required={@required && "true"}
aria-disabled={@disabled && "true"}
aria-orientation={@orientation}
data-orientation={@orientation}
data-slot="radio-group"
class={[
"grid gap-3 data-[orientation=horizontal]:grid-flow-col data-[orientation=horizontal]:auto-cols-max",
@class
]}
{@rest}
>
{render_slot(@inner_block)}
</div>
"""
end
attr :value, :string, required: true
attr :name, :string, required: true
attr :class, :string, default: nil
attr :rest, :global,
include:
# hx-* fire on the radio's change event (live filters, dependent panels).
# repos/htmx/www/content/attributes/hx-trigger.md
# form: associate with a <form> rendered in a separate swap.
# autocomplete: control cross-load checked-state persistence ("off").
~w(hx-get hx-post hx-put hx-patch hx-target hx-swap hx-trigger hx-vals hx-include
id checked disabled required form autocomplete
aria-label aria-labelledby aria-describedby aria-invalid)
def radio_group_item(assigns) do
assigns = assign(assigns, :input_base, @input_base)
~H"""
<span class="relative inline-flex size-4 shrink-0 align-middle">
<input
type="radio"
class={[@input_base, @class]}
value={@value}
name={@name}
data-slot="radio-group-item"
{@rest}
/>
<span
class="pointer-events-none absolute inset-0 m-auto size-2 hidden rounded-full bg-primary peer-checked:block"
data-slot="radio-group-indicator"
aria-hidden="true"
>
</span>
</span>
"""
end
end
1. Save the file
Tailwind utilities only; no JS required.
2. Use it
<fieldset>
<legend class="text-sm font-medium">Choose a plan</legend>
<div role="radiogroup" class="grid gap-3">
<!-- one item -->
<div class="flex items-center gap-2">
<span class="relative inline-flex size-4">
<input id="plan-free" type="radio" name="plan" value="free" checked
class="peer aspect-square size-4 …">
<span class="… peer-checked:block"></span>
</span>
<label for="plan-free">Free</label>
</div>
</div>
</fieldset>View source
<!--
shadcn-htmx — raw HTML radio group snippet.
Mirrors registry/ui/radio-group.tsx. Native <input type="radio"> grouped
by `name` — the browser handles arrow-key navigation between siblings in
the same group, auto-activates on focus, and only one can be :checked at
a time.
ITEM base:
peer aspect-square size-4 shrink-0 cursor-pointer appearance-none
rounded-full border border-input bg-background shadow-xs
transition-[color,box-shadow] outline-none
focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50
disabled:cursor-not-allowed disabled:opacity-50
checked:border-primary
aria-invalid:border-destructive aria-invalid:ring-destructive/20
dark:aria-invalid:ring-destructive/40 dark:bg-input/30
DOT base:
pointer-events-none absolute inset-0 m-auto size-2 hidden rounded-full
bg-primary peer-checked:block
-->
<fieldset>
<legend class="text-sm font-medium">Choose a plan</legend>
<div role="radiogroup" aria-label="Plan" data-slot="radio-group" class="grid gap-3">
<div class="flex items-center gap-2">
<span class="relative inline-flex size-4 shrink-0">
<input id="plan-free" type="radio" name="plan" value="free" checked
data-slot="radio-group-item"
class="peer aspect-square size-4 shrink-0 cursor-pointer appearance-none rounded-full border border-input bg-background shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:bg-input/30">
<span class="pointer-events-none absolute inset-0 m-auto size-2 hidden rounded-full bg-primary peer-checked:block" data-slot="radio-group-indicator" aria-hidden="true"></span>
</span>
<label for="plan-free" class="text-sm font-medium leading-none">Free</label>
</div>
<div class="flex items-center gap-2">
<span class="relative inline-flex size-4 shrink-0">
<input id="plan-pro" type="radio" name="plan" value="pro"
data-slot="radio-group-item"
class="peer aspect-square size-4 shrink-0 cursor-pointer appearance-none rounded-full border border-input bg-background shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:bg-input/30">
<span class="pointer-events-none absolute inset-0 m-auto size-2 hidden rounded-full bg-primary peer-checked:block" data-slot="radio-group-indicator" aria-hidden="true"></span>
</span>
<label for="plan-pro" class="text-sm font-medium leading-none">Pro</label>
</div>
<div class="flex items-center gap-2">
<span class="relative inline-flex size-4 shrink-0">
<input id="plan-team" type="radio" name="plan" value="team" disabled
data-slot="radio-group-item"
class="peer aspect-square size-4 shrink-0 cursor-pointer appearance-none rounded-full border border-input bg-background shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:bg-input/30">
<span class="pointer-events-none absolute inset-0 m-auto size-2 hidden rounded-full bg-primary peer-checked:block" data-slot="radio-group-indicator" aria-hidden="true"></span>
</span>
<label for="plan-team" class="text-sm font-medium leading-none opacity-50">Team (coming soon)</label>
</div>
</div>
</fieldset>
Examples
Basic — arrow keys cycle
Focus a radio (Tab) and press ↑/↓/←/→. The browser moves focus AND selects the next radio in the same name group.
APG's radio group pattern is "Tab enters the group on the selected item; arrows move between items in the group." The native HTML radio behaviour already does this — we just need to share a name attribute. Auto-activation (selecting on focus) is the default; if you need manual activation, that's a custom ARIA radio group widget, not native radios.
<RadioGroup name="plan" ariaLabel="Plan">
<RadioGroupItem value="free" name="plan" id="free" checked />
<Label htmlFor="free">Free</Label>
<RadioGroupItem value="pro" name="plan" id="pro" />
<Label htmlFor="pro">Pro</Label>
</RadioGroup>{{ radio_group_open(aria_label="Plan") }}
{{ radio_group_item(value="free", name="plan", id="free", checked=true) }}
{{ label("Free", for_="free") }}
{{ radio_group_item(value="pro", name="plan", id="pro") }}
{{ label("Pro", for_="pro") }}
{{ radio_group_close() }}{{template "radio_group" (dict "AriaLabel" "Plan"
"Body" (htmlSafe `
{{template "radio_group_item" (dict "Value" "free" "Name" "plan" "ID" "free" "Checked" true)}}
{{template "radio_group_item" (dict "Value" "pro" "Name" "plan" "ID" "pro")}}
`))}}<.radio_group aria-label="Plan">
<.radio_group_item value="free" name="plan" id="free" checked />
<.label for="free">Free</.label>
<.radio_group_item value="pro" name="plan" id="pro" />
<.label for="pro">Pro</.label>
</.radio_group><div role="radiogroup" aria-label="Plan" data-slot="radio-group" data-name="plan" class="grid gap-3 data-[orientation=horizontal]:grid-flow-col data-[orientation=horizontal]:auto-cols-max">
<div class="flex items-center gap-2">
<span class="relative inline-flex size-4 shrink-0 align-middle">
<input type="radio" class="peer aspect-square size-4 shrink-0 cursor-pointer appearance-none rounded-full border border-input bg-background shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:bg-input/30" data-slot="radio-group-item" value="free" name="plan" id="ex-rg-free" checked=""/>
<span class="pointer-events-none absolute inset-0 m-auto size-2 hidden rounded-full bg-primary peer-checked:block" data-slot="radio-group-indicator" aria-hidden="true">
</span>
</span>
<label for="ex-rg-free" class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="label">Free — $0/mo</label>
</div>
<div class="flex items-center gap-2">
<span class="relative inline-flex size-4 shrink-0 align-middle">
<input type="radio" class="peer aspect-square size-4 shrink-0 cursor-pointer appearance-none rounded-full border border-input bg-background shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:bg-input/30" data-slot="radio-group-item" value="pro" name="plan" id="ex-rg-pro"/>
<span class="pointer-events-none absolute inset-0 m-auto size-2 hidden rounded-full bg-primary peer-checked:block" data-slot="radio-group-indicator" aria-hidden="true">
</span>
</span>
<label for="ex-rg-pro" class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="label">Pro — $9/mo</label>
</div>
<div class="flex items-center gap-2">
<span class="relative inline-flex size-4 shrink-0 align-middle">
<input type="radio" class="peer aspect-square size-4 shrink-0 cursor-pointer appearance-none rounded-full border border-input bg-background shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:bg-input/30" data-slot="radio-group-item" value="team" name="plan" id="ex-rg-team"/>
<span class="pointer-events-none absolute inset-0 m-auto size-2 hidden rounded-full bg-primary peer-checked:block" data-slot="radio-group-indicator" aria-hidden="true">
</span>
</span>
<label for="ex-rg-team" class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="label">Team — $29/mo</label>
</div>
</div>Further reading
Disabled item + invalid group
Disable a single radio with the disabled attribute. Mark the whole group invalid with aria-invalid + describedby.
When one option in a group isn't available yet, disable just that radio — arrow keys skip it automatically. When the whole group has a validation problem (e.g. the user must pick one), apply aria-invalid="true" to each item and pair them with a single error message via aria-describedby.
<RadioGroupItem value="instant" name="freq" id="instant" disabled />
<Label htmlFor="instant">Instant (Pro plan)</Label>{{ radio_group_item(value="instant", name="freq", id="instant", disabled=true) }}
{{ label("Instant (Pro plan)", for_="instant") }}{{template "radio_group_item" (dict "Value" "instant" "Name" "freq" "ID" "instant" "Disabled" true)}}<.radio_group_item value="instant" name="freq" id="instant" disabled />
<.label for="instant">Instant (Pro plan)</.label><div role="radiogroup" aria-label="Notification frequency" data-slot="radio-group" data-name="ex-rg-d" class="grid gap-3 data-[orientation=horizontal]:grid-flow-col data-[orientation=horizontal]:auto-cols-max">
<div class="flex items-center gap-2">
<span class="relative inline-flex size-4 shrink-0 align-middle">
<input type="radio" class="peer aspect-square size-4 shrink-0 cursor-pointer appearance-none rounded-full border border-input bg-background shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:bg-input/30" data-slot="radio-group-item" value="daily" name="ex-rg-d" id="ex-rg-d-daily"/>
<span class="pointer-events-none absolute inset-0 m-auto size-2 hidden rounded-full bg-primary peer-checked:block" data-slot="radio-group-indicator" aria-hidden="true">
</span>
</span>
<label for="ex-rg-d-daily" class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="label">Daily digest</label>
</div>
<div class="flex items-center gap-2">
<span class="relative inline-flex size-4 shrink-0 align-middle">
<input type="radio" class="peer aspect-square size-4 shrink-0 cursor-pointer appearance-none rounded-full border border-input bg-background shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:bg-input/30" data-slot="radio-group-item" value="weekly" name="ex-rg-d" id="ex-rg-d-weekly"/>
<span class="pointer-events-none absolute inset-0 m-auto size-2 hidden rounded-full bg-primary peer-checked:block" data-slot="radio-group-indicator" aria-hidden="true">
</span>
</span>
<label for="ex-rg-d-weekly" class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="label">Weekly digest</label>
</div>
<div class="flex items-center gap-2">
<span class="relative inline-flex size-4 shrink-0 align-middle">
<input type="radio" class="peer aspect-square size-4 shrink-0 cursor-pointer appearance-none rounded-full border border-input bg-background shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:bg-input/30" data-slot="radio-group-item" value="instant" name="ex-rg-d" id="ex-rg-d-instant" disabled=""/>
<span class="pointer-events-none absolute inset-0 m-auto size-2 hidden rounded-full bg-primary peer-checked:block" data-slot="radio-group-indicator" aria-hidden="true">
</span>
</span>
<label for="ex-rg-d-instant" class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="label">Instant (Pro plan)</label>
</div>
</div>Further reading
htmx — save on change
Wrap the group in a form and post on every change. The server records the choice; the response can swap a status row in lockstep.
For settings rows (notifications, themes, default views) you often want to persist the user's pick the moment they make it. hx-trigger="change" on the wrapping <form> fires on every radio toggle and submits the full form payload (including the radio name + value) to the endpoint.
<form hx-post="/api/theme" hx-trigger="change"
hx-target="#status" hx-swap="innerHTML">
<RadioGroup name="theme">…</RadioGroup>
<p id="status" aria-live="polite" />
</form><form hx-post="/api/theme" hx-trigger="change"
hx-target="#status" hx-swap="innerHTML">
{{ radio_group_open() }}…{{ radio_group_close() }}
<p id="status" aria-live="polite"></p>
</form><form hx-post="/api/theme" hx-trigger="change"
hx-target="#status" hx-swap="innerHTML">
{{template "radio_group" (dict "Body" (htmlSafe `…`))}}
<p id="status" aria-live="polite"></p>
</form><form hx-post={~p"/api/theme"} hx-trigger="change"
hx-target="#status" hx-swap="innerHTML">
<.radio_group>…</.radio_group>
<p id="status" aria-live="polite"></p>
</form><form hx-post="/radio-group/save" hx-trigger="change" hx-target="#ex-rg-status" hx-swap="innerHTML" class="grid gap-3">
<div role="radiogroup" aria-label="Theme" data-slot="radio-group" data-name="theme" class="grid gap-3 data-[orientation=horizontal]:grid-flow-col data-[orientation=horizontal]:auto-cols-max">
<div class="flex items-center gap-2">
<span class="relative inline-flex size-4 shrink-0 align-middle">
<input type="radio" class="peer aspect-square size-4 shrink-0 cursor-pointer appearance-none rounded-full border border-input bg-background shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:bg-input/30" data-slot="radio-group-item" value="light" name="theme" id="ex-rg-theme-light" checked=""/>
<span class="pointer-events-none absolute inset-0 m-auto size-2 hidden rounded-full bg-primary peer-checked:block" data-slot="radio-group-indicator" aria-hidden="true">
</span>
</span>
<label for="ex-rg-theme-light" class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="label">Light</label>
</div>
<div class="flex items-center gap-2">
<span class="relative inline-flex size-4 shrink-0 align-middle">
<input type="radio" class="peer aspect-square size-4 shrink-0 cursor-pointer appearance-none rounded-full border border-input bg-background shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:bg-input/30" data-slot="radio-group-item" value="dark" name="theme" id="ex-rg-theme-dark"/>
<span class="pointer-events-none absolute inset-0 m-auto size-2 hidden rounded-full bg-primary peer-checked:block" data-slot="radio-group-indicator" aria-hidden="true">
</span>
</span>
<label for="ex-rg-theme-dark" class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="label">Dark</label>
</div>
<div class="flex items-center gap-2">
<span class="relative inline-flex size-4 shrink-0 align-middle">
<input type="radio" class="peer aspect-square size-4 shrink-0 cursor-pointer appearance-none rounded-full border border-input bg-background shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:bg-input/30" data-slot="radio-group-item" value="system" name="theme" id="ex-rg-theme-system"/>
<span class="pointer-events-none absolute inset-0 m-auto size-2 hidden rounded-full bg-primary peer-checked:block" data-slot="radio-group-indicator" aria-hidden="true">
</span>
</span>
<label for="ex-rg-theme-system" class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="label">System</label>
</div>
</div>
<p id="ex-rg-status" class="text-xs text-muted-foreground" aria-live="polite">Pick a theme to save it.</p>
</form>Further reading
API Reference
<RadioGroup>
| Prop | Type | Default | Description |
|---|---|---|---|
form | string | — | Associate the radio with a <form> by its id when rendered outside that form (common in SSR/htmx swaps).MDN<input> form |
autocomplete | string | — | Control cross-load checked-state persistence. Pass "off" to stop the browser re-applying a prior selection on reload / back-forward.MDNinput/radio autocomplete |
hx-* | any | — | Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference |
name* | string | — | Shared name attribute. All radios in the group must use it. |
defaultValue | string | — | Initially selected value. |
required | boolean | — | Set aria-required at the group level + native required on the first item. |
disabled | boolean | — | Disable the whole group. |
orientation | "horizontal"|"vertical" | "vertical" | Layout + aria-orientation. |
ariaErrormessage | string | — | Id of a visible error message for the group. |
ariaLabel | string | — | Accessible name when no visible <label>.MDNaria-label |
ariaLabelledby | string | — | Id of a visible element providing the accessible name.MDNaria-labelledby |
ariaDescribedby | string | — | Id of an element describing this control (announced after the name).MDNaria-describedby |
class | string | — | Extra Tailwind classes appended to the root element. |
* required