Skip to content

Radio group

Group of native radio inputs with roving keyboard focus.

Browsers
Chrome 123+Firefox 121+Safari 17.5+
Tier
0
Bundle compressed
946 B
Forced layout budget
0 ms
Axe budget
0
SSR compatible
Yes
Built on
elements/choice
Published contract
Test harness

Playground

Radio group

Default

import { RadioGroup } from "@gunkin/uiify";

export default function Example() {
  return (
    <RadioGroup
      className=""
      id="plan"
      aria-label="Billing plan"
      defaultValue="pro"
      items={[
        { value: "free", label: "Free", className: "" },
        { value: "pro", label: "Pro", className: "" },
      ]}
    />
  );
}
/* Checkbox, Radio, Switch — all native <input>s styled with appearance:none.
   State comes from :checked / :indeterminate (works uncontrolled) and the
   [data-state] / [data-disabled] attributes (set when controlled). Thumbs and
   checkmarks are pseudo-elements on the input itself. */

@layer components {
  :where([data-uiify-checkbox] > input, [data-uiify-radio]) {
    appearance: none;
    inline-size: 1.25rem;
    block-size: 1.25rem;
    margin: 0;
    border: var(--border-width) solid var(--input);
    background-color: var(--card);
    display: inline-grid;
    place-content: center;
    cursor: pointer;
    transition: var(--transition-colors);
  }

  [data-uiify-checkbox] {
    --uiify-checkbox-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.6 8.6l3 3L12.4 4.8' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E")
      center / 100% 100% no-repeat;

    display: inline-flex;
    align-items: center;
    gap: var(--space-sm);
    cursor: pointer;
    user-select: none;
    color: var(--foreground);
    font-size: var(--text-sm);
    line-height: var(--leading-normal);
  }

  [data-uiify-checkbox] > input {
    border-radius: var(--radius-xs);
    corner-shape: squircle;
  }

  [data-uiify-radio] {
    border-radius: var(--radius-full);
  }

  /* Checked fill (covers controlled [data-state] and uncontrolled :checked). */
  [data-uiify-checkbox] > input:checked,
  [data-uiify-checkbox] > input[data-state="checked"] {
    border-color: var(--primary);
    background-color: var(--primary);
  }

  /* Radio checked state is a thick accent ring around a white center, not a
     filled dot — matches the 2a/2b handoff's target/bullseye radio anatomy. */
  [data-uiify-radio]:checked,
  [data-uiify-radio][data-state="checked"] {
    border-width: 5px;
    border-color: var(--primary);
    background-color: var(--card);
  }

  /* Checkmark. */
  [data-uiify-checkbox] > input::before {
    content: "";
    inline-size: 1rem;
    block-size: 1rem;
    transform: scale(0);
    transition: transform var(--duration-fast) var(--ease-out);
    background-color: var(--primary-foreground);
    mask: var(--uiify-checkbox-mask);
  }

  [data-uiify-checkbox] > input:checked::before,
  [data-uiify-checkbox] > input[data-state="checked"]::before {
    transform: scale(1);
  }

  /* Indeterminate dash. */
  [data-uiify-checkbox] > input:indeterminate,
  [data-uiify-checkbox] > input[data-state="indeterminate"] {
    border-color: var(--primary);
    background-color: var(--primary);
  }

  [data-uiify-checkbox] > input:indeterminate::before,
  [data-uiify-checkbox] > input[data-state="indeterminate"]::before {
    transform: scale(1);
    mask: none;
    clip-path: inset(42% 12% 42% 12%);
  }

  :where([data-uiify-checkbox] > input, [data-uiify-radio]):disabled,
  :where([data-uiify-checkbox] > input, [data-uiify-radio])[data-disabled] {
    opacity: 0.5;
    cursor: not-allowed;
  }

  [data-uiify-checkbox]:has(> input:disabled) {
    opacity: 0.5;
    cursor: not-allowed;
  }

  [data-uiify-radio-group] {
    display: flex;
    flex-direction: column;
    gap: var(--space-md);
  }

  /* Switch — a pill track with a sliding thumb pseudo-element. */
  [data-uiify-switch] {
    appearance: none;
    position: relative;
    inline-size: 2.25rem;
    block-size: 1.25rem;
    margin: 0;
    flex: none;
    border: none;
    border-radius: var(--radius-full);
    background-color: var(--input);
    cursor: pointer;
    transition: background-color var(--duration-fast) var(--ease-out);
  }

  [data-uiify-switch]::before {
    content: "";
    position: absolute;
    inset-block-start: 2px;
    inset-inline-start: 2px;
    inline-size: calc(1.25rem - 4px);
    block-size: calc(1.25rem - 4px);
    border-radius: var(--radius-full);
    background-color: var(--gray-0);
    box-shadow: var(--shadow-1);
    transition: translate var(--duration-fast) var(--ease-out);
  }

  [data-uiify-switch]:checked,
  [data-uiify-switch][data-state="checked"] {
    background-color: var(--primary);
  }

  [data-uiify-switch]:checked::before,
  [data-uiify-switch][data-state="checked"]::before {
    translate: 1rem 0;
  }

  [data-uiify-switch]:disabled,
  [data-uiify-switch][data-disabled] {
    opacity: 0.5;
    cursor: not-allowed;
  }
}

@layer components {
  label:has([data-uiify-radio]),
  [data-uiify-radio-group] label {
    user-select: none;
    cursor: pointer;
  }
}
import { RadioGroup } from "@gunkin/uiify";

export default function Example() {
  return (
    <RadioGroup
      className="
        flex flex-col gap-md p-md data-[orientation=horizontal]:flex-row [&>[data-part=item]]:flex
        [&>[data-part=item]]:items-center [&>[data-part=item]]:gap-sm [&_[data-part=item]>label]:flex-auto
        [&_[data-part=item]>label]:cursor-pointer [&_[data-part=item]>label]:select-none
      "
      id="plan"
      aria-label="Billing plan"
      defaultValue="pro"
      items={[
        {
          value: "free",
          label: "Free",
          className:
            "appearance-none inline-grid size-5 flex-none place-content-center m-0 " +
            "border-[length:var(--border-width)] border-solid border-input rounded-full " +
            "bg-card cursor-pointer [transition:var(--transition-colors)] " +
            "checked:border-[5px] checked:border-primary data-[state=checked]:border-[5px] " +
            "data-[state=checked]:border-primary focus-visible:outline-none " +
            "focus-visible:[box-shadow:0_0_0_var(--ring-width)_color-mix(in_oklch,_var(--ring)_16%,_transparent)] " +
            "disabled:opacity-50 disabled:cursor-not-allowed data-[disabled]:opacity-50 " +
            "data-[disabled]:cursor-not-allowed",
        },
        {
          value: "pro",
          label: "Pro",
          className:
            "appearance-none inline-grid size-5 flex-none place-content-center m-0 " +
            "border-[length:var(--border-width)] border-solid border-input rounded-full " +
            "bg-card cursor-pointer [transition:var(--transition-colors)] " +
            "checked:border-[5px] checked:border-primary data-[state=checked]:border-[5px] " +
            "data-[state=checked]:border-primary focus-visible:outline-none " +
            "focus-visible:[box-shadow:0_0_0_var(--ring-width)_color-mix(in_oklch,_var(--ring)_16%,_transparent)] " +
            "disabled:opacity-50 disabled:cursor-not-allowed data-[disabled]:opacity-50 " +
            "data-[disabled]:cursor-not-allowed",
        },
      ]}
    />
  );
}
/* app.css */

@layer reset, ui, app, tokens, base, demo, components, utilities, theme;

@import "@gunkin/uiify/styles/reset";

@import "@gunkin/uiify/components/behavior.css";

@import "@gunkin/uiify/styles/tokens";

@import "@gunkin/uiify/styles/tailwind";

@import "tailwindcss/utilities.css";

@theme { --spacing: 0.25rem; --font-weight-normal: 400; --font-weight-medium: 500; --font-weight-semibold: 600; --font-weight-bold: 700; }

@source inline('flex flex-col gap-md p-md data-[orientation=horizontal]:flex-row [&>[data-part=item]]:flex [&>[data-part=item]]:items-center [&>[data-part=item]]:gap-sm [&_[data-part=item]>label]:flex-auto [&_[data-part=item]>label]:cursor-pointer [&_[data-part=item]>label]:select-none appearance-none inline-grid size-5 flex-none place-content-center m-0 border-[length:var(--border-width)] border-solid border-input rounded-full bg-card cursor-pointer [transition:var(--transition-colors)] checked:border-[5px] checked:border-primary data-[state=checked]:border-[5px] data-[state=checked]:border-primary focus-visible:outline-none focus-visible:[box-shadow:0_0_0_var(--ring-width)_color-mix(in_oklch,_var(--ring)_16%,_transparent)] disabled:opacity-50 disabled:cursor-not-allowed data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed');

@layer base { :root { --uiify-control-text: var(--text-sm); } @media (pointer: coarse) { :root { --uiify-control-text: var(--text-base); } } }

/* Authored animation and registered properties used by the Tailwind skin.
 * Visual rules belong in utility recipes; only at-rule definitions live here. */
@layer components {
  @keyframes uiify-presence-in {
    from {
      opacity: 0;
      scale: var(--presence-scale);
    }
  }
}

@layer components {
  @keyframes uiify-presence-out {
    to {
      opacity: 0;
      scale: var(--presence-scale);
    }
  }
}

@property --uiify-spinner-tail {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}

@property --uiify-spinner-length {
  syntax: "<angle>";
  inherits: false;
  initial-value: 36deg;
}

@layer components {
  @keyframes uiify-spinner-rotate {
    to {
      transform: rotate(360deg);
    }
  }
}

@layer components {
  @keyframes uiify-spinner-orbit {
    0% {
      --uiify-spinner-tail: 0deg;
      --uiify-spinner-length: 36deg;
    }

    50% {
      --uiify-spinner-tail: 90deg;
      --uiify-spinner-length: 324deg;
    }

    100% {
      --uiify-spinner-tail: 360deg;
      --uiify-spinner-length: 36deg;
    }
  }
}

@layer components {
  @keyframes uiify-skeleton-pulse {
    50% {
      opacity: 0.45;
    }
  }
}

@layer components {
  @keyframes uiify-skeleton-shimmer {
    to {
      background-position: -200% 0;
    }
  }
}

@layer components {
  @keyframes uiify-skeleton-reveal {
    from {
      opacity: 0;
    }
    to {
      opacity: 1;
    }
  }
}

@property --candle-body-height {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 0%;
}

@property --candle-body-top {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 50%;
}

@property --candle-slot {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 100%;
}

@property --candle-wick-height {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 0%;
}

@property --candle-wick-top {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 50%;
}

@property --candle-x {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 0%;
}

@property --candle-scale {
  syntax: "<number>";
  inherits: true;
  initial-value: 1;
}

@property --line-y {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 50%;
}

@property --line-prev-y {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 50%;
}

@property --line-count {
  syntax: "<integer>";
  inherits: false;
  initial-value: 1;
}

@property --line-scale {
  syntax: "<number>";
  inherits: true;
  initial-value: 1;
}

@layer components {
  @keyframes uiify-otp-input-caret-blink {
    50% {
      opacity: 0;
    }
  }
}

API

RadioGroup

PropTypeDefaultDescription
id*string—Id prefix for the group and for each generated radio input.
items*RadioGroupItem[]—Options to render; the group is data-driven rather than compound.
defaultValuestring—Value checked on first render; the browser owns selection from then on, so the group works without JavaScript.
namestring—Form field name shared by every radio input in the group.
disabledbooleanfalseDisables every radio in the group.
orientation"horizontal" | "vertical""vertical"Layout direction reported to assistive technology and used for styling.
classNamestring—Class applied to the group element.
aria-labelstring—Accessible name for the radio group.

RadioGroupItem

PropTypeDefaultDescription
value*string—Value submitted when this radio is selected.
label*ReactNode—Label content rendered next to this radio.
disabledboolean—Disables this radio only.
classNamestring—Class applied to this item's wrapper.

Keyboard

KeyAction
TabMove focus to the group
Arrow keysRove and select between options

Accessibility

Native radio inputs in a roving-focus group.

Styling hooks

data-statedata-disabled