shshadcn-htmx

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.json

2. Use it

components/ui/radio-group.tsx
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
components/ui/radio-group.tsx
/** @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

templates/components/radio-group.html
{% 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
templates/components/radio-group.html
{# 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

templates/components/radio-group.tmpl
{{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
templates/components/radio-group.tmpl
{{/*
  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

lib/my_app_web/components/radio_group.ex
<.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
lib/my_app_web/components/radio_group.ex
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

index.html
<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
index.html
<!--
  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>

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>

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.

Pick a theme to save it.

<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>

API Reference

<RadioGroup>

PropTypeDefaultDescription
formstring
Associate the radio with a <form> by its id when rendered outside that form (common in SSR/htmx swaps).MDN<input> form
autocompletestring
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.
defaultValuestring
Initially selected value.
requiredboolean
Set aria-required at the group level + native required on the first item.
disabledboolean
Disable the whole group.
orientation"horizontal"|"vertical""vertical"
Layout + aria-orientation.
ariaErrormessagestring
Id of a visible error message for the group.
ariaLabelstring
Accessible name when no visible <label>.MDNaria-label
ariaLabelledbystring
Id of a visible element providing the accessible name.MDNaria-labelledby
ariaDescribedbystring
Id of an element describing this control (announced after the name).MDNaria-describedby
classstring
Extra Tailwind classes appended to the root element.

* required