Skip to content
UI Kit
Accent color

Radio group

One choice from a few, on native radios: as a list, as cards with prices or as tiles with icons.

One of 9 components in Forms and inputs

3 variants

Variants

List

Vertical or in a row. The point springs in, arrow keys move the choice, a missing answer shows as text.

Cards

Each option a surface with description, price and an optional tag. The ring glides to the new choice and settles with a light overshoot.

Tiles

Centered tiles with an icon for visual choices, three across from tablet up.

Details

What Radio group brings along, read from the registry and the source.

Variants
3
Version
1.0.1 · since 1.0.0
File
components/ui/radio-group.tsx
Brings along
utilskiticoninput
npm packages
None
Languages
Texts come through props
Studio
Adjust in code

Props

The RadioGroupProps interface from components/ui/radio-group.tsx, with the comments from the code.

  • legendrequired
    React.ReactNode
  • hideLegendoptional
    boolean

    Hide the legend visually, it stays for screen readers

  • descriptionoptional
    React.ReactNode
  • erroroptional
    React.ReactNode
  • optionsrequired
    RadioOption[]
  • nameoptional
    string
  • valueoptional
    string
  • defaultValueoptional
    string
  • onValueChangeoptional
    (value: string) => void
  • variantoptional
    'list' | 'cards' | 'tiles'

    list: dots with labels. cards: bordered surfaces. tiles: centered cards with an icon on top.

  • orientationoptional
    'vertical' | 'horizontal'
  • columnsoptional
    1 | 2 | 3 | 4

    Columns for cards and tiles from tablet up

  • requiredoptional
    boolean
  • disabledoptional
    boolean

Accessibility

Read from the source of the component.

  • Sets aria-describedby, aria-invalid, aria-labelledby and aria-required, so screen readers announce purpose and state.
  • Uses the role radiogroup.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • With reduced motion (prefers-reduced-motion) it stays still. 3 places in the code switch motion off then.
  • No animations, it stands still from the start.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 237 lines
components/ui/radio-group.tsx
'use client';

import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import { cn, useInView } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { FieldError, FieldHint } from '@/components/ui/input';

export interface RadioOption {
  value: string;
  label: React.ReactNode;
  description?: React.ReactNode;
  icon?: IconName;
  /** Quiet text on the right of a card, e.g. a price */
  meta?: React.ReactNode;
  /** Small label next to the title, e.g. "Popular" */
  tag?: React.ReactNode;
  disabled?: boolean;
}

export interface RadioGroupProps {
  legend: React.ReactNode;
  /** Hide the legend visually, it stays for screen readers */
  hideLegend?: boolean;
  description?: React.ReactNode;
  error?: React.ReactNode;
  options: RadioOption[];
  name?: string;
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  /** list: dots with labels. cards: bordered surfaces. tiles: centered cards with an icon on top. */
  variant?: 'list' | 'cards' | 'tiles';
  orientation?: 'vertical' | 'horizontal';
  /** Columns for cards and tiles from tablet up */
  columns?: 1 | 2 | 3 | 4;
  required?: boolean;
  disabled?: boolean;
  className?: string;
}

// useLayoutEffect warns on the server, the measurement only matters in the browser
const useIsoLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;

/** The ring that slides from the old card to the newly chosen one, with a light overshoot */
function SlidingRing({ container, value }: { container: React.RefObject<HTMLDivElement | null>; value?: string }) {
  const [box, setBox] = useState<{ x: number; y: number; w: number; h: number; animate: boolean } | null>(null);
  const shown = useRef(false);
  useIsoLayoutEffect(() => {
    const root = container.current;
    if (!root) return;
    const measure = (animate: boolean) => {
      const label = root.querySelector('input:checked')?.closest('label');
      if (!label) {
        shown.current = false;
        return setBox(null);
      }
      setBox({ x: label.offsetLeft, y: label.offsetTop, w: label.offsetWidth, h: label.offsetHeight, animate: animate && shown.current });
      shown.current = true;
    };
    measure(true);
    const ro = new ResizeObserver(() => measure(false));
    ro.observe(root);
    return () => ro.disconnect();
  }, [value]);
  if (!box) return null;
  return (
    <span
      aria-hidden
      className={cn(
        'pointer-events-none absolute left-0 top-0 z-10 rounded-xl shadow-[0_0_0_1.5px_color-mix(in_oklab,var(--primary)_70%,transparent),0_0_0_5px_color-mix(in_oklab,var(--primary)_10%,transparent)]',
        box.animate && 'transition-[translate,width,height] duration-[460ms] ease-[cubic-bezier(0.3,1.25,0.5,1)] motion-reduce:transition-none',
      )}
      style={{ translate: `${box.x}px ${box.y}px`, width: box.w, height: box.h }}
    />
  );
}

function Dot({ className }: { className?: string }) {
  return (
    <span aria-hidden className={cn('relative grid size-[18px] shrink-0 place-items-center transition-[scale] duration-150 group-active/radio:scale-90', className)}>
      <span className="absolute inset-0 rounded-full border border-border-strong bg-background shadow-[var(--shadow-sm)] transition-[border-color,background-color,box-shadow] duration-150 group-hover/radio:border-[color-mix(in_oklab,var(--foreground)_35%,var(--border-strong))] group-has-[:checked]/radio:border-primary group-has-[:checked]/radio:bg-primary group-has-[:focus-visible]/radio:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_22%,transparent)] group-has-[[aria-invalid]]/radio:border-destructive" />
      {/* The point springs in with an overshoot */}
      <span className="relative size-[7px] scale-0 rounded-full bg-primary-foreground transition-[scale] duration-[380ms] ease-[cubic-bezier(0.34,1.7,0.64,1)] group-has-[:checked]/radio:scale-100 motion-reduce:transition-none" />
    </span>
  );
}

const COLS = { 1: '', 2: 'sm:grid-cols-2', 3: 'sm:grid-cols-3', 4: 'sm:grid-cols-2 lg:grid-cols-4' };

/**
 * One choice from a few, built on native radio inputs (arrow keys, form values and validation for free).
 *   <RadioGroup legend="Delivery" variant="cards" options={[{ value: 'standard', label: 'Standard', meta: 'Free' }]} />
 */
export function RadioGroup({ legend, hideLegend, description, error, options, name, value, defaultValue, onValueChange, variant = 'list', orientation = 'vertical', columns = 1, required, disabled, className }: RadioGroupProps) {
  const id = useId();
  const groupName = name ?? id;
  const [inner, setInner] = useState(defaultValue);
  const current = value ?? inner;
  const groupRef = useRef<HTMLDivElement>(null);
  // The options rise one after another as the group scrolls in (kit-stagger from kit.css)
  const [viewRef, inView] = useInView<HTMLDivElement>();
  const [done, setDone] = useState(false);
  useEffect(() => {
    if (!inView) return;
    const t = window.setTimeout(() => setDone(true), 1300);
    return () => window.clearTimeout(t);
  }, [inView]);
  // Description and error describe the group, so they hang on the element with role=radiogroup
  const describedBy = cn(description && `${id}-desc`, error && `${id}-err`) || undefined;

  return (
    <fieldset className={cn('min-w-0', className)} disabled={disabled}>
      <legend id={`${id}-legend`} className={cn('text-sm font-medium text-foreground', hideLegend && 'sr-only')}>
        {legend}
        {required && (
          <span aria-hidden className="ms-0.5 text-[color-mix(in_oklab,var(--destructive)_85%,var(--foreground))]">
            *
          </span>
        )}
      </legend>
      {description && (
        <FieldHint id={`${id}-desc`} className="mt-1">
          {description}
        </FieldHint>
      )}
      <div
        ref={(el) => {
          groupRef.current = el;
          viewRef.current = el;
        }}
        role="radiogroup"
        style={{ '--kit-step': '70ms' } as React.CSSProperties}
        aria-labelledby={`${id}-legend`}
        aria-describedby={describedBy}
        aria-required={required || undefined}
        aria-invalid={error ? true : undefined}
        className={cn(
          'kit-stagger relative',
          !inView && 'kit-stagger-wait',
          done && 'kit-stagger-done',
          !hideLegend || description ? 'mt-4' : '',
          variant === 'list' ? (orientation === 'horizontal' ? 'flex flex-wrap gap-x-7 gap-y-3' : 'grid gap-4') : cn('grid gap-3', COLS[columns]),
        )}
      >
        {variant !== 'list' && <SlidingRing container={groupRef} value={current} />}
        {options.map((o) => {
          const oid = `${id}-${o.value}`;
          const checked = current === o.value;
          const input = (
            <input
              type="radio"
              id={oid}
              name={groupName}
              value={o.value}
              checked={value !== undefined ? checked : undefined}
              defaultChecked={value === undefined ? defaultValue === o.value : undefined}
              required={required}
              disabled={o.disabled}
              aria-invalid={error ? true : undefined}
              aria-describedby={o.description ? `${oid}-d` : undefined}
              onChange={() => {
                setInner(o.value);
                onValueChange?.(o.value);
              }}
              className="peer sr-only"
            />
          );
          if (variant === 'list') {
            return (
              <label key={o.value} htmlFor={oid} className={cn('group/radio flex cursor-pointer items-start gap-3', o.disabled && 'cursor-not-allowed opacity-50')}>
                {input}
                <span className="flex h-5 items-center">
                  <Dot />
                </span>
                <span className="min-w-0">
                  <span className="block text-sm font-medium leading-5 text-foreground">{o.label}</span>
                  {o.description && (
                    <span id={`${oid}-d`} className="mt-0.5 block text-[13px] leading-5 text-muted-foreground">
                      {o.description}
                    </span>
                  )}
                </span>
              </label>
            );
          }
          const tiles = variant === 'tiles';
          return (
            <label
              key={o.value}
              htmlFor={oid}
              className={cn(
                'group/radio relative flex cursor-pointer rounded-xl border border-border bg-card p-4 shadow-[var(--shadow-sm)] transition-[border-color,background-color,box-shadow,scale,transform,opacity,filter] duration-300 hover:border-border-strong active:scale-[0.985] motion-reduce:active:scale-100',
                'has-[:checked]:border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] has-[:checked]:bg-[color-mix(in_oklab,var(--primary)_5%,var(--card))] has-[:checked]:shadow-[0_0_0_1px_color-mix(in_oklab,var(--primary)_60%,var(--border)),var(--shadow-sm)]',
                'has-[:focus-visible]:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_22%,transparent)]',
                error && 'border-[color-mix(in_oklab,var(--destructive)_50%,var(--border))]',
                // Tiles stand upright from tablet up, on phones they become rows: three tall tiles would fill the screen
                tiles ? 'items-center gap-3.5 sm:flex-col sm:gap-3 sm:px-4 sm:pb-5 sm:pt-6 sm:text-center' : 'items-start gap-3.5',
                o.disabled && 'cursor-not-allowed opacity-50',
              )}
            >
              {input}
              {o.icon && (
                <span
                  className={cn(
                    'grid shrink-0 place-items-center rounded-lg border border-border bg-surface text-muted-foreground transition-[color,border-color,scale] duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] group-has-[:checked]/radio:scale-105 group-has-[:checked]/radio:border-[color-mix(in_oklab,var(--primary)_35%,var(--border))] group-has-[:checked]/radio:text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]',
                    tiles ? 'size-10 sm:size-11' : 'size-9',
                  )}
                >
                  <Icon name={o.icon} size={tiles ? 20 : 18} />
                </span>
              )}
              <span className="min-w-0 flex-1">
                <span className={cn('flex flex-wrap items-center gap-x-2 gap-y-1 text-sm font-medium leading-5 text-foreground', tiles && 'sm:justify-center')}>
                  {o.label}
                  {o.tag && <span className="rounded-full bg-[color-mix(in_oklab,var(--primary)_12%,transparent)] px-2 py-px text-[11px] font-medium text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">{o.tag}</span>}
                </span>
                {o.description && (
                  <span id={`${oid}-d`} className="mt-1 block text-[13px] leading-5 text-muted-foreground">
                    {o.description}
                  </span>
                )}
              </span>
              {!tiles && o.meta && <span className="shrink-0 text-sm font-medium tabular-nums text-foreground">{o.meta}</span>}
              {tiles && o.meta && <span className="text-[13px] tabular-nums text-muted-foreground">{o.meta}</span>}
              <Dot className={cn(tiles ? 'sm:absolute sm:end-3 sm:top-3' : 'mt-px')} />
            </label>
          );
        })}
      </div>
      {error && (
        <FieldError id={`${id}-err`} className="mt-3">
          {error}
        </FieldError>
      )}
    </fieldset>
  );
}

Frequent questions

How do I install Radio group?

With aiw add @ai-website/radio-group. The command puts components/ui/radio-group.tsx into your project, the code is yours from then on. It brings along icon and input. It needs no further npm packages.

Which variants does Radio group have?

3 variants, List, Cards and Tiles. You pick one with the variant prop, for example variant="tiles".

Can an AI assistant build with Radio group?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Radio group with aiw_components and install it with aiw_add @ai-website/radio-group. Name the variant in your prompt, for example variant="cards", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard