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
Details
Was Optionsgruppe mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Forms und Inputs
- Bereich
- Elements
- Variants
- 3
- Version
- 1.0.1 · seit 1.0.0
- Datei
- components/ui/radio-group.tsx
- npm-Pakete
- Keine
- Sprachen
- Texte kommen über Props
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle RadioGroupProps aus components/ui/radio-group.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
legendPflichtReact.ReactNodehideLegendoptionalbooleanHide the legend visually, it stays for screen readers
descriptionoptionalReact.ReactNodeerroroptionalReact.ReactNodeoptionsPflichtRadioOption[]nameoptionalstringvalueoptionalstringdefaultValueoptionalstringonValueChangeoptional(value: string) => voidvariantoptional'list' | 'cards' | 'tiles'list: dots with labels. cards: bordered surfaces. tiles: centered cards with an icon on top.
orientationoptional'vertical' | 'horizontal'columnsoptional1 | 2 | 3 | 4Columns for cards and tiles from tablet up
requiredoptionalbooleandisabledoptionalboolean
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
'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.