🔥 Oxide UI v0.1.0 — Dark mode only, copy-paste ready. Get started →
Skip to content

Radio Group

<input type="radio"> + Headless UI RadioGroup

Allows selecting exactly one option from a set. Three visual styles: card picker, pill row, and classic.

Overview

Preview

Plan

Period

Selected: Monthly

Card picker (most expressive)

Preview
html
<fieldset class="space-y-2">
  <legend class="sr-only">Choose a plan</legend>

  <!-- Selected option -->
  <label class="flex cursor-pointer items-start gap-3 rounded-lg border border-[#D40C37]/50 bg-[#D40C37]/5 p-4">
    <input type="radio" name="plan" value="pro" class="sr-only" checked />
    <div class="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2 border-[#D40C37]">
      <div class="h-1.5 w-1.5 rounded-full bg-[#D40C37]"></div>
    </div>
    <div>
      <p class="text-sm font-semibold text-zinc-100">Pro — $9/mo</p>
      <p class="text-xs text-zinc-500 mt-0.5">Unlimited + API access</p>
    </div>
  </label>

  <!-- Unselected option -->
  <label class="flex cursor-pointer items-start gap-3 rounded-lg border border-zinc-800 p-4 hover:border-zinc-700 transition-colors">
    <input type="radio" name="plan" value="team" class="sr-only" />
    <div class="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2 border-zinc-600"></div>
    <div>
      <p class="text-sm font-semibold text-zinc-300">Team — $29/mo</p>
      <p class="text-xs text-zinc-500 mt-0.5">Up to 10 seats</p>
    </div>
  </label>
</fieldset>

Pill row

Preview
html
<div class="flex gap-1 rounded-lg bg-zinc-900 p-1">
  <label class="relative cursor-pointer rounded-md bg-zinc-700 px-4 py-1.5 text-sm font-medium text-zinc-100 shadow-sm">
    <input type="radio" name="period" value="monthly" class="sr-only" checked />
    Monthly
  </label>
  <label class="relative cursor-pointer rounded-md px-4 py-1.5 text-sm font-medium text-zinc-500 hover:text-zinc-300 transition-colors">
    <input type="radio" name="period" value="yearly" class="sr-only" />
    Yearly
  </label>
</div>

Classic radio inputs

Preview
html
<fieldset class="space-y-3">
  <legend class="sr-only">Notification preference</legend>

  <label class="flex cursor-pointer items-center gap-3">
    <input type="radio" name="notify" value="all" class="h-4 w-4 accent-[#D40C37] cursor-pointer" />
    <div>
      <p class="text-sm text-zinc-200">All notifications</p>
      <p class="text-xs text-zinc-500">Get notified about all activity.</p>
    </div>
  </label>
  <!-- repeat -->
</fieldset>

With Vue reactivity

vue
<script setup>
import { ref } from 'vue'
const selected = ref('pro')
const plans = [
  { value: 'free', label: 'Free', desc: '100 downloads / day' },
  { value: 'pro', label: 'Pro — $9/mo', desc: 'Unlimited + API' },
]
</script>

<template>
  <div class="space-y-2">
    <label v-for="plan in plans" :key="plan.value"
      class="flex cursor-pointer items-start gap-3 rounded-lg border p-4 transition-all"
      :class="selected === plan.value ? 'border-[#D40C37]/50 bg-[#D40C37]/5' : 'border-zinc-800 hover:border-zinc-700'"
    >
      <input type="radio" :value="plan.value" v-model="selected" class="sr-only" />
      <div class="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2 transition-all"
        :class="selected === plan.value ? 'border-[#D40C37]' : 'border-zinc-600'"
      >
        <div v-if="selected === plan.value" class="h-1.5 w-1.5 rounded-full bg-[#D40C37]"></div>
      </div>
      <div>
        <p class="text-sm font-semibold text-zinc-100">{{ plan.label }}</p>
        <p class="text-xs text-zinc-500 mt-0.5">{{ plan.desc }}</p>
      </div>
    </label>
  </div>
</template>

With Headless UI RadioGroup

vue
<script setup>
import { RadioGroup, RadioGroupLabel, RadioGroupOption } from '@headlessui/vue'
import { ref } from 'vue'
const selected = ref('pro')
</script>

<template>
  <RadioGroup v-model="selected" class="space-y-2">
    <RadioGroupLabel class="sr-only">Choose a plan</RadioGroupLabel>
    <RadioGroupOption v-for="plan in plans" :key="plan.value" :value="plan.value" v-slot="{ checked }">
      <div :class="['flex cursor-pointer items-start gap-3 rounded-lg border p-4 transition-all focus:outline-none',
        checked ? 'border-[#D40C37]/50 bg-[#D40C37]/5' : 'border-zinc-800 hover:border-zinc-700']">
        <div :class="['mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2',
          checked ? 'border-[#D40C37]' : 'border-zinc-600']">
          <div v-if="checked" class="h-1.5 w-1.5 rounded-full bg-[#D40C37]"></div>
        </div>
        <div>
          <p class="text-sm font-semibold text-zinc-100">{{ plan.label }}</p>
          <p class="text-xs text-zinc-500">{{ plan.desc }}</p>
        </div>
      </div>
    </RadioGroupOption>
  </RadioGroup>
</template>

Accessibility notes

  • Always wrap radio inputs in a <fieldset> with a <legend> (use sr-only if visual label isn't needed)
  • Native radio inputs handle navigation within a group automatically
  • Headless UI's RadioGroup additionally provides aria-checked on each option and manages roving tabindex
  • Use class="sr-only" on hidden inputs — never display:none — so they remain keyboard accessible

Released under the MIT License.