Zum Inhalt springen
UI Kit
Accent

Optionsgruppe

Eine Wahl aus wenigen, auf nativen Radios: als Liste, als Karten mit Preisen oder als Kacheln mit Icons.

Eine von 9 Components in Forms und Inputs

3 Variants

Variants

Liste

Untereinander oder in einer Reihe. Der Punkt springt hinein, Pfeiltasten bewegen die Wahl, eine fehlende Antwort steht als Text da.

Karten

Jede Option eine Fläche mit Beschreibung, Preis und optionalem Etikett. Der Ring gleitet zur neuen Wahl und setzt mit leichtem Überschwingen auf.

Kacheln

Mittige Kacheln mit Icon für bildhafte Entscheidungen, ab Tablet drei nebeneinander.

Details

Was Optionsgruppe mitbringt, aus Registry und Quellcode gelesen.

Bereich
Elements
Variants
3
Version
1.0.1 · seit 1.0.0
Datei
components/ui/radio-group.tsx
Bringt mit
utilskiticoninput
npm-Pakete
Keine
Sprachen
Texte kommen über Props
Studio
Im Code anpassen

Props

Die Schnittstelle RadioGroupProps aus components/ui/radio-group.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • legendPflicht
    React.ReactNode
  • hideLegendoptional
    boolean

    Hide the legend visually, it stays for screen readers

  • descriptionoptional
    React.ReactNode
  • erroroptional
    React.ReactNode
  • optionsPflicht
    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

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-describedby, aria-invalid, aria-labelledby und aria-required, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rolle radiogroup.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 3 Stellen im Code schalten Bewegung dann ab.
  • Ohne Animationen, sie steht sofort ruhig da.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 237 Zeilen
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>
  );
}

Häufige Fragen

Wie installiere ich Optionsgruppe?

Mit aiw add @ai-website/radio-group. Der Befehl legt die Datei components/ui/radio-group.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon und input. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Optionsgruppe?

3 Varianten, Liste, Karten und Kacheln. Du wählst sie mit der Prop variant, zum Beispiel variant="tiles".

Kann ein KI-Assistent Optionsgruppe einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Optionsgruppe mit aiw_components und installieren sie mit aiw_add @ai-website/radio-group. Nenn im Prompt die Variante, etwa variant="cards", dann schreibt der Assistent deine Inhalte direkt in die Props.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert