Tailwind Radio Button: 17 Examples, From Custom to Cards

Most Tailwind radio button problems come from mixing methods. Install @tailwindcss/forms and accent-* stops tinting radios, because the plugin's global reset replaces the native control that accent-color paints.

The other routes are accent-* alone and appearance-none, and radio cards style the label through peer-checked or has-checked. Below are 17 Tailwind radio button examples you can copy, followed by a guide to every method.

By Bogdan Sandu ยท Updated October 29, 2026

Every example is built with Tailwind CSS v4.3 utility classes. To try one quickly, add the Play CDN tags below to your page and paste the example's HTML, CSS and JS. The dark variant line makes dark: classes follow a .dark class instead of the system setting. For production, install Tailwind with Vite or the CLI.

<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<style type="text/tailwindcss">
  @custom-variant dark (&:where(.dark, .dark *));
</style>

Native and custom radio controls

The plain input type="radio", first tinted with accent-* and then rebuilt with appearance-none and a before: dot. Fieldset, legend, help text, a user-invalid: error state, inline and stacked layouts and the keyboard model all come from the browser.

Native radios tinted with accent-*

  • accent-*
  • hover:accent-*
  • size-*
  • accent-[#hex]
  • scheme-light

Nothing here is rebuilt: accent-indigo-600, accent-emerald-700 and an arbitrary accent-[#c2410c] set the CSS accent-color of the browser radio, and the browser picks a contrasting dot on its own. size-4, size-5 and size-6 scale the control, and hover:accent-* darkens it under the pointer. Focus, keyboard and forced colors behaviour stay native.

View code
<div class="w-full max-w-2xl rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-900/10 sm:p-8">
  <h2 class="text-lg font-semibold tracking-tight text-slate-900">Notifications</h2>
  <p class="mt-1 text-sm text-slate-600">Three groups, three accent colors, three control sizes.</p>
  <div class="mt-6 grid gap-6 md:grid-cols-3">
    <fieldset>
      <legend class="text-sm font-semibold text-slate-900">Email digest</legend>
      <div class="mt-3 space-y-2.5">
        <label class="flex cursor-pointer items-center gap-2.5 text-sm text-slate-700"><input type="radio" name="trb-an-digest" value="daily" class="size-4 cursor-pointer accent-indigo-600 hover:accent-indigo-700" checked> Daily at 8:00</label>
        <label class="flex cursor-pointer items-center gap-2.5 text-sm text-slate-700"><input type="radio" name="trb-an-digest" value="weekly" class="size-4 cursor-pointer accent-indigo-600 hover:accent-indigo-700"> Weekly on Monday</label>
        <label class="flex cursor-pointer items-center gap-2.5 text-sm text-slate-700"><input type="radio" name="trb-an-digest" value="never" class="size-4 cursor-pointer accent-indigo-600 hover:accent-indigo-700"> Never</label>
      </div>
    </fieldset>
    <fieldset>
      <legend class="text-sm font-semibold text-slate-900">Push alerts</legend>
      <div class="mt-3 space-y-2.5">
        <label class="flex cursor-pointer items-center gap-2.5 text-sm text-slate-700"><input type="radio" name="trb-an-push" value="all" class="size-5 cursor-pointer accent-emerald-700 hover:accent-emerald-800"> Every comment</label>
        <label class="flex cursor-pointer items-center gap-2.5 text-sm text-slate-700"><input type="radio" name="trb-an-push" value="mentions" class="size-5 cursor-pointer accent-emerald-700 hover:accent-emerald-800" checked> Mentions only</label>
        <label class="flex cursor-pointer items-center gap-2.5 text-sm text-slate-700"><input type="radio" name="trb-an-push" value="off" class="size-5 cursor-pointer accent-emerald-700 hover:accent-emerald-800"> Off</label>
      </div>
    </fieldset>
    <fieldset>
      <legend class="text-sm font-semibold text-slate-900">Desktop sound</legend>
      <div class="mt-3 space-y-2.5">
        <label class="flex cursor-pointer items-center gap-3 text-base text-slate-700"><input type="radio" name="trb-an-sound" value="chime" class="size-6 cursor-pointer accent-[#c2410c]" checked> Chime</label>
        <label class="flex cursor-pointer items-center gap-3 text-base text-slate-700"><input type="radio" name="trb-an-sound" value="pop" class="size-6 cursor-pointer accent-[#c2410c]"> Pop</label>
        <label class="flex cursor-pointer items-center gap-3 text-base text-slate-700"><input type="radio" name="trb-an-sound" value="silent" class="size-6 cursor-pointer accent-[#c2410c]"> Silent</label>
      </div>
    </fieldset>
  </div>
</div>

appearance-none radios with a before: dot

  • appearance-none
  • before:
  • checked:before:scale-100
  • checked:border-[6px]
  • forced-colors:appearance-auto
  • disabled:

appearance-none removes the browser circle, then size-*, rounded-full and border-2 draw a new one. The input is a one-cell grid, so its before: pseudo-element sits centered and grows from scale-0 to scale-100 under checked:, animated only inside motion-safe:. The second row skips the dot and thickens the border with checked:border-[6px], and forced-colors:appearance-auto hands the control back to the system in Windows contrast themes.

View code
<div class="w-full max-w-2xl rounded-2xl bg-white p-6 shadow-sm ring-1 ring-stone-900/10 sm:p-8">
  <fieldset>
    <legend class="text-lg font-semibold tracking-tight text-stone-900">Pickup slot</legend>
    <p class="mt-1 text-sm text-stone-600">Collect from Harbour Street, Saturday.</p>
    <div class="mt-5 grid gap-3 sm:grid-cols-2">
      <label class="flex cursor-pointer items-center gap-3 rounded-lg border border-stone-200 px-4 py-3 text-sm text-stone-800 has-checked:border-teal-700 has-checked:bg-teal-50 has-disabled:cursor-not-allowed has-disabled:bg-stone-50 has-disabled:text-stone-500">
        <input type="radio" name="trb-cd-slot" value="9" class="grid size-5 shrink-0 cursor-pointer appearance-none place-content-center rounded-full border-2 border-stone-500 bg-white before:size-2 before:scale-0 before:rounded-full before:bg-white checked:border-teal-700 checked:bg-teal-700 checked:before:scale-100 hover:border-teal-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-700 disabled:cursor-not-allowed disabled:border-stone-300 disabled:bg-stone-100 motion-safe:before:transition-transform forced-colors:appearance-auto forced-colors:before:hidden">
        9 to 11 am
      </label>
      <label class="flex cursor-pointer items-center gap-3 rounded-lg border border-stone-200 px-4 py-3 text-sm text-stone-800 has-checked:border-teal-700 has-checked:bg-teal-50 has-disabled:cursor-not-allowed has-disabled:bg-stone-50 has-disabled:text-stone-500">
        <input type="radio" name="trb-cd-slot" value="11" checked class="grid size-5 shrink-0 cursor-pointer appearance-none place-content-center rounded-full border-2 border-stone-500 bg-white before:size-2 before:scale-0 before:rounded-full before:bg-white checked:border-teal-700 checked:bg-teal-700 checked:before:scale-100 hover:border-teal-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-700 disabled:cursor-not-allowed disabled:border-stone-300 disabled:bg-stone-100 motion-safe:before:transition-transform forced-colors:appearance-auto forced-colors:before:hidden">
        11 am to 1 pm
      </label>
      <label class="flex cursor-pointer items-center gap-3 rounded-lg border border-stone-200 px-4 py-3 text-sm text-stone-800 has-checked:border-teal-700 has-checked:bg-teal-50 has-disabled:cursor-not-allowed has-disabled:bg-stone-50 has-disabled:text-stone-500">
        <input type="radio" name="trb-cd-slot" value="14" class="grid size-5 shrink-0 cursor-pointer appearance-none place-content-center rounded-full border-2 border-stone-500 bg-white before:size-2 before:scale-0 before:rounded-full before:bg-white checked:border-teal-700 checked:bg-teal-700 checked:before:scale-100 hover:border-teal-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-700 disabled:cursor-not-allowed disabled:border-stone-300 disabled:bg-stone-100 motion-safe:before:transition-transform forced-colors:appearance-auto forced-colors:before:hidden">
        2 to 4 pm
      </label>
      <label class="flex cursor-pointer items-center gap-3 rounded-lg border border-stone-200 px-4 py-3 text-sm text-stone-800 has-checked:border-teal-700 has-checked:bg-teal-50 has-disabled:cursor-not-allowed has-disabled:bg-stone-50 has-disabled:text-stone-500">
        <input type="radio" name="trb-cd-slot" value="16" disabled class="grid size-5 shrink-0 cursor-pointer appearance-none place-content-center rounded-full border-2 border-stone-500 bg-white before:size-2 before:scale-0 before:rounded-full before:bg-white checked:border-teal-700 checked:bg-teal-700 checked:before:scale-100 hover:border-teal-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-700 disabled:cursor-not-allowed disabled:border-stone-300 disabled:bg-stone-100 motion-safe:before:transition-transform forced-colors:appearance-auto forced-colors:before:hidden">
        4 to 6 pm <span class="ml-auto rounded-full bg-stone-200 px-2 py-0.5 text-xs font-medium text-stone-700">Full</span>
      </label>
    </div>
  </fieldset>
  <div class="mt-7 grid gap-6 border-t border-stone-200 pt-6 md:grid-cols-2">
    <fieldset>
      <legend class="text-sm font-semibold text-stone-900">Three sizes</legend>
      <div class="mt-3 flex flex-wrap items-center gap-x-6 gap-y-3">
        <label class="flex cursor-pointer items-center gap-2 text-xs text-stone-700"><input type="radio" name="trb-cd-size" value="sm" class="grid size-4 cursor-pointer appearance-none place-content-center rounded-full border-2 border-stone-500 bg-white before:size-1.5 before:scale-0 before:rounded-full before:bg-white checked:border-teal-700 checked:bg-teal-700 checked:before:scale-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-700 motion-safe:before:transition-transform forced-colors:appearance-auto forced-colors:before:hidden"> Small</label>
        <label class="flex cursor-pointer items-center gap-2.5 text-sm text-stone-700"><input type="radio" name="trb-cd-size" value="md" checked class="grid size-5 cursor-pointer appearance-none place-content-center rounded-full border-2 border-stone-500 bg-white before:size-2 before:scale-0 before:rounded-full before:bg-white checked:border-teal-700 checked:bg-teal-700 checked:before:scale-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-700 motion-safe:before:transition-transform forced-colors:appearance-auto forced-colors:before:hidden"> Medium</label>
        <label class="flex cursor-pointer items-center gap-3 text-base text-stone-700"><input type="radio" name="trb-cd-size" value="lg" class="grid size-6 cursor-pointer appearance-none place-content-center rounded-full border-2 border-stone-500 bg-white before:size-2.5 before:scale-0 before:rounded-full before:bg-white checked:border-teal-700 checked:bg-teal-700 checked:before:scale-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-700 motion-safe:before:transition-transform forced-colors:appearance-auto forced-colors:before:hidden"> Large</label>
      </div>
    </fieldset>
    <fieldset>
      <legend class="text-sm font-semibold text-stone-900">Thick border, no dot</legend>
      <div class="mt-3 flex flex-wrap items-center gap-x-6 gap-y-3">
        <label class="flex cursor-pointer items-center gap-2.5 text-sm text-stone-700"><input type="radio" name="trb-cd-ring" value="paper" checked class="size-5 cursor-pointer appearance-none rounded-full border-2 border-stone-500 bg-white checked:border-[6px] checked:border-rose-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-rose-700 motion-safe:transition-[border-width] forced-colors:appearance-auto"> Paper bag</label>
        <label class="flex cursor-pointer items-center gap-2.5 text-sm text-stone-700"><input type="radio" name="trb-cd-ring" value="none" class="size-5 cursor-pointer appearance-none rounded-full border-2 border-stone-500 bg-white checked:border-[6px] checked:border-rose-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-rose-700 motion-safe:transition-[border-width] forced-colors:appearance-auto"> No bag</label>
      </div>
    </fieldset>
  </div>
</div>

Required group with help text and a user-invalid: error

  • fieldset
  • aria-describedby
  • user-invalid:
  • group-has-user-invalid:
  • required

Press Register without choosing, then pick a session.

One required attribute makes the whole group required. After a submit attempt the browser marks the radios :user-invalid, so user-invalid:border-red-600 recolors each circle and group-has-user-invalid: on the fieldset reveals the error and tints the legend, all without classes from script. The script only cancels the native bubble in the invalid event, sets aria-invalid, focuses the first radio and confirms a valid submit; every input points at the help and error text through aria-describedby.

View code
<form id="trb-fe-form" class="w-full max-w-lg rounded-2xl bg-white p-6 shadow-sm ring-1 ring-zinc-900/10 sm:p-8">
  <fieldset class="group">
    <legend class="text-lg font-semibold tracking-tight text-zinc-900 group-has-user-invalid:text-red-700">Which workshop session will you attend?</legend>
    <p id="trb-fe-help" class="mt-1 text-sm text-zinc-600">You can switch sessions until May 2. Each one runs 90 minutes.</p>
    <div class="mt-5 space-y-3">
      <label class="flex cursor-pointer items-start gap-3">
        <input type="radio" name="trb-fe-session" value="morning" required aria-describedby="trb-fe-help trb-fe-err" class="mt-0.5 grid size-5 shrink-0 cursor-pointer appearance-none place-content-center rounded-full border-2 border-zinc-500 bg-white before:size-2 before:scale-0 before:rounded-full before:bg-white checked:border-blue-700 checked:bg-blue-700 checked:before:scale-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-700 user-invalid:border-red-600 motion-safe:before:transition-transform forced-colors:appearance-auto forced-colors:before:hidden">
        <span><span class="block text-sm font-medium text-zinc-900">Morning, 9:30</span><span class="block text-sm text-zinc-600">Room 2, ground floor</span></span>
      </label>
      <label class="flex cursor-pointer items-start gap-3">
        <input type="radio" name="trb-fe-session" value="afternoon" aria-describedby="trb-fe-help trb-fe-err" class="mt-0.5 grid size-5 shrink-0 cursor-pointer appearance-none place-content-center rounded-full border-2 border-zinc-500 bg-white before:size-2 before:scale-0 before:rounded-full before:bg-white checked:border-blue-700 checked:bg-blue-700 checked:before:scale-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-700 user-invalid:border-red-600 motion-safe:before:transition-transform forced-colors:appearance-auto forced-colors:before:hidden">
        <span><span class="block text-sm font-medium text-zinc-900">Afternoon, 14:00</span><span class="block text-sm text-zinc-600">Room 2, ground floor</span></span>
      </label>
      <label class="flex cursor-pointer items-start gap-3">
        <input type="radio" name="trb-fe-session" value="online" aria-describedby="trb-fe-help trb-fe-err" class="mt-0.5 grid size-5 shrink-0 cursor-pointer appearance-none place-content-center rounded-full border-2 border-zinc-500 bg-white before:size-2 before:scale-0 before:rounded-full before:bg-white checked:border-blue-700 checked:bg-blue-700 checked:before:scale-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-700 user-invalid:border-red-600 motion-safe:before:transition-transform forced-colors:appearance-auto forced-colors:before:hidden">
        <span><span class="block text-sm font-medium text-zinc-900">Online, 18:00</span><span class="block text-sm text-zinc-600">Link arrives the day before</span></span>
      </label>
    </div>
    <p id="trb-fe-err" class="mt-4 hidden items-center gap-2 rounded-lg bg-red-50 px-3 py-2 text-sm font-medium text-red-800 group-has-user-invalid:flex">
      <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="size-4 shrink-0"><path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm.75-11.5a.75.75 0 0 0-1.5 0v4a.75.75 0 0 0 1.5 0v-4ZM10 14.5a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd"/></svg>
      Choose a session to finish your registration.
    </p>
  </fieldset>
  <div class="mt-6 flex flex-wrap items-center gap-4">
    <button type="submit" class="min-h-11 rounded-lg bg-blue-700 px-5 text-sm font-semibold text-white hover:bg-blue-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-700">Register</button>
    <p id="trb-fe-status" role="status" class="text-sm font-medium text-emerald-800"></p>
  </div>
</form>

Stacked, inline and responsive groups

  • flex-col
  • flex-wrap
  • sm:flex-row
  • accent-*
  • has-checked:

Layout is only the container: space-y-* stacks options with descriptions, flex flex-wrap gap-x-6 sets short labels inline and lets them wrap, and flex-col sm:flex-row stacks on phones and goes inline from the sm breakpoint. The radios stay native with accent-sky-700, and has-checked:text-sky-900 on each label bolds the chosen text.

View code
<div class="grid w-full max-w-3xl gap-5 md:grid-cols-2">
  <fieldset class="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-sky-950/10">
    <legend class="sr-only">Account type</legend>
    <p aria-hidden="true" class="text-sm font-semibold text-sky-950">Account type</p>
    <div class="mt-4 space-y-4">
      <label class="flex cursor-pointer gap-3 has-checked:[&_strong]:text-sky-800">
        <input type="radio" name="trb-is-account" value="personal" checked class="mt-0.5 size-4 shrink-0 cursor-pointer accent-sky-700">
        <span class="text-sm"><strong class="block font-medium text-slate-900">Personal</strong><span class="text-slate-600">One login, your own projects.</span></span>
      </label>
      <label class="flex cursor-pointer gap-3 has-checked:[&_strong]:text-sky-800">
        <input type="radio" name="trb-is-account" value="team" class="mt-0.5 size-4 shrink-0 cursor-pointer accent-sky-700">
        <span class="text-sm"><strong class="block font-medium text-slate-900">Team</strong><span class="text-slate-600">Shared folders and roles for up to 20 people.</span></span>
      </label>
      <label class="flex cursor-pointer gap-3 has-checked:[&_strong]:text-sky-800">
        <input type="radio" name="trb-is-account" value="school" class="mt-0.5 size-4 shrink-0 cursor-pointer accent-sky-700">
        <span class="text-sm"><strong class="block font-medium text-slate-900">School</strong><span class="text-slate-600">Classes, rosters and free seats for students.</span></span>
      </label>
    </div>
  </fieldset>
  <div class="space-y-5">
    <fieldset class="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-sky-950/10">
      <legend class="sr-only">Preferred contact</legend>
      <p aria-hidden="true" class="text-sm font-semibold text-sky-950">Preferred contact (inline)</p>
      <div class="mt-3 flex flex-wrap gap-x-6 gap-y-2">
        <label class="flex cursor-pointer items-center gap-2 text-sm text-slate-700 has-checked:font-medium has-checked:text-sky-900"><input type="radio" name="trb-is-contact" value="email" checked class="size-4 cursor-pointer accent-sky-700"> Email</label>
        <label class="flex cursor-pointer items-center gap-2 text-sm text-slate-700 has-checked:font-medium has-checked:text-sky-900"><input type="radio" name="trb-is-contact" value="phone" class="size-4 cursor-pointer accent-sky-700"> Phone</label>
        <label class="flex cursor-pointer items-center gap-2 text-sm text-slate-700 has-checked:font-medium has-checked:text-sky-900"><input type="radio" name="trb-is-contact" value="sms" class="size-4 cursor-pointer accent-sky-700"> Text message</label>
      </div>
    </fieldset>
    <fieldset class="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-sky-950/10">
      <legend class="sr-only">Invoice language</legend>
      <p aria-hidden="true" class="text-sm font-semibold text-sky-950">Invoice language (stacked on phones)</p>
      <div class="mt-3 flex flex-col gap-2 sm:flex-row sm:gap-6">
        <label class="flex cursor-pointer items-center gap-2 text-sm text-slate-700 has-checked:font-medium has-checked:text-sky-900"><input type="radio" name="trb-is-lang" value="en" checked class="size-4 cursor-pointer accent-sky-700"> English</label>
        <label class="flex cursor-pointer items-center gap-2 text-sm text-slate-700 has-checked:font-medium has-checked:text-sky-900"><input type="radio" name="trb-is-lang" value="de" class="size-4 cursor-pointer accent-sky-700"> German</label>
        <label class="flex cursor-pointer items-center gap-2 text-sm text-slate-700 has-checked:font-medium has-checked:text-sky-900"><input type="radio" name="trb-is-lang" value="es" class="size-4 cursor-pointer accent-sky-700"> Spanish</label>
      </div>
    </fieldset>
  </div>
</div>

Keyboard behaviour of a native group, in dark mode

  • dark:
  • dark:checked:
  • focus-visible:ring-*
  • scheme-dark
  • aria-live

Click 720p, then press the arrow keys.

Tab lands on the checked radio, and the arrow keys move focus and check the next or previous option, wrapping at the ends; Space checks a focused radio when none is checked. That all comes from the browser because the three inputs share one name. The script only logs which key fired which change, and every color has a dark: partner so the panel also works on a light page.

View code
<div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-sm ring-1 ring-zinc-900/10 sm:p-8 dark:bg-zinc-900 dark:shadow-none dark:ring-white/10">
  <fieldset>
    <legend class="text-lg font-semibold tracking-tight text-zinc-900 dark:text-white">Playback quality</legend>
    <div class="mt-4 divide-y divide-zinc-200 rounded-xl ring-1 ring-zinc-200 dark:divide-white/10 dark:ring-white/10">
      <label class="flex cursor-pointer items-center justify-between gap-3 px-4 py-3 has-checked:bg-amber-50 dark:has-checked:bg-amber-400/10">
        <span class="text-sm text-zinc-800 dark:text-zinc-200">720p <span class="text-zinc-500 dark:text-zinc-400">saves data</span></span>
        <input id="trb-kb-720" type="radio" name="trb-kb-q" value="720p" class="grid size-5 shrink-0 cursor-pointer appearance-none place-content-center rounded-full border-2 border-zinc-500 bg-white before:size-2 before:scale-0 before:rounded-full before:bg-zinc-950 checked:border-amber-500 checked:bg-amber-400 checked:before:scale-100 focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:ring-offset-2 focus-visible:outline-hidden dark:border-zinc-400 dark:bg-zinc-900 dark:checked:border-amber-400 dark:checked:bg-amber-400 dark:focus-visible:ring-offset-zinc-900 motion-safe:before:transition-transform forced-colors:appearance-auto forced-colors:before:hidden">
      </label>
      <label class="flex cursor-pointer items-center justify-between gap-3 px-4 py-3 has-checked:bg-amber-50 dark:has-checked:bg-amber-400/10">
        <span class="text-sm text-zinc-800 dark:text-zinc-200">1080p <span class="text-zinc-500 dark:text-zinc-400">recommended</span></span>
        <input id="trb-kb-1080" type="radio" name="trb-kb-q" value="1080p" class="grid size-5 shrink-0 cursor-pointer appearance-none place-content-center rounded-full border-2 border-zinc-500 bg-white before:size-2 before:scale-0 before:rounded-full before:bg-zinc-950 checked:border-amber-500 checked:bg-amber-400 checked:before:scale-100 focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:ring-offset-2 focus-visible:outline-hidden dark:border-zinc-400 dark:bg-zinc-900 dark:checked:border-amber-400 dark:checked:bg-amber-400 dark:focus-visible:ring-offset-zinc-900 motion-safe:before:transition-transform forced-colors:appearance-auto forced-colors:before:hidden">
      </label>
      <label class="flex cursor-pointer items-center justify-between gap-3 px-4 py-3 has-checked:bg-amber-50 dark:has-checked:bg-amber-400/10">
        <span class="text-sm text-zinc-800 dark:text-zinc-200">4K <span class="text-zinc-500 dark:text-zinc-400">fast connection</span></span>
        <input id="trb-kb-4k" type="radio" name="trb-kb-q" value="4K" class="grid size-5 shrink-0 cursor-pointer appearance-none place-content-center rounded-full border-2 border-zinc-500 bg-white before:size-2 before:scale-0 before:rounded-full before:bg-zinc-950 checked:border-amber-500 checked:bg-amber-400 checked:before:scale-100 focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:ring-offset-2 focus-visible:outline-hidden dark:border-zinc-400 dark:bg-zinc-900 dark:checked:border-amber-400 dark:checked:bg-amber-400 dark:focus-visible:ring-offset-zinc-900 motion-safe:before:transition-transform forced-colors:appearance-auto forced-colors:before:hidden">
      </label>
    </div>
  </fieldset>
  <div class="mt-5 flex flex-wrap items-center gap-1.5 text-xs text-zinc-600 dark:text-zinc-400">
    <kbd class="rounded border border-zinc-300 bg-zinc-50 px-1.5 py-0.5 font-sans text-zinc-800 dark:border-white/15 dark:bg-white/5 dark:text-zinc-200">Tab</kbd> enters the group,
    <kbd class="rounded border border-zinc-300 bg-zinc-50 px-1.5 py-0.5 font-sans text-zinc-800 dark:border-white/15 dark:bg-white/5 dark:text-zinc-200">&uarr;</kbd>
    <kbd class="rounded border border-zinc-300 bg-zinc-50 px-1.5 py-0.5 font-sans text-zinc-800 dark:border-white/15 dark:bg-white/5 dark:text-zinc-200">&darr;</kbd>
    <kbd class="rounded border border-zinc-300 bg-zinc-50 px-1.5 py-0.5 font-sans text-zinc-800 dark:border-white/15 dark:bg-white/5 dark:text-zinc-200">&larr;</kbd>
    <kbd class="rounded border border-zinc-300 bg-zinc-50 px-1.5 py-0.5 font-sans text-zinc-800 dark:border-white/15 dark:bg-white/5 dark:text-zinc-200">&rarr;</kbd> move and select
  </div>
  <p id="trb-kb-log" aria-live="polite" class="mt-4 min-h-10 rounded-lg bg-zinc-100 px-3 py-2.5 font-mono text-xs text-zinc-700 dark:bg-black/30 dark:text-amber-200">No selection yet.</p>
</div>

Radio cards and option lists

The input stays in the markup, often as sr-only, and the label around or after it takes the selected style through has-checked: or peer-checked:. Plans, shipping and payment lists are radio groups with richer labels.

Region cards styled with has-checked:

  • has-checked:
  • has-focus-visible:
  • has-disabled:
  • group-has-checked:
  • sr-only

Click an option or Tab into the group and use the arrow keys.

Each label wraps an sr-only radio and all of its content, so has-checked:border-indigo-600 and has-checked:ring-1 style the whole card from inside, with no ordering rule. has-focus-visible: draws the focus outline on the card, has-disabled: dims the region that is not open yet, and group-has-checked:visible shows the check icon, which still marks the choice when forced colors drop the ring.

View code
<fieldset class="w-full max-w-3xl">
  <legend class="text-lg font-semibold tracking-tight text-slate-900">Deploy region</legend>
  <p class="mt-1 text-sm text-slate-600">Pick the region closest to most of your users. Latency measured from your last 1,000 requests.</p>
  <div class="mt-5 grid gap-3 sm:grid-cols-2 md:grid-cols-3">
    <label class="group relative flex cursor-pointer flex-col rounded-xl border border-slate-300 bg-white p-4 shadow-xs has-checked:border-indigo-600 has-checked:ring-1 has-checked:ring-indigo-600 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-indigo-600 has-disabled:cursor-not-allowed has-disabled:bg-slate-50 has-disabled:opacity-70">
      <input type="radio" name="trb-ch-region" value="fra" class="sr-only" checked>
      <span class="text-sm font-semibold text-slate-900">Frankfurt</span>
      <span class="mt-0.5 text-sm text-slate-600">eu-central</span>
      <span class="mt-4 text-sm font-medium text-emerald-700">24 ms</span>
      <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="invisible absolute top-4 right-4 size-5 text-indigo-600 group-has-checked:visible"><path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.86-9.86a.75.75 0 0 0-1.22-.88l-3.24 4.5-1.6-1.6a.75.75 0 1 0-1.06 1.06l2.22 2.22a.75.75 0 0 0 1.14-.09l3.76-5.21Z" clip-rule="evenodd"/></svg>
    </label>
    <label class="group relative flex cursor-pointer flex-col rounded-xl border border-slate-300 bg-white p-4 shadow-xs has-checked:border-indigo-600 has-checked:ring-1 has-checked:ring-indigo-600 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-indigo-600 has-disabled:cursor-not-allowed has-disabled:bg-slate-50 has-disabled:opacity-70">
      <input type="radio" name="trb-ch-region" value="iad" class="sr-only">
      <span class="text-sm font-semibold text-slate-900">Virginia</span>
      <span class="mt-0.5 text-sm text-slate-600">us-east</span>
      <span class="mt-4 text-sm font-medium text-amber-700">96 ms</span>
      <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="invisible absolute top-4 right-4 size-5 text-indigo-600 group-has-checked:visible"><path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.86-9.86a.75.75 0 0 0-1.22-.88l-3.24 4.5-1.6-1.6a.75.75 0 1 0-1.06 1.06l2.22 2.22a.75.75 0 0 0 1.14-.09l3.76-5.21Z" clip-rule="evenodd"/></svg>
    </label>
    <label class="group relative flex cursor-pointer flex-col rounded-xl border border-slate-300 bg-white p-4 shadow-xs has-checked:border-indigo-600 has-checked:ring-1 has-checked:ring-indigo-600 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-indigo-600 has-disabled:cursor-not-allowed has-disabled:bg-slate-50 has-disabled:opacity-70">
      <input type="radio" name="trb-ch-region" value="sin" class="sr-only">
      <span class="text-sm font-semibold text-slate-900">Singapore</span>
      <span class="mt-0.5 text-sm text-slate-600">ap-southeast</span>
      <span class="mt-4 text-sm font-medium text-red-700">212 ms</span>
      <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="invisible absolute top-4 right-4 size-5 text-indigo-600 group-has-checked:visible"><path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.86-9.86a.75.75 0 0 0-1.22-.88l-3.24 4.5-1.6-1.6a.75.75 0 1 0-1.06 1.06l2.22 2.22a.75.75 0 0 0 1.14-.09l3.76-5.21Z" clip-rule="evenodd"/></svg>
    </label>
    <label class="group relative flex cursor-pointer flex-col rounded-xl border border-slate-300 bg-white p-4 shadow-xs has-checked:border-indigo-600 has-checked:ring-1 has-checked:ring-indigo-600 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-indigo-600 has-disabled:cursor-not-allowed has-disabled:bg-slate-50 has-disabled:opacity-70">
      <input type="radio" name="trb-ch-region" value="lhr" class="sr-only">
      <span class="text-sm font-semibold text-slate-900">London</span>
      <span class="mt-0.5 text-sm text-slate-600">eu-west</span>
      <span class="mt-4 text-sm font-medium text-emerald-700">31 ms</span>
      <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="invisible absolute top-4 right-4 size-5 text-indigo-600 group-has-checked:visible"><path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.86-9.86a.75.75 0 0 0-1.22-.88l-3.24 4.5-1.6-1.6a.75.75 0 1 0-1.06 1.06l2.22 2.22a.75.75 0 0 0 1.14-.09l3.76-5.21Z" clip-rule="evenodd"/></svg>
    </label>
    <label class="group relative flex cursor-pointer flex-col rounded-xl border border-slate-300 bg-white p-4 shadow-xs has-checked:border-indigo-600 has-checked:ring-1 has-checked:ring-indigo-600 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-indigo-600 has-disabled:cursor-not-allowed has-disabled:bg-slate-50 has-disabled:opacity-70">
      <input type="radio" name="trb-ch-region" value="syd" class="sr-only">
      <span class="text-sm font-semibold text-slate-900">Sydney</span>
      <span class="mt-0.5 text-sm text-slate-600">ap-southeast-2</span>
      <span class="mt-4 text-sm font-medium text-red-700">288 ms</span>
      <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="invisible absolute top-4 right-4 size-5 text-indigo-600 group-has-checked:visible"><path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.86-9.86a.75.75 0 0 0-1.22-.88l-3.24 4.5-1.6-1.6a.75.75 0 1 0-1.06 1.06l2.22 2.22a.75.75 0 0 0 1.14-.09l3.76-5.21Z" clip-rule="evenodd"/></svg>
    </label>
    <label class="group relative flex cursor-pointer flex-col rounded-xl border border-slate-300 bg-white p-4 shadow-xs has-checked:border-indigo-600 has-checked:ring-1 has-checked:ring-indigo-600 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-indigo-600 has-disabled:cursor-not-allowed has-disabled:bg-slate-50 has-disabled:opacity-70">
      <input type="radio" name="trb-ch-region" value="gru" class="sr-only" disabled>
      <span class="text-sm font-semibold text-slate-900">Sao Paulo</span>
      <span class="mt-0.5 text-sm text-slate-600">sa-east</span>
      <span class="mt-4 text-sm font-medium text-slate-600">Opens in December</span>
    </label>
  </div>
</fieldset>

Project type tiles with peer-checked:

  • peer
  • peer-checked:
  • peer-focus-visible:
  • sr-only
  • li wrapper

Click an option or Tab into the group and use the arrow keys.

Here the sr-only input comes first and carries peer, so the label after it reacts through peer-checked:border-emerald-600 and peer-checked:bg-emerald-50, and the check badge after that through peer-checked:flex. Each pair sits in its own li, because a peer reaches every later sibling and one checked input would otherwise style the tiles that follow it.

View code
<fieldset class="w-full max-w-3xl rounded-2xl bg-[#fbfaf7] p-6 ring-1 ring-stone-900/10 sm:p-8">
  <legend class="sr-only">New project type</legend>
  <h2 aria-hidden="true" class="text-lg font-semibold tracking-tight text-stone-900">What are you building?</h2>
  <p class="mt-1 text-sm text-stone-600">We set up the starter files and checklists for you.</p>
  <ul class="mt-5 grid grid-cols-2 gap-3 md:grid-cols-4">
    <li class="relative">
      <input id="trb-cp-site" type="radio" name="trb-cp-type" value="site" class="peer sr-only" checked>
      <label for="trb-cp-site" class="flex h-full cursor-pointer flex-col items-start gap-3 rounded-xl border-2 border-stone-200 bg-white p-4 text-stone-900 hover:border-stone-300 peer-checked:border-emerald-600 peer-checked:bg-emerald-50 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-emerald-700">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="size-6 text-emerald-700"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9h18M7 6.5h.01M10 6.5h.01"/></svg>
        <span class="text-sm font-semibold">Website</span>
        <span class="text-xs text-stone-600">Pages, blog, forms</span>
      </label>
      <span aria-hidden="true" class="absolute top-3 right-3 hidden size-5 items-center justify-center rounded-full bg-emerald-600 text-white peer-checked:flex"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="size-3"><path d="m5 10.5 3 3 7-7"/></svg></span>
    </li>
    <li class="relative">
      <input id="trb-cp-app" type="radio" name="trb-cp-type" value="app" class="peer sr-only">
      <label for="trb-cp-app" class="flex h-full cursor-pointer flex-col items-start gap-3 rounded-xl border-2 border-stone-200 bg-white p-4 text-stone-900 hover:border-stone-300 peer-checked:border-emerald-600 peer-checked:bg-emerald-50 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-emerald-700">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="size-6 text-emerald-700"><rect x="7" y="2.5" width="10" height="19" rx="2"/><path d="M11 18.5h2"/></svg>
        <span class="text-sm font-semibold">Mobile app</span>
        <span class="text-xs text-stone-600">iOS and Android</span>
      </label>
      <span aria-hidden="true" class="absolute top-3 right-3 hidden size-5 items-center justify-center rounded-full bg-emerald-600 text-white peer-checked:flex"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="size-3"><path d="m5 10.5 3 3 7-7"/></svg></span>
    </li>
    <li class="relative">
      <input id="trb-cp-api" type="radio" name="trb-cp-type" value="api" class="peer sr-only">
      <label for="trb-cp-api" class="flex h-full cursor-pointer flex-col items-start gap-3 rounded-xl border-2 border-stone-200 bg-white p-4 text-stone-900 hover:border-stone-300 peer-checked:border-emerald-600 peer-checked:bg-emerald-50 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-emerald-700">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="size-6 text-emerald-700"><path d="m8 8-4 4 4 4M16 8l4 4-4 4M13.5 5l-3 14"/></svg>
        <span class="text-sm font-semibold">API</span>
        <span class="text-xs text-stone-600">REST or GraphQL</span>
      </label>
      <span aria-hidden="true" class="absolute top-3 right-3 hidden size-5 items-center justify-center rounded-full bg-emerald-600 text-white peer-checked:flex"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="size-3"><path d="m5 10.5 3 3 7-7"/></svg></span>
    </li>
    <li class="relative">
      <input id="trb-cp-ds" type="radio" name="trb-cp-type" value="ds" class="peer sr-only">
      <label for="trb-cp-ds" class="flex h-full cursor-pointer flex-col items-start gap-3 rounded-xl border-2 border-stone-200 bg-white p-4 text-stone-900 hover:border-stone-300 peer-checked:border-emerald-600 peer-checked:bg-emerald-50 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-emerald-700">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="size-6 text-emerald-700"><rect x="3.5" y="3.5" width="7" height="7" rx="1.5"/><rect x="13.5" y="3.5" width="7" height="7" rx="3.5"/><rect x="3.5" y="13.5" width="7" height="7" rx="1.5"/><path d="m17 13.5 3.5 7h-7z"/></svg>
        <span class="text-sm font-semibold">Design system</span>
        <span class="text-xs text-stone-600">Tokens and components</span>
      </label>
      <span aria-hidden="true" class="absolute top-3 right-3 hidden size-5 items-center justify-center rounded-full bg-emerald-600 text-white peer-checked:flex"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="size-3"><path d="m5 10.5 3 3 7-7"/></svg></span>
    </li>
  </ul>
</fieldset>

Plan selector with billing period and badge

  • group-has-[#id:checked]:
  • has-checked:
  • has-focus-visible:
  • sr-only
  • tabular-nums

Click an option or Tab into the group and use the arrow keys.

Two radio groups and no script. The billing period is a small segmented control, and the root carries group, so group-has-[#trb-pl-yearly:checked]:hidden and its inline partner swap every price when Yearly is checked. The plan cards are labels with has-checked:border-orange-600 and has-checked:bg-orange-50, the badge sits in the card flow, and only the shown price is read out because the other one is display: none.

View code
<div class="group w-full max-w-3xl rounded-2xl bg-white p-6 shadow-sm ring-1 ring-neutral-900/10 sm:p-8">
  <div class="flex flex-wrap items-end justify-between gap-4">
    <div>
      <h2 class="text-lg font-semibold tracking-tight text-neutral-900">Choose a plan</h2>
      <p class="mt-1 text-sm text-neutral-600">Prices per editor. Viewers are free on every plan.</p>
    </div>
    <fieldset class="flex rounded-full bg-neutral-100 p-1">
      <legend class="sr-only">Billing period</legend>
      <label class="cursor-pointer rounded-full px-4 py-1.5 text-sm font-medium text-neutral-700 has-checked:bg-white has-checked:text-neutral-900 has-checked:shadow-sm has-focus-visible:outline-2 has-focus-visible:outline-orange-600">
        <input type="radio" name="trb-pl-period" value="monthly" class="sr-only" checked> Monthly
      </label>
      <label class="cursor-pointer rounded-full px-4 py-1.5 text-sm font-medium text-neutral-700 has-checked:bg-white has-checked:text-neutral-900 has-checked:shadow-sm has-focus-visible:outline-2 has-focus-visible:outline-orange-600">
        <input id="trb-pl-yearly" type="radio" name="trb-pl-period" value="yearly" class="sr-only"> Yearly <span class="text-orange-700">-20%</span>
      </label>
    </fieldset>
  </div>
  <fieldset class="mt-6">
    <legend class="sr-only">Plan</legend>
    <div class="grid gap-3 md:grid-cols-3">
      <label class="flex cursor-pointer flex-col rounded-xl border border-neutral-300 p-5 has-checked:border-orange-600 has-checked:bg-orange-50 has-checked:ring-1 has-checked:ring-orange-600 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-orange-600">
        <span class="flex items-center justify-between gap-2"><span class="text-sm font-semibold text-neutral-900">Starter</span><input type="radio" name="trb-pl-plan" value="starter" class="size-4 accent-orange-600"></span>
        <span class="mt-3 text-3xl font-semibold tracking-tight text-neutral-900 tabular-nums"><span class="group-has-[#trb-pl-yearly:checked]:hidden">$9</span><span class="hidden group-has-[#trb-pl-yearly:checked]:inline">$7</span></span>
        <span class="text-xs text-neutral-600"><span class="group-has-[#trb-pl-yearly:checked]:hidden">per month</span><span class="hidden group-has-[#trb-pl-yearly:checked]:inline">per month, billed yearly</span></span>
        <span class="mt-4 text-sm text-neutral-700">3 projects, 2 GB of assets, community support.</span>
      </label>
      <label class="flex cursor-pointer flex-col rounded-xl border border-neutral-300 p-5 has-checked:border-orange-600 has-checked:bg-orange-50 has-checked:ring-1 has-checked:ring-orange-600 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-orange-600">
        <span class="flex items-center justify-between gap-2"><span class="flex flex-wrap items-center gap-2"><span class="text-sm font-semibold text-neutral-900">Team</span><span class="rounded-full bg-neutral-900 px-2 py-0.5 text-xs font-medium text-white">Most popular</span></span><input type="radio" name="trb-pl-plan" value="team" class="size-4 accent-orange-600" checked></span>
        <span class="mt-3 text-3xl font-semibold tracking-tight text-neutral-900 tabular-nums"><span class="group-has-[#trb-pl-yearly:checked]:hidden">$24</span><span class="hidden group-has-[#trb-pl-yearly:checked]:inline">$19</span></span>
        <span class="text-xs text-neutral-600"><span class="group-has-[#trb-pl-yearly:checked]:hidden">per month</span><span class="hidden group-has-[#trb-pl-yearly:checked]:inline">per month, billed yearly</span></span>
        <span class="mt-4 text-sm text-neutral-700">Unlimited projects, 100 GB, version history.</span>
      </label>
      <label class="flex cursor-pointer flex-col rounded-xl border border-neutral-300 p-5 has-checked:border-orange-600 has-checked:bg-orange-50 has-checked:ring-1 has-checked:ring-orange-600 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-orange-600">
        <span class="flex items-center justify-between gap-2"><span class="text-sm font-semibold text-neutral-900">Business</span><input type="radio" name="trb-pl-plan" value="business" class="size-4 accent-orange-600"></span>
        <span class="mt-3 text-3xl font-semibold tracking-tight text-neutral-900 tabular-nums"><span class="group-has-[#trb-pl-yearly:checked]:hidden">$59</span><span class="hidden group-has-[#trb-pl-yearly:checked]:inline">$47</span></span>
        <span class="text-xs text-neutral-600"><span class="group-has-[#trb-pl-yearly:checked]:hidden">per month</span><span class="hidden group-has-[#trb-pl-yearly:checked]:inline">per month, billed yearly</span></span>
        <span class="mt-4 text-sm text-neutral-700">SSO, audit log, priority support.</span>
      </label>
    </div>
  </fieldset>
</div>

Shipping method list with live total

  • divide-y
  • has-checked:bg-*
  • appearance-none
  • output
  • tabular-nums

Click an option or Tab into the group and use the arrow keys.

A checkout list where each row is a label with a custom appearance-none radio, a name, a description and a price; has-checked:bg-sky-50 tints the chosen row and divide-y draws the separators. The legend gets float-left w-full so it sits inside the padded fieldset like a heading instead of on its border. The script reads data-price from the checked input on change and writes the new total into an output element, which screen readers announce as a live region.

View code
<form class="w-full max-w-lg rounded-2xl bg-white shadow-sm ring-1 ring-slate-900/10">
  <fieldset class="px-6 pt-6 pb-2 sm:px-8 sm:pt-8">
    <legend class="float-left mb-4 w-full text-lg font-semibold tracking-tight text-slate-900">Shipping method</legend>
    <ul class="clear-both divide-y divide-slate-200 overflow-hidden rounded-xl border border-slate-200">
      <li>
        <label class="flex cursor-pointer items-start gap-3 p-4 has-checked:bg-sky-50">
          <input type="radio" name="trb-sh-method" value="standard" data-price="0" checked class="trb-sh-radio mt-0.5 grid size-5 shrink-0 cursor-pointer appearance-none place-content-center rounded-full border-2 border-slate-500 bg-white before:size-2 before:scale-0 before:rounded-full before:bg-white checked:border-sky-700 checked:bg-sky-700 checked:before:scale-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700 motion-safe:before:transition-transform forced-colors:appearance-auto forced-colors:before:hidden">
          <span class="min-w-0 flex-1"><span class="block text-sm font-medium text-slate-900">Standard</span><span class="block text-sm text-slate-600">4 to 6 business days, tracked</span></span>
          <span class="text-sm font-semibold text-slate-900">Free</span>
        </label>
      </li>
      <li>
        <label class="flex cursor-pointer items-start gap-3 p-4 has-checked:bg-sky-50">
          <input type="radio" name="trb-sh-method" value="express" data-price="9.5" class="trb-sh-radio mt-0.5 grid size-5 shrink-0 cursor-pointer appearance-none place-content-center rounded-full border-2 border-slate-500 bg-white before:size-2 before:scale-0 before:rounded-full before:bg-white checked:border-sky-700 checked:bg-sky-700 checked:before:scale-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700 motion-safe:before:transition-transform forced-colors:appearance-auto forced-colors:before:hidden">
          <span class="min-w-0 flex-1"><span class="block text-sm font-medium text-slate-900">Express</span><span class="block text-sm text-slate-600">1 to 2 business days, signature on delivery</span></span>
          <span class="text-sm font-semibold text-slate-900 tabular-nums">$9.50</span>
        </label>
      </li>
      <li>
        <label class="flex cursor-pointer items-start gap-3 p-4 has-checked:bg-sky-50">
          <input type="radio" name="trb-sh-method" value="locker" data-price="3" class="trb-sh-radio mt-0.5 grid size-5 shrink-0 cursor-pointer appearance-none place-content-center rounded-full border-2 border-slate-500 bg-white before:size-2 before:scale-0 before:rounded-full before:bg-white checked:border-sky-700 checked:bg-sky-700 checked:before:scale-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700 motion-safe:before:transition-transform forced-colors:appearance-auto forced-colors:before:hidden">
          <span class="min-w-0 flex-1"><span class="block text-sm font-medium text-slate-900">Parcel locker</span><span class="block text-sm text-slate-600">Pick up at 41 Mill Lane, open 24/7</span></span>
          <span class="text-sm font-semibold text-slate-900 tabular-nums">$3.00</span>
        </label>
      </li>
    </ul>
  </fieldset>
  <dl class="space-y-1.5 px-6 pt-4 pb-6 text-sm sm:px-8 sm:pb-8">
    <div class="flex justify-between text-slate-600"><dt>Subtotal</dt><dd class="tabular-nums">$86.00</dd></div>
    <div class="flex justify-between border-t border-slate-200 pt-2 text-base font-semibold text-slate-900"><dt>Total</dt><dd><output id="trb-sh-total" class="tabular-nums">$86.00</output></dd></div>
  </dl>
</form>

Payment method list that reveals its fields

  • group-has-checked:block
  • has-checked:
  • rounded-t-xl
  • autocomplete
  • inputmode

Click an option or Tab into the group and use the arrow keys.

Each list item is a group, and its panel is hidden until group-has-checked:block shows it, so picking Card opens the card fields and picking Bank transfer opens the IBAN note, with no script. The label row gets has-checked:bg-emerald-50 and a native radio tinted with accent-emerald-800; the field inputs use autocomplete and inputmode so phones bring up the right keyboard.

View code
<form class="w-full max-w-lg rounded-2xl bg-stone-50 p-6 ring-1 ring-stone-900/10 sm:p-8">
  <fieldset>
    <legend class="text-lg font-semibold tracking-tight text-stone-900">Payment method</legend>
    <ul class="mt-4 space-y-2.5">
      <li class="group overflow-hidden rounded-xl border border-stone-300 bg-white has-checked:border-emerald-800">
        <label class="flex cursor-pointer items-center gap-3 px-4 py-3.5 has-checked:bg-emerald-50">
          <input type="radio" name="trb-pm-method" value="card" checked class="size-4 shrink-0 cursor-pointer accent-emerald-800">
          <span class="flex-1 text-sm font-medium text-stone-900">Credit or debit card</span>
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true" class="size-6 text-stone-600"><rect x="2.5" y="5" width="19" height="14" rx="2"/><path d="M2.5 9.5h19M6 15h4"/></svg>
        </label>
        <div class="hidden border-t border-stone-200 p-4 group-has-checked:block">
          <div class="grid grid-cols-2 gap-3">
            <div class="col-span-2">
              <label for="trb-pm-name" class="block text-xs font-medium text-stone-700">Name on card</label>
              <input id="trb-pm-name" type="text" autocomplete="cc-name" class="mt-1 block w-full rounded-lg border border-stone-400 px-3 py-2 text-sm text-stone-900 focus-visible:border-emerald-800 focus-visible:outline-1 focus-visible:outline-emerald-800">
            </div>
            <div class="col-span-2">
              <label for="trb-pm-number" class="block text-xs font-medium text-stone-700">Card number</label>
              <input id="trb-pm-number" type="text" inputmode="numeric" autocomplete="cc-number" class="mt-1 block w-full rounded-lg border border-stone-400 px-3 py-2 text-sm text-stone-900 focus-visible:border-emerald-800 focus-visible:outline-1 focus-visible:outline-emerald-800">
            </div>
            <div>
              <label for="trb-pm-exp" class="block text-xs font-medium text-stone-700">Expiry (MM/YY)</label>
              <input id="trb-pm-exp" type="text" inputmode="numeric" autocomplete="cc-exp" class="mt-1 block w-full rounded-lg border border-stone-400 px-3 py-2 text-sm text-stone-900 focus-visible:border-emerald-800 focus-visible:outline-1 focus-visible:outline-emerald-800">
            </div>
            <div>
              <label for="trb-pm-cvc" class="block text-xs font-medium text-stone-700">Security code</label>
              <input id="trb-pm-cvc" type="text" inputmode="numeric" autocomplete="cc-csc" class="mt-1 block w-full rounded-lg border border-stone-400 px-3 py-2 text-sm text-stone-900 focus-visible:border-emerald-800 focus-visible:outline-1 focus-visible:outline-emerald-800">
            </div>
          </div>
        </div>
      </li>
      <li class="group overflow-hidden rounded-xl border border-stone-300 bg-white has-checked:border-emerald-800">
        <label class="flex cursor-pointer items-center gap-3 px-4 py-3.5 has-checked:bg-emerald-50">
          <input type="radio" name="trb-pm-method" value="bank" class="size-4 shrink-0 cursor-pointer accent-emerald-800">
          <span class="flex-1 text-sm font-medium text-stone-900">Bank transfer</span>
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" aria-hidden="true" class="size-6 text-stone-600"><path d="M3 9.5 12 4l9 5.5M5 10v7M9.5 10v7M14.5 10v7M19 10v7M3 20h18"/></svg>
        </label>
        <div class="hidden border-t border-stone-200 p-4 text-sm text-stone-700 group-has-checked:block">
          We email the account details after you place the order. Your order ships once the payment arrives, usually within two business days.
        </div>
      </li>
      <li class="group overflow-hidden rounded-xl border border-stone-300 bg-white has-checked:border-emerald-800">
        <label class="flex cursor-pointer items-center gap-3 px-4 py-3.5 has-checked:bg-emerald-50">
          <input type="radio" name="trb-pm-method" value="invoice" class="size-4 shrink-0 cursor-pointer accent-emerald-800">
          <span class="flex-1 text-sm font-medium text-stone-900">Invoice, pay in 30 days <span class="block text-xs font-normal text-stone-600">Business accounts only</span></span>
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="size-6 text-stone-600"><path d="M6 3h9l4 4v14H6zM15 3v4h4M9 12h7M9 16h5"/></svg>
        </label>
        <div class="hidden border-t border-stone-200 p-4 text-sm text-stone-700 group-has-checked:block">
          The invoice goes to the billing email on your account. Add a purchase order number at the next step if your team needs one.
        </div>
      </li>
    </ul>
  </fieldset>
</form>

Product and survey pickers

Color swatches, clothing sizes with a sold-out option, a 1 to 10 rating scale and photo choices. Each one is still a native radio group, so arrow keys, form submission and screen reader announcements work without extra script.

Color swatch picker with sr-only names

  • forced-color-adjust-none
  • sr-only
  • has-checked:ring-*
  • forced-colors:
  • bg-[#hex]

Each swatch is a label with an sr-only radio and an sr-only color name, so a screen reader hears Olive, radio button, 2 of 5, while sighted users see only the dot. has-checked:ring-2 with ring-offset-2 marks the choice, forced-color-adjust-none keeps the swatch colors in Windows contrast themes as Tailwind docs show, and forced-colors:has-checked:outline-2 replaces the ring, which forced colors removes. The script copies the name into the heading.

View code
<div class="w-full max-w-md overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-zinc-900/10">
  <div class="flex aspect-[16/9] items-center justify-center bg-[#eef0e6]">
    <svg viewBox="0 0 120 120" aria-hidden="true" class="trb-sw-bag h-32 w-32 text-[#556b2f]"><path fill="currentColor" d="M30 44h60l-6 62H36z"/><path d="M45 44V34a15 15 0 0 1 30 0v10" fill="none" stroke="currentColor" stroke-width="5"/></svg>
  </div>
  <div class="p-6">
    <h2 class="text-lg font-semibold tracking-tight text-zinc-900">Canvas tote</h2>
    <p class="mt-0.5 text-sm text-zinc-600">$38, organic cotton</p>
    <fieldset class="mt-5">
      <legend class="text-sm text-zinc-700">Color: <span id="trb-sw-name" class="font-semibold text-zinc-900">Olive</span></legend>
      <div class="mt-3 flex flex-wrap gap-3 forced-color-adjust-none">
        <label class="cursor-pointer rounded-full p-0.5 ring-zinc-900 ring-offset-2 has-checked:ring-2 has-focus-visible:outline-2 has-focus-visible:outline-offset-4 has-focus-visible:outline-zinc-900 forced-colors:has-checked:outline-2 forced-colors:has-checked:outline-offset-2 forced-colors:has-checked:outline-[CanvasText]">
          <input type="radio" name="trb-sw-color" value="Sand" class="trb-sw-input sr-only"><span class="sr-only">Sand</span>
          <span aria-hidden="true" class="block size-8 rounded-full bg-[#d8c7a3] ring-1 ring-black/15 ring-inset"></span>
        </label>
        <label class="cursor-pointer rounded-full p-0.5 ring-zinc-900 ring-offset-2 has-checked:ring-2 has-focus-visible:outline-2 has-focus-visible:outline-offset-4 has-focus-visible:outline-zinc-900 forced-colors:has-checked:outline-2 forced-colors:has-checked:outline-offset-2 forced-colors:has-checked:outline-[CanvasText]">
          <input type="radio" name="trb-sw-color" value="Olive" class="trb-sw-input sr-only" checked><span class="sr-only">Olive</span>
          <span aria-hidden="true" class="block size-8 rounded-full bg-[#556b2f] ring-1 ring-black/15 ring-inset"></span>
        </label>
        <label class="cursor-pointer rounded-full p-0.5 ring-zinc-900 ring-offset-2 has-checked:ring-2 has-focus-visible:outline-2 has-focus-visible:outline-offset-4 has-focus-visible:outline-zinc-900 forced-colors:has-checked:outline-2 forced-colors:has-checked:outline-offset-2 forced-colors:has-checked:outline-[CanvasText]">
          <input type="radio" name="trb-sw-color" value="Rust" class="trb-sw-input sr-only"><span class="sr-only">Rust</span>
          <span aria-hidden="true" class="block size-8 rounded-full bg-[#b5502d] ring-1 ring-black/15 ring-inset"></span>
        </label>
        <label class="cursor-pointer rounded-full p-0.5 ring-zinc-900 ring-offset-2 has-checked:ring-2 has-focus-visible:outline-2 has-focus-visible:outline-offset-4 has-focus-visible:outline-zinc-900 forced-colors:has-checked:outline-2 forced-colors:has-checked:outline-offset-2 forced-colors:has-checked:outline-[CanvasText]">
          <input type="radio" name="trb-sw-color" value="Navy" class="trb-sw-input sr-only"><span class="sr-only">Navy</span>
          <span aria-hidden="true" class="block size-8 rounded-full bg-[#1f2a44] ring-1 ring-black/15 ring-inset"></span>
        </label>
        <label class="cursor-pointer rounded-full p-0.5 ring-zinc-900 ring-offset-2 has-checked:ring-2 has-focus-visible:outline-2 has-focus-visible:outline-offset-4 has-focus-visible:outline-zinc-900 forced-colors:has-checked:outline-2 forced-colors:has-checked:outline-offset-2 forced-colors:has-checked:outline-[CanvasText]">
          <input type="radio" name="trb-sw-color" value="Chalk" class="trb-sw-input sr-only"><span class="sr-only">Chalk</span>
          <span aria-hidden="true" class="block size-8 rounded-full bg-[#f4f1ea] ring-1 ring-black/15 ring-inset"></span>
        </label>
      </div>
    </fieldset>
  </div>
</div>

Size picker with a sold-out size

  • has-checked:bg-*
  • has-disabled:
  • line-through
  • sr-only
  • grid-cols-3 sm:grid-cols-6

Six labels in a grid, each with an sr-only radio. has-checked:bg-zinc-900 and has-checked:text-white fill the chosen size, while the XXL input is disabled, so has-disabled:line-through, has-disabled:text-zinc-400 and has-disabled:cursor-not-allowed mark it and the arrow keys skip it. An sr-only "out of stock" inside its label tells screen reader users why.

View code
<form class="w-full max-w-md rounded-2xl bg-white p-6 shadow-sm ring-1 ring-zinc-900/10 sm:p-8">
  <h2 class="text-lg font-semibold tracking-tight text-zinc-900">Merino crew sweater</h2>
  <p class="mt-0.5 text-sm text-zinc-600">$120</p>
  <fieldset class="mt-6">
    <div class="flex items-center justify-between">
      <legend class="text-sm font-medium text-zinc-900">Size</legend>
      <a href="#" class="rounded text-sm font-medium text-zinc-700 underline underline-offset-4 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-900">Size guide</a>
    </div>
    <div class="mt-3 grid grid-cols-3 gap-2 sm:grid-cols-6">
      <label class="flex h-11 cursor-pointer items-center justify-center rounded-lg border border-zinc-300 text-sm font-medium text-zinc-900 hover:bg-zinc-50 has-checked:border-zinc-900 has-checked:bg-zinc-900 has-checked:text-white has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-zinc-900 has-disabled:cursor-not-allowed has-disabled:bg-zinc-50 has-disabled:text-zinc-400 has-disabled:line-through"><input type="radio" name="trb-sz-size" value="XS" class="sr-only">XS</label>
      <label class="flex h-11 cursor-pointer items-center justify-center rounded-lg border border-zinc-300 text-sm font-medium text-zinc-900 hover:bg-zinc-50 has-checked:border-zinc-900 has-checked:bg-zinc-900 has-checked:text-white has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-zinc-900 has-disabled:cursor-not-allowed has-disabled:bg-zinc-50 has-disabled:text-zinc-400 has-disabled:line-through"><input type="radio" name="trb-sz-size" value="S" class="sr-only">S</label>
      <label class="flex h-11 cursor-pointer items-center justify-center rounded-lg border border-zinc-300 text-sm font-medium text-zinc-900 hover:bg-zinc-50 has-checked:border-zinc-900 has-checked:bg-zinc-900 has-checked:text-white has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-zinc-900 has-disabled:cursor-not-allowed has-disabled:bg-zinc-50 has-disabled:text-zinc-400 has-disabled:line-through"><input type="radio" name="trb-sz-size" value="M" class="sr-only" checked>M</label>
      <label class="flex h-11 cursor-pointer items-center justify-center rounded-lg border border-zinc-300 text-sm font-medium text-zinc-900 hover:bg-zinc-50 has-checked:border-zinc-900 has-checked:bg-zinc-900 has-checked:text-white has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-zinc-900 has-disabled:cursor-not-allowed has-disabled:bg-zinc-50 has-disabled:text-zinc-400 has-disabled:line-through"><input type="radio" name="trb-sz-size" value="L" class="sr-only">L</label>
      <label class="flex h-11 cursor-pointer items-center justify-center rounded-lg border border-zinc-300 text-sm font-medium text-zinc-900 hover:bg-zinc-50 has-checked:border-zinc-900 has-checked:bg-zinc-900 has-checked:text-white has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-zinc-900 has-disabled:cursor-not-allowed has-disabled:bg-zinc-50 has-disabled:text-zinc-400 has-disabled:line-through"><input type="radio" name="trb-sz-size" value="XL" class="sr-only">XL</label>
      <label class="flex h-11 cursor-pointer items-center justify-center rounded-lg border border-zinc-300 text-sm font-medium text-zinc-900 hover:bg-zinc-50 has-checked:border-zinc-900 has-checked:bg-zinc-900 has-checked:text-white has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-zinc-900 has-disabled:cursor-not-allowed has-disabled:bg-zinc-50 has-disabled:text-zinc-400 has-disabled:line-through"><input type="radio" name="trb-sz-size" value="XXL" class="sr-only" disabled>XXL<span class="sr-only">, out of stock</span></label>
    </div>
    <p class="mt-3 text-sm text-zinc-600">XXL is back in stock on November 20.</p>
  </fieldset>
  <button type="submit" class="mt-6 min-h-11 w-full rounded-lg bg-zinc-900 px-5 text-sm font-semibold text-white hover:bg-zinc-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-900">Add to bag</button>
</form>

Rating scale from 1 to 10

  • grid-cols-5 sm:grid-cols-10
  • has-checked:
  • group-has-checked:block
  • aria-describedby
  • sr-only

Ten radios named after their numbers, five per row on phones and ten from sm:. The checked fill depends on the score band, has-checked:bg-rose-700 for 1 to 6, amber for 7 and 8 and emerald for 9 and 10, so the color repeats what the number already says. The scale ends are tied to the group with aria-describedby, and a follow-up field appears through group-has-checked:block once any score is picked.

View code
<form class="w-full max-w-2xl rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-900/10 sm:p-8">
  <fieldset class="group" aria-describedby="trb-rt-ends">
    <legend class="text-lg font-semibold tracking-tight text-slate-900">How likely are you to recommend Fieldnote to a colleague?</legend>
    <div class="mt-5 grid grid-cols-5 gap-2 sm:grid-cols-10">
      <label class="flex h-11 cursor-pointer items-center justify-center rounded-lg border border-slate-300 text-sm font-semibold text-slate-800 hover:border-slate-500 has-checked:border-rose-700 has-checked:bg-rose-700 has-checked:text-white has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-slate-900"><input type="radio" name="trb-rt-score" value="1" class="sr-only">1</label>
      <label class="flex h-11 cursor-pointer items-center justify-center rounded-lg border border-slate-300 text-sm font-semibold text-slate-800 hover:border-slate-500 has-checked:border-rose-700 has-checked:bg-rose-700 has-checked:text-white has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-slate-900"><input type="radio" name="trb-rt-score" value="2" class="sr-only">2</label>
      <label class="flex h-11 cursor-pointer items-center justify-center rounded-lg border border-slate-300 text-sm font-semibold text-slate-800 hover:border-slate-500 has-checked:border-rose-700 has-checked:bg-rose-700 has-checked:text-white has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-slate-900"><input type="radio" name="trb-rt-score" value="3" class="sr-only">3</label>
      <label class="flex h-11 cursor-pointer items-center justify-center rounded-lg border border-slate-300 text-sm font-semibold text-slate-800 hover:border-slate-500 has-checked:border-rose-700 has-checked:bg-rose-700 has-checked:text-white has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-slate-900"><input type="radio" name="trb-rt-score" value="4" class="sr-only">4</label>
      <label class="flex h-11 cursor-pointer items-center justify-center rounded-lg border border-slate-300 text-sm font-semibold text-slate-800 hover:border-slate-500 has-checked:border-rose-700 has-checked:bg-rose-700 has-checked:text-white has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-slate-900"><input type="radio" name="trb-rt-score" value="5" class="sr-only">5</label>
      <label class="flex h-11 cursor-pointer items-center justify-center rounded-lg border border-slate-300 text-sm font-semibold text-slate-800 hover:border-slate-500 has-checked:border-rose-700 has-checked:bg-rose-700 has-checked:text-white has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-slate-900"><input type="radio" name="trb-rt-score" value="6" class="sr-only">6</label>
      <label class="flex h-11 cursor-pointer items-center justify-center rounded-lg border border-slate-300 text-sm font-semibold text-slate-800 hover:border-slate-500 has-checked:border-amber-500 has-checked:bg-amber-400 has-checked:text-slate-950 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-slate-900"><input type="radio" name="trb-rt-score" value="7" class="sr-only">7</label>
      <label class="flex h-11 cursor-pointer items-center justify-center rounded-lg border border-slate-300 text-sm font-semibold text-slate-800 hover:border-slate-500 has-checked:border-amber-500 has-checked:bg-amber-400 has-checked:text-slate-950 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-slate-900"><input type="radio" name="trb-rt-score" value="8" class="sr-only">8</label>
      <label class="flex h-11 cursor-pointer items-center justify-center rounded-lg border border-slate-300 text-sm font-semibold text-slate-800 hover:border-slate-500 has-checked:border-emerald-700 has-checked:bg-emerald-700 has-checked:text-white has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-slate-900"><input type="radio" name="trb-rt-score" value="9" class="sr-only">9</label>
      <label class="flex h-11 cursor-pointer items-center justify-center rounded-lg border border-slate-300 text-sm font-semibold text-slate-800 hover:border-slate-500 has-checked:border-emerald-700 has-checked:bg-emerald-700 has-checked:text-white has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-slate-900"><input type="radio" name="trb-rt-score" value="10" class="sr-only">10</label>
    </div>
    <p id="trb-rt-ends" class="mt-2 flex justify-between text-xs text-slate-600"><span>1 = Not at all likely</span><span>10 = Extremely likely</span></p>
    <div class="mt-5 hidden group-has-checked:block">
      <label for="trb-rt-why" class="block text-sm font-medium text-slate-900">What is the main reason for your score?</label>
      <textarea id="trb-rt-why" rows="3" class="mt-1.5 block w-full rounded-lg border border-slate-400 px-3 py-2 text-sm text-slate-900 focus-visible:border-slate-900 focus-visible:outline-1 focus-visible:outline-slate-900"></textarea>
    </div>
  </fieldset>
</form>

Image choice cards with photos

  • has-checked:ring-*
  • aspect-[4/3]
  • object-cover
  • group-has-checked:
  • motion-safe:

Each photo card is a label with an sr-only radio. has-checked:ring-2 and has-checked:ring-sky-700 frame the chosen card, group-has-checked:flex shows a check badge on the photo, and group-hover:scale-105 zooms the image only inside motion-safe:. The photos have empty alt text because the label text already names each trip; repeating a description would only lengthen what the screen reader reads.

View code
<fieldset class="w-full max-w-3xl">
  <legend class="text-lg font-semibold tracking-tight text-slate-900">Pick your trip style</legend>
  <p class="mt-1 text-sm text-slate-600">We build the route around it. You can change it until we send the first draft.</p>
  <div class="mt-5 grid grid-cols-2 gap-3 md:grid-cols-4">
    <label class="group cursor-pointer overflow-hidden rounded-xl bg-white shadow-xs ring-1 ring-slate-900/10 has-checked:ring-2 has-checked:ring-sky-700 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-sky-700">
      <input type="radio" name="trb-ic-trip" value="fjord" class="sr-only" checked>
      <span class="relative block overflow-hidden"><img src="fjord.webp" width="800" height="600" alt="" loading="lazy" class="aspect-[4/3] w-full object-cover motion-safe:transition-transform motion-safe:duration-300 motion-safe:group-hover:scale-105"><span aria-hidden="true" class="absolute top-2 right-2 hidden size-6 items-center justify-center rounded-full bg-sky-700 text-white ring-2 ring-white group-has-checked:flex"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="size-3.5"><path d="m5 10.5 3 3 7-7"/></svg></span></span>
      <span class="block p-3"><span class="block text-sm font-semibold text-slate-900">Fjords by boat</span><span class="block text-xs text-slate-600">8 days, from $2,140</span></span>
    </label>
    <label class="group cursor-pointer overflow-hidden rounded-xl bg-white shadow-xs ring-1 ring-slate-900/10 has-checked:ring-2 has-checked:ring-sky-700 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-sky-700">
      <input type="radio" name="trb-ic-trip" value="canyon" class="sr-only">
      <span class="relative block overflow-hidden"><img src="red-rock-canyon.webp" width="800" height="600" alt="" loading="lazy" class="aspect-[4/3] w-full object-cover motion-safe:transition-transform motion-safe:duration-300 motion-safe:group-hover:scale-105"><span aria-hidden="true" class="absolute top-2 right-2 hidden size-6 items-center justify-center rounded-full bg-sky-700 text-white ring-2 ring-white group-has-checked:flex"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="size-3.5"><path d="m5 10.5 3 3 7-7"/></svg></span></span>
      <span class="block p-3"><span class="block text-sm font-semibold text-slate-900">Canyon hiking</span><span class="block text-xs text-slate-600">6 days, from $1,380</span></span>
    </label>
    <label class="group cursor-pointer overflow-hidden rounded-xl bg-white shadow-xs ring-1 ring-slate-900/10 has-checked:ring-2 has-checked:ring-sky-700 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-sky-700">
      <input type="radio" name="trb-ic-trip" value="snow" class="sr-only">
      <span class="relative block overflow-hidden"><img src="snow-camp.webp" width="800" height="600" alt="" loading="lazy" class="aspect-[4/3] w-full object-cover motion-safe:transition-transform motion-safe:duration-300 motion-safe:group-hover:scale-105"><span aria-hidden="true" class="absolute top-2 right-2 hidden size-6 items-center justify-center rounded-full bg-sky-700 text-white ring-2 ring-white group-has-checked:flex"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="size-3.5"><path d="m5 10.5 3 3 7-7"/></svg></span></span>
      <span class="block p-3"><span class="block text-sm font-semibold text-slate-900">Winter camping</span><span class="block text-xs text-slate-600">5 days, from $1,690</span></span>
    </label>
    <label class="group cursor-pointer overflow-hidden rounded-xl bg-white shadow-xs ring-1 ring-slate-900/10 has-checked:ring-2 has-checked:ring-sky-700 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-sky-700">
      <input type="radio" name="trb-ic-trip" value="city" class="sr-only">
      <span class="relative block overflow-hidden"><img src="canal-houses.webp" width="800" height="600" alt="" loading="lazy" class="aspect-[4/3] w-full object-cover motion-safe:transition-transform motion-safe:duration-300 motion-safe:group-hover:scale-105"><span aria-hidden="true" class="absolute top-2 right-2 hidden size-6 items-center justify-center rounded-full bg-sky-700 text-white ring-2 ring-white group-has-checked:flex"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="size-3.5"><path d="m5 10.5 3 3 7-7"/></svg></span></span>
      <span class="block p-3"><span class="block text-sm font-semibold text-slate-900">Canal city break</span><span class="block text-xs text-slate-600">4 days, from $940</span></span>
    </label>
  </div>
</fieldset>

Segmented controls, matrices and dark mode

Radios laid out as a segmented control with three or more options, a survey matrix that turns from a table into stacked cards on phones, and an appearance setting whose radios switch the card into dark mode.

Segmented controls built from radios

  • has-checked:bg-white
  • has-checked:shadow-sm
  • flex-1
  • sr-only
  • has-focus-visible:

A segmented control is a radio group drawn as one bar: a fieldset with a sunken background, labels with flex-1, and has-checked:bg-white with has-checked:shadow-sm lifting the chosen segment. The first one has four text options and updates the stat through a small script; the second is icon-only, so each label carries sr-only text, and the script swaps full text-left, text-center, text-right and text-justify class names on the preview.

View code
<div class="w-full max-w-xl space-y-5">
  <div class="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-violet-950/10">
    <div class="flex flex-wrap items-center justify-between gap-3">
      <h2 class="text-sm font-semibold text-violet-950">Visitors</h2>
      <fieldset class="flex w-full rounded-lg bg-violet-100 p-1 sm:w-auto">
        <legend class="sr-only">Time range</legend>
        <label class="flex-1 cursor-pointer rounded-md px-3 py-1.5 text-center text-sm font-medium text-violet-900 hover:text-violet-950 has-checked:bg-white has-checked:text-violet-950 has-checked:shadow-sm has-focus-visible:outline-2 has-focus-visible:outline-violet-700"><input type="radio" name="trb-sg-range" value="day" class="trb-sg-range sr-only">Day</label>
        <label class="flex-1 cursor-pointer rounded-md px-3 py-1.5 text-center text-sm font-medium text-violet-900 hover:text-violet-950 has-checked:bg-white has-checked:text-violet-950 has-checked:shadow-sm has-focus-visible:outline-2 has-focus-visible:outline-violet-700"><input type="radio" name="trb-sg-range" value="week" class="trb-sg-range sr-only" checked>Week</label>
        <label class="flex-1 cursor-pointer rounded-md px-3 py-1.5 text-center text-sm font-medium text-violet-900 hover:text-violet-950 has-checked:bg-white has-checked:text-violet-950 has-checked:shadow-sm has-focus-visible:outline-2 has-focus-visible:outline-violet-700"><input type="radio" name="trb-sg-range" value="month" class="trb-sg-range sr-only">Month</label>
        <label class="flex-1 cursor-pointer rounded-md px-3 py-1.5 text-center text-sm font-medium text-violet-900 hover:text-violet-950 has-checked:bg-white has-checked:text-violet-950 has-checked:shadow-sm has-focus-visible:outline-2 has-focus-visible:outline-violet-700"><input type="radio" name="trb-sg-range" value="year" class="trb-sg-range sr-only">Year</label>
      </fieldset>
    </div>
    <p class="mt-5 text-4xl font-semibold tracking-tight text-violet-950 tabular-nums" id="trb-sg-stat">18,240</p>
    <p class="mt-1 text-sm text-violet-900" id="trb-sg-sub">this week, up 6% on last week</p>
  </div>
  <div class="rounded-2xl bg-[#1c1917] p-6">
    <div class="flex flex-wrap items-center justify-between gap-3">
      <h2 class="text-sm font-semibold text-stone-100">Caption alignment</h2>
      <fieldset class="flex rounded-lg bg-black/40 p-1 ring-1 ring-white/10">
        <legend class="sr-only">Text alignment</legend>
        <label class="flex size-9 cursor-pointer items-center justify-center rounded-md text-stone-300 hover:text-white has-checked:bg-stone-100 has-checked:text-stone-900 has-focus-visible:outline-2 has-focus-visible:outline-offset-1 has-focus-visible:outline-stone-100"><input type="radio" name="trb-sg-align" value="text-left" class="trb-sg-align sr-only" checked><span class="sr-only">Align left</span><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" aria-hidden="true" class="size-5"><path d="M3 5h14M3 10h9M3 15h12"/></svg></label>
        <label class="flex size-9 cursor-pointer items-center justify-center rounded-md text-stone-300 hover:text-white has-checked:bg-stone-100 has-checked:text-stone-900 has-focus-visible:outline-2 has-focus-visible:outline-offset-1 has-focus-visible:outline-stone-100"><input type="radio" name="trb-sg-align" value="text-center" class="trb-sg-align sr-only"><span class="sr-only">Align center</span><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" aria-hidden="true" class="size-5"><path d="M3 5h14M5.5 10h9M4 15h12"/></svg></label>
        <label class="flex size-9 cursor-pointer items-center justify-center rounded-md text-stone-300 hover:text-white has-checked:bg-stone-100 has-checked:text-stone-900 has-focus-visible:outline-2 has-focus-visible:outline-offset-1 has-focus-visible:outline-stone-100"><input type="radio" name="trb-sg-align" value="text-right" class="trb-sg-align sr-only"><span class="sr-only">Align right</span><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" aria-hidden="true" class="size-5"><path d="M3 5h14M8 10h9M5 15h12"/></svg></label>
        <label class="flex size-9 cursor-pointer items-center justify-center rounded-md text-stone-300 hover:text-white has-checked:bg-stone-100 has-checked:text-stone-900 has-focus-visible:outline-2 has-focus-visible:outline-offset-1 has-focus-visible:outline-stone-100"><input type="radio" name="trb-sg-align" value="text-justify" class="trb-sg-align sr-only"><span class="sr-only">Justify</span><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" aria-hidden="true" class="size-5"><path d="M3 5h14M3 10h14M3 15h14"/></svg></label>
      </fieldset>
    </div>
    <p id="trb-sg-preview" class="mt-5 text-left text-sm leading-relaxed text-stone-300">Morning light over the harbour, shot from the north pier at 6:40 with the tide going out and the first ferry still tied up.</p>
  </div>
</div>

Survey matrix that stacks on mobile

  • max-md:grid
  • md:table-cell
  • aria-labelledby
  • has-checked:
  • accent-*

A real table from md: up, with a column header row and one radio group per row. Below md the rows become grid cards: each tr turns into a five-column grid, the question spans the row, the header row is hidden and a small aria-hidden caption appears under every radio. Each input gets its name from aria-labelledby, pointing at its row question and column header, so it reads the same at both sizes; the script counts answered rows.

View code
<form class="w-full max-w-3xl rounded-2xl bg-white p-6 shadow-sm ring-1 ring-teal-950/10 sm:p-8">
  <div class="flex flex-wrap items-baseline justify-between gap-2">
    <h2 class="text-lg font-semibold tracking-tight text-teal-950">How is the new editor working for you?</h2>
    <p id="trb-sm-count" role="status" class="text-sm font-medium text-teal-800">0 of 4 answered</p>
  </div>
  <table class="mt-5 w-full max-md:block">
    <thead class="max-md:hidden">
      <tr class="text-xs text-slate-600">
        <td class="w-2/5"></td>
        <th id="trb-sm-c1" scope="col" class="px-1 pb-3 text-center font-medium">Strongly disagree</th>
        <th id="trb-sm-c2" scope="col" class="px-1 pb-3 text-center font-medium">Disagree</th>
        <th id="trb-sm-c3" scope="col" class="px-1 pb-3 text-center font-medium">Neutral</th>
        <th id="trb-sm-c4" scope="col" class="px-1 pb-3 text-center font-medium">Agree</th>
        <th id="trb-sm-c5" scope="col" class="px-1 pb-3 text-center font-medium">Strongly agree</th>
      </tr>
    </thead>
    <tbody class="max-md:block max-md:space-y-3">
      <tr class="has-checked:bg-teal-50/60 max-md:grid max-md:grid-cols-5 max-md:gap-y-3 max-md:rounded-xl max-md:border max-md:border-slate-200 max-md:p-4 md:border-t md:border-slate-200">
        <th id="trb-sm-q1" scope="row" class="text-left text-sm font-medium text-slate-900 max-md:col-span-5 md:py-4 md:pr-4">The editor stays fast on large files.</th>
        <td class="text-center"><label class="flex cursor-pointer flex-col items-center gap-1.5 py-1"><input type="radio" name="trb-sm-r1" value="1" aria-labelledby="trb-sm-q1 trb-sm-c1" class="trb-sm-radio size-5 cursor-pointer accent-teal-700"><span aria-hidden="true" class="text-[11px] leading-tight text-slate-600 md:hidden">Strongly disagree</span></label></td>
        <td class="text-center"><label class="flex cursor-pointer flex-col items-center gap-1.5 py-1"><input type="radio" name="trb-sm-r1" value="2" aria-labelledby="trb-sm-q1 trb-sm-c2" class="trb-sm-radio size-5 cursor-pointer accent-teal-700"><span aria-hidden="true" class="text-[11px] leading-tight text-slate-600 md:hidden">Disagree</span></label></td>
        <td class="text-center"><label class="flex cursor-pointer flex-col items-center gap-1.5 py-1"><input type="radio" name="trb-sm-r1" value="3" aria-labelledby="trb-sm-q1 trb-sm-c3" class="trb-sm-radio size-5 cursor-pointer accent-teal-700"><span aria-hidden="true" class="text-[11px] leading-tight text-slate-600 md:hidden">Neutral</span></label></td>
        <td class="text-center"><label class="flex cursor-pointer flex-col items-center gap-1.5 py-1"><input type="radio" name="trb-sm-r1" value="4" aria-labelledby="trb-sm-q1 trb-sm-c4" class="trb-sm-radio size-5 cursor-pointer accent-teal-700"><span aria-hidden="true" class="text-[11px] leading-tight text-slate-600 md:hidden">Agree</span></label></td>
        <td class="text-center"><label class="flex cursor-pointer flex-col items-center gap-1.5 py-1"><input type="radio" name="trb-sm-r1" value="5" aria-labelledby="trb-sm-q1 trb-sm-c5" class="trb-sm-radio size-5 cursor-pointer accent-teal-700"><span aria-hidden="true" class="text-[11px] leading-tight text-slate-600 md:hidden">Strongly agree</span></label></td>
      </tr>
      <tr class="has-checked:bg-teal-50/60 max-md:grid max-md:grid-cols-5 max-md:gap-y-3 max-md:rounded-xl max-md:border max-md:border-slate-200 max-md:p-4 md:border-t md:border-slate-200">
        <th id="trb-sm-q2" scope="row" class="text-left text-sm font-medium text-slate-900 max-md:col-span-5 md:py-4 md:pr-4">Comments are easy to find.</th>
        <td class="text-center"><label class="flex cursor-pointer flex-col items-center gap-1.5 py-1"><input type="radio" name="trb-sm-r2" value="1" aria-labelledby="trb-sm-q2 trb-sm-c1" class="trb-sm-radio size-5 cursor-pointer accent-teal-700"><span aria-hidden="true" class="text-[11px] leading-tight text-slate-600 md:hidden">Strongly disagree</span></label></td>
        <td class="text-center"><label class="flex cursor-pointer flex-col items-center gap-1.5 py-1"><input type="radio" name="trb-sm-r2" value="2" aria-labelledby="trb-sm-q2 trb-sm-c2" class="trb-sm-radio size-5 cursor-pointer accent-teal-700"><span aria-hidden="true" class="text-[11px] leading-tight text-slate-600 md:hidden">Disagree</span></label></td>
        <td class="text-center"><label class="flex cursor-pointer flex-col items-center gap-1.5 py-1"><input type="radio" name="trb-sm-r2" value="3" aria-labelledby="trb-sm-q2 trb-sm-c3" class="trb-sm-radio size-5 cursor-pointer accent-teal-700"><span aria-hidden="true" class="text-[11px] leading-tight text-slate-600 md:hidden">Neutral</span></label></td>
        <td class="text-center"><label class="flex cursor-pointer flex-col items-center gap-1.5 py-1"><input type="radio" name="trb-sm-r2" value="4" aria-labelledby="trb-sm-q2 trb-sm-c4" class="trb-sm-radio size-5 cursor-pointer accent-teal-700"><span aria-hidden="true" class="text-[11px] leading-tight text-slate-600 md:hidden">Agree</span></label></td>
        <td class="text-center"><label class="flex cursor-pointer flex-col items-center gap-1.5 py-1"><input type="radio" name="trb-sm-r2" value="5" aria-labelledby="trb-sm-q2 trb-sm-c5" class="trb-sm-radio size-5 cursor-pointer accent-teal-700"><span aria-hidden="true" class="text-[11px] leading-tight text-slate-600 md:hidden">Strongly agree</span></label></td>
      </tr>
      <tr class="has-checked:bg-teal-50/60 max-md:grid max-md:grid-cols-5 max-md:gap-y-3 max-md:rounded-xl max-md:border max-md:border-slate-200 max-md:p-4 md:border-t md:border-slate-200">
        <th id="trb-sm-q3" scope="row" class="text-left text-sm font-medium text-slate-900 max-md:col-span-5 md:py-4 md:pr-4">Exports work the first time.</th>
        <td class="text-center"><label class="flex cursor-pointer flex-col items-center gap-1.5 py-1"><input type="radio" name="trb-sm-r3" value="1" aria-labelledby="trb-sm-q3 trb-sm-c1" class="trb-sm-radio size-5 cursor-pointer accent-teal-700"><span aria-hidden="true" class="text-[11px] leading-tight text-slate-600 md:hidden">Strongly disagree</span></label></td>
        <td class="text-center"><label class="flex cursor-pointer flex-col items-center gap-1.5 py-1"><input type="radio" name="trb-sm-r3" value="2" aria-labelledby="trb-sm-q3 trb-sm-c2" class="trb-sm-radio size-5 cursor-pointer accent-teal-700"><span aria-hidden="true" class="text-[11px] leading-tight text-slate-600 md:hidden">Disagree</span></label></td>
        <td class="text-center"><label class="flex cursor-pointer flex-col items-center gap-1.5 py-1"><input type="radio" name="trb-sm-r3" value="3" aria-labelledby="trb-sm-q3 trb-sm-c3" class="trb-sm-radio size-5 cursor-pointer accent-teal-700"><span aria-hidden="true" class="text-[11px] leading-tight text-slate-600 md:hidden">Neutral</span></label></td>
        <td class="text-center"><label class="flex cursor-pointer flex-col items-center gap-1.5 py-1"><input type="radio" name="trb-sm-r3" value="4" aria-labelledby="trb-sm-q3 trb-sm-c4" class="trb-sm-radio size-5 cursor-pointer accent-teal-700"><span aria-hidden="true" class="text-[11px] leading-tight text-slate-600 md:hidden">Agree</span></label></td>
        <td class="text-center"><label class="flex cursor-pointer flex-col items-center gap-1.5 py-1"><input type="radio" name="trb-sm-r3" value="5" aria-labelledby="trb-sm-q3 trb-sm-c5" class="trb-sm-radio size-5 cursor-pointer accent-teal-700"><span aria-hidden="true" class="text-[11px] leading-tight text-slate-600 md:hidden">Strongly agree</span></label></td>
      </tr>
      <tr class="has-checked:bg-teal-50/60 max-md:grid max-md:grid-cols-5 max-md:gap-y-3 max-md:rounded-xl max-md:border max-md:border-slate-200 max-md:p-4 md:border-y md:border-slate-200">
        <th id="trb-sm-q4" scope="row" class="text-left text-sm font-medium text-slate-900 max-md:col-span-5 md:py-4 md:pr-4">I would miss it if it went away.</th>
        <td class="text-center"><label class="flex cursor-pointer flex-col items-center gap-1.5 py-1"><input type="radio" name="trb-sm-r4" value="1" aria-labelledby="trb-sm-q4 trb-sm-c1" class="trb-sm-radio size-5 cursor-pointer accent-teal-700"><span aria-hidden="true" class="text-[11px] leading-tight text-slate-600 md:hidden">Strongly disagree</span></label></td>
        <td class="text-center"><label class="flex cursor-pointer flex-col items-center gap-1.5 py-1"><input type="radio" name="trb-sm-r4" value="2" aria-labelledby="trb-sm-q4 trb-sm-c2" class="trb-sm-radio size-5 cursor-pointer accent-teal-700"><span aria-hidden="true" class="text-[11px] leading-tight text-slate-600 md:hidden">Disagree</span></label></td>
        <td class="text-center"><label class="flex cursor-pointer flex-col items-center gap-1.5 py-1"><input type="radio" name="trb-sm-r4" value="3" aria-labelledby="trb-sm-q4 trb-sm-c3" class="trb-sm-radio size-5 cursor-pointer accent-teal-700"><span aria-hidden="true" class="text-[11px] leading-tight text-slate-600 md:hidden">Neutral</span></label></td>
        <td class="text-center"><label class="flex cursor-pointer flex-col items-center gap-1.5 py-1"><input type="radio" name="trb-sm-r4" value="4" aria-labelledby="trb-sm-q4 trb-sm-c4" class="trb-sm-radio size-5 cursor-pointer accent-teal-700"><span aria-hidden="true" class="text-[11px] leading-tight text-slate-600 md:hidden">Agree</span></label></td>
        <td class="text-center"><label class="flex cursor-pointer flex-col items-center gap-1.5 py-1"><input type="radio" name="trb-sm-r4" value="5" aria-labelledby="trb-sm-q4 trb-sm-c5" class="trb-sm-radio size-5 cursor-pointer accent-teal-700"><span aria-hidden="true" class="text-[11px] leading-tight text-slate-600 md:hidden">Strongly agree</span></label></td>
      </tr>
    </tbody>
  </table>
</form>

Appearance radios that switch dark mode

  • dark:
  • dark:has-checked:
  • scheme-light dark:scheme-dark
  • accent-*
  • matchMedia

Choose Dark, then System.

The theme radios are cards with small drawn previews, and choosing Dark adds the dark class to the card wrapper, so every dark: utility inside it switches, including dark:has-checked:ring-sky-400 on the cards themselves. System follows prefers-color-scheme through matchMedia. scheme-light dark:scheme-dark tells the browser to draw the native accent-* radios in the density row for the matching background.

View code
<div id="trb-dt-root" class="w-full max-w-2xl">
  <form class="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-zinc-900/10 scheme-light sm:p-8 dark:bg-zinc-900 dark:shadow-none dark:ring-white/10 dark:scheme-dark">
    <fieldset>
      <legend class="text-lg font-semibold tracking-tight text-zinc-900 dark:text-white">Appearance</legend>
      <p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">Applies to this device only.</p>
      <div class="mt-5 grid grid-cols-3 gap-3">
        <label class="cursor-pointer rounded-xl p-2 ring-1 ring-zinc-300 has-checked:ring-2 has-checked:ring-sky-600 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-sky-600 dark:ring-white/15 dark:has-checked:ring-sky-400 dark:has-focus-visible:outline-sky-400">
          <input type="radio" name="trb-dt-theme" value="light" class="trb-dt-theme sr-only" checked>
          <span aria-hidden="true" class="block rounded-lg bg-zinc-100 p-2"><span class="block h-2 w-3/5 rounded-full bg-zinc-400"></span><span class="mt-1.5 block h-6 rounded-md bg-white"></span></span>
          <span class="mt-2 block text-center text-sm font-medium text-zinc-800 dark:text-zinc-200">Light</span>
        </label>
        <label class="cursor-pointer rounded-xl p-2 ring-1 ring-zinc-300 has-checked:ring-2 has-checked:ring-sky-600 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-sky-600 dark:ring-white/15 dark:has-checked:ring-sky-400 dark:has-focus-visible:outline-sky-400">
          <input id="trb-dt-dark" type="radio" name="trb-dt-theme" value="dark" class="trb-dt-theme sr-only">
          <span aria-hidden="true" class="block rounded-lg bg-zinc-800 p-2"><span class="block h-2 w-3/5 rounded-full bg-zinc-500"></span><span class="mt-1.5 block h-6 rounded-md bg-zinc-950"></span></span>
          <span class="mt-2 block text-center text-sm font-medium text-zinc-800 dark:text-zinc-200">Dark</span>
        </label>
        <label class="cursor-pointer rounded-xl p-2 ring-1 ring-zinc-300 has-checked:ring-2 has-checked:ring-sky-600 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-sky-600 dark:ring-white/15 dark:has-checked:ring-sky-400 dark:has-focus-visible:outline-sky-400">
          <input id="trb-dt-system" type="radio" name="trb-dt-theme" value="system" class="trb-dt-theme sr-only">
          <span aria-hidden="true" class="grid grid-cols-2 overflow-hidden rounded-lg"><span class="block bg-zinc-100 p-2"><span class="block h-2 rounded-full bg-zinc-400"></span><span class="mt-1.5 block h-6 rounded-l-md bg-white"></span></span><span class="block bg-zinc-800 py-2 pr-2"><span class="block h-2 w-1/2 rounded-r-full bg-zinc-500"></span><span class="mt-1.5 block h-6 rounded-r-md bg-zinc-950"></span></span></span>
          <span class="mt-2 block text-center text-sm font-medium text-zinc-800 dark:text-zinc-200">System</span>
        </label>
      </div>
    </fieldset>
    <fieldset class="mt-7 border-t border-zinc-200 pt-6 dark:border-white/10">
      <legend class="sr-only">Density</legend>
      <p aria-hidden="true" class="text-sm font-semibold text-zinc-900 dark:text-white">Density</p>
      <div class="mt-3 flex flex-wrap gap-x-6 gap-y-2">
        <label class="flex cursor-pointer items-center gap-2 text-sm text-zinc-700 dark:text-zinc-300"><input type="radio" name="trb-dt-density" value="compact" class="size-4 cursor-pointer accent-sky-600 dark:accent-sky-400"> Compact</label>
        <label class="flex cursor-pointer items-center gap-2 text-sm text-zinc-700 dark:text-zinc-300"><input type="radio" name="trb-dt-density" value="default" checked class="size-4 cursor-pointer accent-sky-600 dark:accent-sky-400"> Default</label>
        <label class="flex cursor-pointer items-center gap-2 text-sm text-zinc-700 dark:text-zinc-300"><input type="radio" name="trb-dt-density" value="roomy" class="size-4 cursor-pointer accent-sky-600 dark:accent-sky-400"> Roomy</label>
      </div>
    </fieldset>
    <p id="trb-dt-status" role="status" class="mt-6 text-sm text-zinc-600 dark:text-zinc-400">Theme: Light</p>
  </form>
</div>

Which method styles a Tailwind radio button best: accent color, appearance-none or the forms plugin?

Use accent-* when all you need is a brand color. Go with appearance-none when the checked indicator has to look custom, and with @tailwindcss/forms when a whole project needs one consistent reset. Underneath all of them sits the same native input type=”radio”, styled with utility classes instead of a separate stylesheet.

Most projects settle on one method. Mixing two usually ends in a conflict.

MethodWhat you controlSetup costMain limit
accent-*Color of the native dot and ringOne classShape and border stay browser-made
appearance-noneEvery visual partBorder, size and dot built by handYou own focus and forced-colors behavior
@tailwindcss/formsBorder, size and checked colorOne pluginaccent-* stops working

Using the accent color utility

Chrome and Edge 93 and Firefox 92 were the first versions to support accent-color, according to browser compatibility data. Safari tinted controls from 15.4 but only counts as full support from Safari 26.2.

That covers every current desktop engine. MDN still labels the property as limited availability rather than Baseline as of October 2026, so test the browsers your audience actually uses.

Tailwind generates accent-* from your palette, so accent-indigo-500 works out of the box, and hover:accent-pink-500 recolors the control on hover (Tailwind docs). In v4, a token such as –color-brand declared inside @theme becomes accent-brand.

The browser picks a contrasting dot color on its own, except in Safari before 26.2 and in Chrome for Android, which skip that contrast step per the same data. Keyboard and screen reader behavior stay native. To resize the control, use size-5 or size-6 (size-* arrived in Tailwind v3.4).

Building from scratch with appearance-none

appearance-none removes the browser’s circle, so the shape has to come back through size-5, rounded-full and a border.

The checked dot can be a sibling span that fades in with peer-checked:opacity-100, or you can skip the dot and thicken the border instead. Checked color comes from checked:border-indigo-600 plus a matching dot color.

Don’t forget focus. focus-visible:ring-2 with an offset keeps keyboard users aware of where they are.

Material Tailwind’s HTML radio docs take this route. A peer input sits first, followed by an absolutely centered span that goes from opacity-0 to peer-checked:opacity-100.

It’s the longest route and the only one with total control. If a design calls for a square box or a star, this is where you end up.

Adding the forms plugin in Tailwind v3 and v4

The plugin is a reset, not a component kit.

Every input type radio gets restyled globally, and a form-radio class covers elements that need the look without the tag. Inputs, selects and textareas get the same reset, which matters when you are styling the rest of your Tailwind forms.

In Tailwind v4, add @plugin “@tailwindcss/forms”; to the main stylesheet after the tailwindcss import, and pass strategy: “base” or “class” inside a block to limit what the plugin generates. Tailwind v3 takes require(‘@tailwindcss/forms’) in the plugins array of tailwind.config.js, with the same strategy option.

Checked color follows text-* utilities, as the README shows with text-pink-500 on a checkbox. Installed globally, the plugin replaces the native look, so accent-* stops tinting radios and text-* takes over.

Version 0.5.7 (November 2023) switched checkboxes and radios back to their normal appearance in forced-colors mode, per the plugin changelog.

How do you style radio button states like checked, focus-visible and disabled?

Prefix the utility with the state you want to target. Variants stack, so dark:checked:border-indigo-400 works exactly as written.

On the input itself, checked:border-indigo-600 sets the selected border, and a slightly darker border under hover: gives pointer feedback. ring-2 and ring-offset-2 behind focus-visible: show up for keyboard focus and stay off for mouse clicks.

Locked options get opacity-50 and cursor-not-allowed under disabled:, plus peer-disabled: on the label so the text dims along with the control. Set the disabled attribute on the single input you want to lock. The rest of the group keeps working.

Pick state colors with color contrast in mind. WCAG success criterion 1.4.11 asks for at least 3:1 contrast between a control’s states and boundaries and the colors next to them (W3C).

A border-gray-300 ring on a white page sits far below that. Disabled controls are exempt from the rule, so dimming them is fine.

In Tailwind v4 the dark: variant follows prefers-color-scheme by default. For a manual toggle, declare @custom-variant dark (&:where(.dark, .dark *)); in your CSS.

In v3 the same job belongs to darkMode: ‘selector’ in tailwind.config.js, which replaced the class strategy in v3.4.1 (Tailwind docs). A toggle that does nothing to your radios usually means this switch is missing.

How do you build custom radio cards and pills with peer-checked and has-checked?

Hide the native input with sr-only, then style its label from the input’s state. peer-checked: works when the input comes before the label as a sibling, while has-checked: works from a wrapping label and has no ordering rule.

I’d use peer-checked for pills and simple tiles, and has-checked for cards with text, badges, icons and other bits that all change on selection.

Any card layout from your Tailwind card components can become a radio card this way, because only the label’s classes change.

The peer-checked pattern

Here’s the build for one card.

  1. Put the input first and the label second, as siblings. The peer marker only reaches later siblings (Tailwind docs).
  2. Give the input sr-only and peer, a shared name, its own value and an id that the label’s for attribute points to.
  3. Style the label as the card: border, rounded-lg, p-4, cursor-pointer.
  4. Add peer-checked:border-indigo-600 and peer-checked:ring-2 to the label.
  5. Add peer-focus-visible:ring-2 so keyboard focus shows on the card.
  6. Show a check icon from another sibling, positioned absolutely: hidden by default, peer-checked:block when selected.

Wrap each input and label pair in its own element, a div or an li. Without that wrapper, one checked input styles the labels of every later option too.

The has-checked pattern

Tailwind v3.4 (December 19, 2023) introduced has-* variants for the :has() pseudo-class. The release post shows a payment-method label that recolors itself with has-[:checked].

The current docs write the same thing as has-checked:bg-indigo-50 and add dark:has-checked:bg-indigo-950 for dark mode.

One label wraps the input and all the card content. The release-post example keeps the native radio visible and tints it with accent-indigo-500, which makes it a blend of two methods. The current docs example styles the visible input with checked:border-indigo-500 instead.

There’s also peer-has-checked:, which hides or shows an element elsewhere in the layout when a peer’s descendant is checked.

Nothing to order here. Move the input anywhere inside the label and the styles still apply.

How do you make a custom Tailwind radio group accessible?

Wrap the options in a fieldset with a legend, give every input the same name, tie each label to its input, and keep the input focusable. Native radios then supply the radio role and arrow-key navigation with no extra attributes.

  • A fieldset with a legend that names the group
  • One shared name attribute across all inputs
  • A label for attribute matching each input id
  • sr-only instead of hidden, so the input stays in the tab order and the accessibility tree
  • A visible focus ring on the card through peer-focus-visible

Skipping labels is common. WebAIM’s 2026 Million report (February 2026, one million home pages) found 33.1% of form inputs not properly labeled.

Radio labels are one piece of the broader practice of building accessible forms, which also covers errors, required fields, instructions and the rest.

Tab moves focus into the group and lands on the checked radio, or on the first one when nothing is checked. Arrow keys then move focus and check the next option, wrapping from last to first (W3C WAI-ARIA Authoring Practices Guide).

With the input hidden through sr-only, the focus ring has to live on the card, since the input itself is invisible. Test with Tab and the arrow keys before anything else, because a pill that looks right but never shows focus fails real users.

Native inputs already carry the radio role, so layering ARIA attributes on them adds nothing. WebAIM’s 2026 data points the same direction: pages using ARIA averaged 59.1 detected errors against 42 for pages without it, a correlation WebAIM does not pin on ARIA alone.

How do you control a Tailwind radio group in React and Vue?

Keep the native input and let the framework hold the selected value. React binds each input with checked and onChange, Vue with v-model, and the CSS :checked state follows both, so peer-checked and has-checked styling keeps working unchanged.

In React, store the selection in one state variable and set checked on each input by comparing that variable with the input’s value. The value attribute is what the form submits, and defaultChecked covers the uncontrolled case (React docs).

Vue works the other way around. Point v-model on every input at the same ref and give each one a value attribute, and v-model reads the checked property and listens for change events (Vue docs).

  • React treats a radio with a boolean checked prop as controlled and will not let it switch back to uncontrolled
  • checked without onChange gives a read-only input and a console warning
  • Vue ignores the initial checked attribute, so the starting selection lives in JavaScript state
  • Vue’s own radio examples omit name, which keeps state correct but skips native grouping, so add one

Prefer CSS state over state-driven classes for radio cards. Fewer moving parts, same result.

When a design does need conditional classes, write complete strings such as border-indigo-600. Fragments like border-${color}-600 never reach the stylesheet, because Tailwind reads source files as plain text.

If the project is not wired up yet, setting up Tailwind CSS in a React project takes a Vite plugin and one CSS import.

Which component library offers a ready-made Tailwind radio group?

For a plain styled single-choice list, native inputs with peer-checked still beat every library below. Headless UI earns its place when you need object values and custom indicators. shadcn/ui suits teams that want copy-in React components, and daisyUI or Flowbite give you class-based markup with no logic.

LibraryApproachStyling hookFramework
Headless UIUnstyled componentsdata-checked, data-disabledReact, Vue (the Vue docs are v1)
shadcn/uiCopy-in componentsClasses on RadioGroupItemReact
daisyUIComponent classesradio, radio-primaryAny
FlowbiteTailwind classes on a native inputchecked:, peer-checked:Any

Headless UI’s Radio renders as a span with the radio role, not a native input, so peer-checked has nothing to target. Style it with data-checked: and group-data-checked: instead, as the docs show.

It also binds objects as values, with a by prop for comparing them, and the name prop renders a hidden input so the group submits with a native form (Headless UI docs, v2.1). Tailwind Plus sold predesigned radio groups built on it, and its Catalyst kit wraps it with Fieldset, Legend and Description components. Tailwind Labs closed Tailwind Plus to new sign-ups on September 9, 2026, the day it announced it was joining Shopify, so only existing customers can still get them.

shadcn/ui is a different story as of October 2026. Its Radio Group docs offer three primitives, Base UI, React Aria and Radix UI, installed through the shadcn CLI. Older write-ups that call it a Radix wrapper describe the earlier default.

Libraries like these package accessible UI components, so focus handling and arrow-key movement get written once instead of per project.

daisyUI 5.7.50 documents 8 color classes (radio-neutral through radio-error) and 5 sizes (radio-xs to radio-xl) on a plain input type radio, and warns that each set needs its own name. Flowbite’s v4.0 radio docs write the appearance-none pattern directly as Tailwind classes, with checked:border-brand and a focus ring.

Reach for a library when you need object values or a non-native indicator, or when the project already has a design system built on one. Radios are a small part of that choice, and UI component library examples for selects, dialogs, tabs and so on show whether a kit earns its dependency.

When does a Tailwind radio button style break?

The forms plugin switching off accent-* and a hidden input dropping out of the keyboard order cause most of the damage. A v3 snippet running on v4 defaults causes the rest. Each failure has a quick check and a fix.

Forms plugin and accent color conflict

Install the plugin globally and accent-* does nothing to radios.

The plugin replaces the native look with its own styles, so no native control is left for accent-color to tint. A 2022 iThome tutorial reports the same result, then adds checked:bg-none to remove the plugin’s white dot image and recolors the control with an outline in the checked state.

The simplest way out is to set the checked color with text-* utilities and drop accent-* for radios. Or switch the plugin to the class strategy, so only inputs carrying form-radio get the reset and plain radios keep accent-* working (plugin README).

Hidden inputs and lost keyboard focus

Flowbite’s current radio docs build their card layout with hidden peer on the input (v4.0 docs, retrieved October 2026).

hidden is display: none, and MDN states that it removes an element from the accessibility tree. The radio then disappears for screen readers and cannot take Tab focus.

Tailwind’s own forced-color-adjust docs use sr-only on the radios in a color swatch picker instead, wrapped in forced-color-adjust-none so the swatch colors survive forced colors mode.

Focus rings fail in a second way. Tailwind’s docs describe ring utilities as a solid box-shadow, and MDN notes that forced colors mode sets box-shadow to none.

Pair peer-focus-visible:ring-2 with peer-focus-visible:outline-hidden. In v4, outline-hidden keeps an invisible outline that still shows in forced colors mode, while the new outline-none removes it, so a v3 snippet with focus:outline-none loses its indicator after migration (Tailwind upgrade guide).

Tab through the group with the keyboard first. Then emulate forced-colors: active in developer tools, as Tailwind’s docs suggest, and finish with a screen reader pass over the legend and each label. All of it belongs on your web accessibility checklist for any custom radio.

Version-specific setup differences

Tailwind v4.0 targets Safari 16.4, Chrome 111 and Firefox 128 and does not work in older browsers (Tailwind upgrade guide).

Treat that as a cross-browser compatibility decision before upgrading, because v3.4 stays the documented option for older browsers.

BehaviorTailwind v3Tailwind v4
Plugin loadingplugins array in tailwind.config.js@plugin directive in CSS
Default border colorgray-200currentColor
Default ring3px, blue-5001px, currentColor
outline-noneInvisible outline, kept in forced colorsRemoves the outline, use outline-hidden

A radio card that says border with no color turns text-colored on v4. One more change hits cards too: hover: styles now apply only when the primary input device supports hover, so touch users never see them.

Sources disagree on when @tailwindcss/forms became v4-ready. Some third-party migration guides list 0.5.7 as compatible, while the plugin changelog records alpha v4 install support in 0.5.8 (August 2024) and beta support in 0.5.10 (January 2025).

Trust the changelog and install the latest release. As of October 2026 that is 0.5.11, dated December 2025.

Tailwind Radio Button FAQ

Should you use a radio button or a checkbox for a yes or no choice?

Use radio buttons whenever only one option can be selected. Vue’s docs add a practical reason: browsers leave unchecked boxes out of form submissions, so radio inputs guarantee that one of the two values arrives.

How do you put Tailwind radio buttons in a row?

Wrap the inputs and labels in a flex container with a gap, such as flex gap-4. Flowbite’s horizontal list group stacks the options on small screens and switches to a row from the sm breakpoint with sm:flex.

How do you preselect a radio option?

Add the checked attribute to one input in a native group. In React, use defaultChecked on an uncontrolled input, and in Headless UI pass defaultValue to the group.

Only one option per group can start checked.

Why do two radio groups on the same page change each other?

They share a name attribute, so the browser merges them into one group and checking an option in one set clears the other. Give each set its own unique name, as daisyUI’s radio docs warn.

Can users uncheck a radio button once it is selected?

No. Clicking a checked radio leaves it checked, and only another option in the group, a form reset or a script clears the selection.

For an optional field, add a “None” radio or switch to a checkbox.

Which Styling Method to Ship First

Start with accent-* on the native input. A Tailwind radio button that only changes color adds no custom CSS to test.

Move up only when the design demands a different shape, first to peer-checked or has-checked cards with an sr-only input, and past that to a headless library if you need object values.

Each step adds markup that keyboard and forced colors checks must cover, plus a screen reader pass, so the cheapest working option wins.

The cost of accent-* is design range. It tints the control but leaves its shape to the browser.

Verified October 29, 2026: Tailwind’s upgrade guide says the team is exploring a v4 compatibility mode for older browsers, and a release would end the advice to stay on v3.4 for them.