Radio Group
radio-groupuiSingle-selection group of radio inputs. Vertical or horizontal layout, optional descriptions per item, and full keyboard navigation. Pair with Form for validation messages.
Installation
$pnpm dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/radio-group.json$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/radio-group.json$yarn dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/radio-group.json$bunx shadcn-svelte@latest add https://uipkge.dev/r/svelte/radio-group.jsonhttps://uipkge.dev/r/svelte/radio-group.jsonInstalls to:src/lib/components/ui/radio-group/Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
valueThe controlled value. Bindable. | any | — | optional |
defaultValueInitial value for uncontrolled use. | any | — | optional |
onValueChangeCalled with the new value after every commit. | (value: any) => void | — | optional |
disabledWhen `true`, prevents the user from interacting with the radio group | boolean | false | optional |
orientationThe orientation of the radio items | 'horizontal' | 'vertical' | vertical | optional |
loopWhen `true`, keyboard navigation will loop from last item to first, and vice versa | boolean | true | optional |
labelLabel for the radio group | string | — | optional |
hintHint text for the radio group | string | — | optional |
errorMessagesError messages to display | string | string[] | — | optional |
errorWhether to show error state | boolean | false | optional |
densityDensity of the radio items | 'compact' | 'default' | 'comfortable' | default | optional |
flatWhether the radio group appears flat | boolean | false | optional |
borderedWhether to show a border around the group | boolean | false | optional |
dirThe reading direction | 'ltr' | 'rtl' | — | optional |
optionsArray of options to render automatically | RadioOption[] | — | optional |
sizeSize of button-style radios | 'small' | 'middle' | 'large' | middle | optional |
optionTypeType of options to render | 'default' | 'button' | default | optional |
buttonVariantVisual variant for button-style radios | 'outline' | 'solid' | outline | optional |
nameForm field name. Renders a hidden input so the value submits with native forms. | string | — | optional |
requiredMarks the group as required for assistive tech. | boolean | false | optional |
children | Snippet<[{ value: unknown }]> | — | optional |
ref | HTMLDivElement | null | — | optional |
npm dependencies
Used by
Files installed (4)
src/lib/components/ui/radio-group/RadioGroup.svelte10.4 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLAttributes } from 'svelte/elements' export type RadioOption = string | { label: string; value: string; disabled?: boolean } /** Context shared with RadioGroupItem / RadioButton. Getters stay reactive in children. */ export interface RadioGroupContext { readonly current: unknown readonly disabled: boolean readonly size: 'small' | 'middle' | 'large' readonly optionType: 'default' | 'button' readonly buttonVariant: 'outline' | 'solid' readonly orientation: 'horizontal' | 'vertical' readonly tabbableValue: unknown select: (value: unknown) => void register: (value: unknown, disabled: boolean, el: HTMLElement) => void unregister: (el: HTMLElement) => void } // Omit `children`: the snippet carries the group value, which narrows the base `Snippet` type. export interface RadioGroupProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> { /** The controlled value. Bindable. */ value?: any /** Initial value for uncontrolled use. */ defaultValue?: any /** Called with the new value after every commit. */ onValueChange?: (value: any) => void /** When `true`, prevents the user from interacting with the radio group */ disabled?: boolean /** The orientation of the radio items */ orientation?: 'horizontal' | 'vertical' /** When `true`, keyboard navigation will loop from last item to first, and vice versa */ loop?: boolean /** Label for the radio group */ label?: string /** Hint text for the radio group */ hint?: string /** Error messages to display */ errorMessages?: string | string[] /** Whether to show error state */ error?: boolean /** Density of the radio items */ density?: 'compact' | 'default' | 'comfortable' /** Whether the radio group appears flat */ flat?: boolean /** Whether to show a border around the group */ bordered?: boolean /** The reading direction */ dir?: 'ltr' | 'rtl' /** Array of options to render automatically */ options?: RadioOption[] /** Size of button-style radios */ size?: 'small' | 'middle' | 'large' /** Type of options to render */ optionType?: 'default' | 'button' /** Visual variant for button-style radios */ buttonVariant?: 'outline' | 'solid' /** Form field name. Renders a hidden input so the value submits with native forms. */ name?: string /** Marks the group as required for assistive tech. */ required?: boolean children?: Snippet<[{ value: unknown }]> ref?: HTMLDivElement | null } </script> <script lang="ts"> import { setContext } from 'svelte' import { cn } from '$lib/utils' import RadioButton from './RadioButton.svelte' import RadioGroupItem from './RadioGroupItem.svelte' let { class: className, value = $bindable(), defaultValue = undefined, onValueChange, disabled = false, orientation = 'vertical', loop = true, label = undefined, hint = undefined, errorMessages = undefined, error = false, density = 'default', flat = false, bordered = false, dir = undefined, options = undefined, size = 'middle', optionType = 'default', buttonVariant = 'outline', name = undefined, required = false, children, onkeydown: onkeydownProp, ref = $bindable(null), ...restProps }: RadioGroupProps = $props() let root: HTMLDivElement | null = null // Uncontrolled seed: intentionally the initial `defaultValue` only. // svelte-ignore state_referenced_locally let internal = $state<any>(defaultValue) let registered = $state<{ value: unknown; disabled: boolean; el: HTMLElement }[]>([]) // `bind:this` can only target one variable; mirror the root onto `ref`. $effect(() => { ref = root }) const current = $derived(value ?? internal) const tabbableValue = $derived.by(() => { if (registered.some((r) => r.value === current && !r.disabled)) return current return registered.find((r) => !r.disabled)?.value }) function select(next: unknown) { if (disabled) return internal = next value = next onValueChange?.(next) } setContext<RadioGroupContext>('radioGroup', { get current() { return current }, get disabled() { return disabled }, get size() { return size }, get optionType() { return optionType }, get buttonVariant() { return buttonVariant }, get orientation() { return orientation }, get tabbableValue() { return tabbableValue }, select, register: (v, d, el) => { // Idempotent: the item $effect re-runs when registration itself // invalidates its deps, so a redundant write would ping-pong forever // (effect_update_depth_exceeded). Skip the write when nothing changed. const existing = registered.find((r) => r.el === el) if (existing && existing.value === v && existing.disabled === d) return registered = [...registered.filter((r) => r.el !== el), { value: v, disabled: d, el }] }, unregister: (el) => { if (!registered.some((r) => r.el === el)) return registered = registered.filter((r) => r.el !== el) }, }) function handleKeydown(event: KeyboardEvent) { const target = event.target as HTMLElement | null if (!target?.matches?.('[role="radio"]')) return if (!root) return const items = [...root.querySelectorAll<HTMLButtonElement>('[role="radio"]')] if (items.length === 0) return const index = items.indexOf(target as HTMLButtonElement) if (index === -1) return const isEnabled = (i: number) => !items[i]!.hasAttribute('data-disabled') /** Step past disabled items, wrapping when `loop` is on. -1 = nowhere to go. */ function step(from: number, direction: 1 | -1): number { let i = from for (let k = 0; k < items.length; k++) { i += direction if (i < 0) { if (!loop) return -1 i = items.length - 1 } else if (i >= items.length) { if (!loop) return -1 i = 0 } if (isEnabled(i)) return i } return -1 } const rtl = dir === 'rtl' const horizontal = orientation === 'horizontal' let next = -1 if (event.key === 'Home') next = items.findIndex((_, i) => isEnabled(i)) else if (event.key === 'End') { next = -1 for (let i = items.length - 1; i >= 0; i--) { if (isEnabled(i)) { next = i break } } } else if (horizontal && (event.key === 'ArrowRight' || event.key === 'ArrowLeft')) { const forward = event.key === 'ArrowRight' ? !rtl : rtl next = step(index, forward ? 1 : -1) } else if (!horizontal && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) { next = step(index, event.key === 'ArrowDown' ? 1 : -1) } else return if (next === -1) return event.preventDefault() const el = items[next]! el.focus() // The real (possibly non-string) value round-trips via registration, // matched by element identity; fall back to the data-value attribute. const candidate = registered.find((r) => r.el === el)?.value select(candidate !== undefined ? candidate : el.getAttribute('data-value')) } // Density classes const densityClasses = { compact: 'gap-1', default: 'gap-3', comfortable: 'gap-4', } const hasError = $derived.by(() => { if (error) return true if (errorMessages && (typeof errorMessages === 'string' ? errorMessages : errorMessages.length > 0)) return true return false }) function normalizeOption(option: RadioOption): { label: string; value: string; disabled?: boolean } { if (typeof option === 'string') { return { label: option, value: option } } return option } </script> <!-- `class` is forwarded ONLY to the radiogroup root below (per shadcn convention). Applying it on the outer wrapper as well caused grid-* utilities to fight the wrapper's `flex flex-col`, so consumers had to fall back to column-count hacks. Forwarding to one element keeps layout intent unambiguous. --> <div class="flex flex-col gap-2"> {#if label} <span class="text-sm font-medium"> {label} </span> {/if} {#if hint && !hasError} <p class="text-muted-foreground text-xs"> {hint} </p> {/if} <div bind:this={root} role="radiogroup" data-uipkge="" data-slot="radio-group" data-orientation={orientation} aria-disabled={disabled || undefined} aria-required={required || undefined} aria-invalid={hasError || undefined} {dir} class={cn( 'grid gap-3', orientation === 'horizontal' && 'flex flex-row items-center gap-4', optionType === 'button' && orientation === 'horizontal' && 'flex flex-row items-stretch gap-0', optionType === 'button' && orientation === 'vertical' && 'flex flex-col items-stretch gap-0', optionType !== 'button' && densityClasses[density], bordered && 'rounded-lg border p-4', className, )} {...restProps} onkeydown={(e) => { handleKeydown(e) onkeydownProp?.(e) }} > {#if name} <input type="hidden" {name} value={current == null ? '' : String(current)} /> {/if} {#if options && options.length > 0} {#if optionType === 'button'} {#each options as option (normalizeOption(option).value)} {@const opt = normalizeOption(option)} <RadioButton value={opt.value} disabled={opt.disabled} label={opt.label} /> {/each} {:else} {#each options as option (normalizeOption(option).value)} {@const opt = normalizeOption(option)} <div class="flex items-center gap-2"> <RadioGroupItem id={opt.value} value={opt.value} disabled={opt.disabled} /> <label for={opt.value} class={cn('cursor-pointer text-sm font-medium select-none', opt.disabled && 'cursor-not-allowed opacity-50')} > {opt.label} </label> </div> {/each} {/if} {/if} {@render children?.({ value: current })} </div> {#if hasError} <div class="flex flex-col gap-0.5"> {#if typeof errorMessages === 'string'} <p class="text-destructive text-xs"> {errorMessages} </p> {:else} {#each errorMessages ?? [] as msg, i (i)} <p class="text-destructive text-xs"> {msg} </p> {/each} {/if} </div> {/if} </div>src/lib/components/ui/radio-group/RadioGroupItem.svelte6.9 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLButtonAttributes } from 'svelte/elements' import type { RadioGroupContext } from './RadioGroup.svelte' export interface RadioGroupItemProps extends HTMLButtonAttributes { value: any /** Size of the radio item */ size?: 'sm' | 'md' | 'lg' /** Custom color for the checked state */ color?: 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'info' | string /** Label text displayed next to the radio item */ label?: string /** Hint text shown below the radio item */ hint?: string /** Error message to display */ errorMessages?: string | string[] /** Whether to show error state */ error?: boolean /** Density of the radio item */ density?: 'compact' | 'default' | 'comfortable' /** Label position - before or after the radio */ labelPosition?: 'before' | 'after' /** Loading state */ loading?: boolean /** Hide the indicator icon */ hideIcon?: boolean /** Custom indicator markup. Defaults to a filled circle. */ children?: Snippet ref?: HTMLButtonElement | null } // Re-exported for consumers that build custom items against the group context. export type { RadioGroupContext } </script> <script lang="ts"> import { getContext, untrack } from 'svelte' import { Circle } from '@lucide/svelte' import { cn } from '$lib/utils' let { class: className, value, id = undefined, disabled = undefined, size = 'md', color = 'primary', label = undefined, hint = undefined, errorMessages = undefined, error = false, density = 'default', labelPosition = 'after', loading = false, hideIcon = false, children, onclick: onclickProp, ref = $bindable(null), ...restProps }: RadioGroupItemProps = $props() const group = getContext<RadioGroupContext | undefined>('radioGroup') let el: HTMLButtonElement | null = null $effect(() => { ref = el }) const effectiveDisabled = $derived(Boolean(loading) || Boolean(disabled ?? group?.disabled ?? false)) const checked = $derived(group?.current === value) const tabbable = $derived(group ? group.tabbableValue === value : true) $effect(() => { if (!el || !group) return const node = el // Track value + disabled so re-registration follows prop updates. const v = value const d = effectiveDisabled // untrack: the register write must not subscribe this effect to the // group's `registered` state, or the write re-triggers the effect // (effect_update_depth_exceeded). Registration itself is idempotent. untrack(() => group.register(v, d, node)) return () => group.unregister(node) }) // Size classes const sizeClasses = { sm: 'size-3.5', md: 'size-4', lg: 'size-5', } const indicatorSizes = { sm: 'size-1.5', md: 'size-2', lg: 'size-2.5', } // Color classes const colorClasses: Record<string, string> = { primary: 'data-[state=checked]:border-primary', secondary: 'data-[state=checked]:border-secondary', success: 'data-[state=checked]:border-success data-[state=checked]:text-success', warning: 'data-[state=checked]:border-warning data-[state=checked]:text-warning', error: 'data-[state=checked]:border-destructive data-[state=checked]:text-destructive', info: 'data-[state=checked]:border-info data-[state=checked]:text-info', } // Density classes const densityClasses = { compact: 'gap-1', default: 'gap-2', comfortable: 'gap-3', } const hasError = $derived.by(() => { if (error) return true if (errorMessages && (typeof errorMessages === 'string' ? errorMessages : errorMessages.length > 0)) return true return false }) </script> <div class={cn('flex flex-col', densityClasses[density])}> <div class="flex items-center"> {#if label && labelPosition === 'before'} <label for={id} class={cn( 'mr-2 cursor-pointer text-sm leading-none font-medium select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', hasError && 'text-destructive', effectiveDisabled && 'cursor-not-allowed opacity-50', )} > {label} </label> {/if} <!-- aria-invalid mirrors the Vue twin's error state (svelte's role table omits radio). --> <!-- svelte-ignore a11y_role_supports_aria_props --> <button bind:this={el} type="button" role="radio" {id} data-uipkge="" data-slot="radio-group-item" data-value={typeof value === 'string' ? value : undefined} data-state={checked ? 'checked' : 'unchecked'} data-disabled={effectiveDisabled || undefined} aria-checked={checked} aria-busy={loading || undefined} aria-invalid={hasError || undefined} disabled={effectiveDisabled} tabindex={tabbable ? 0 : -1} class={cn( 'border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square shrink-0 rounded-full border shadow-sm transition-colors duration-200 outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50', sizeClasses[size], colorClasses[color] || colorClasses.primary, hasError && 'border-destructive', loading && 'opacity-50', className, )} {...restProps} onclick={(e) => { if (!effectiveDisabled) group?.select(value) onclickProp?.(e) }} > <span data-uipkge="" data-slot="radio-group-indicator" class="relative flex items-center justify-center"> {#if children} {@render children()} {:else if !hideIcon} <Circle class={cn( 'absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 fill-current text-current', indicatorSizes[size], )} /> {/if} </span> </button> {#if label && labelPosition === 'after'} <label for={id} class={cn( 'ml-2 cursor-pointer text-sm leading-none font-medium select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', hasError && 'text-destructive', effectiveDisabled && 'cursor-not-allowed opacity-50', )} > {label} </label> {/if} </div> {#if hint && !hasError} <p class="text-muted-foreground ml-6 text-xs"> {hint} </p> {/if} {#if hasError} <div class="ml-6 flex flex-col gap-0.5"> {#if typeof errorMessages === 'string'} <p class="text-destructive text-xs"> {errorMessages} </p> {:else} {#each errorMessages ?? [] as msg, i (i)} <p class="text-destructive text-xs"> {msg} </p> {/each} {/if} </div> {/if} </div>src/lib/components/ui/radio-group/RadioButton.svelte3.6 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLButtonAttributes } from 'svelte/elements' import type { RadioGroupContext } from './RadioGroup.svelte' export interface RadioButtonProps extends HTMLButtonAttributes { value: any /** Size of the button radio. Defaults to the group's size. */ size?: 'small' | 'middle' | 'large' /** Visual variant. Defaults to the group's buttonVariant. */ variant?: 'outline' | 'solid' /** Label text */ label?: string children?: Snippet ref?: HTMLButtonElement | null } </script> <script lang="ts"> import { getContext, untrack } from 'svelte' import { cn } from '$lib/utils' let { class: className, value, size = undefined, variant = undefined, label = undefined, disabled = undefined, children, onclick: onclickProp, ref = $bindable(null), ...restProps }: RadioButtonProps = $props() const group = getContext<RadioGroupContext | undefined>('radioGroup') let el: HTMLButtonElement | null = null $effect(() => { ref = el }) const effectiveSize = $derived(size ?? group?.size ?? 'middle') const effectiveVariant = $derived(variant ?? group?.buttonVariant ?? 'outline') const effectiveDisabled = $derived(disabled ?? group?.disabled ?? false) const checked = $derived(group?.current === value) const tabbable = $derived(group ? group.tabbableValue === value : true) $effect(() => { if (!el || !group) return const node = el const v = value const d = effectiveDisabled // untrack: keep the register write from subscribing this effect to the // group's `registered` state (effect_update_depth_exceeded). See RadioGroup. untrack(() => group.register(v, d, node)) return () => group.unregister(node) }) const sizeClasses = { small: 'h-7 px-2.5 text-xs', middle: 'h-8 px-4 text-sm', large: 'h-10 px-4.5 text-base', } const variantClasses = { outline: cn( 'border border-input bg-transparent text-foreground hover:text-foreground hover:bg-muted/50', 'data-[state=checked]:border-primary data-[state=checked]:text-primary', 'disabled:hover:bg-transparent', ), solid: cn( 'border border-input bg-transparent text-foreground hover:text-foreground hover:bg-muted/50', 'data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground', 'disabled:hover:bg-transparent', ), } const groupClasses = $derived( group?.orientation === 'vertical' ? 'rounded-md w-full justify-start' : cn('rounded-none first:rounded-l-md last:rounded-r-md', 'border-l-0 first:border-l', '-ml-px first:ml-0'), ) </script> <button bind:this={el} type="button" role="radio" data-uipkge="" data-slot="radio-button" data-value={typeof value === 'string' ? value : undefined} data-state={checked ? 'checked' : 'unchecked'} data-disabled={effectiveDisabled || undefined} aria-checked={checked} disabled={effectiveDisabled} tabindex={tabbable ? 0 : -1} class={cn( 'inline-flex items-center justify-center gap-2 font-medium whitespace-nowrap transition-colors duration-200', 'focus-visible:border-ring focus-visible:ring-ring/50 outline-none focus-visible:ring-[3px]', 'disabled:cursor-not-allowed disabled:opacity-50', sizeClasses[effectiveSize], variantClasses[effectiveVariant], groupClasses, className, )} {...restProps} onclick={(e) => { if (!effectiveDisabled) group?.select(value) onclickProp?.(e) }} > {#if children} {@render children()} {:else} {label ?? value} {/if} </button>src/lib/components/ui/radio-group/index.ts0.3 kB
export { default as RadioGroup, type RadioGroupContext, type RadioGroupProps, type RadioOption } from './RadioGroup.svelte' export { default as RadioGroupItem, type RadioGroupItemProps } from './RadioGroupItem.svelte' export { default as RadioButton, type RadioButtonProps } from './RadioButton.svelte'
Raw manifest:https://uipkge.dev/r/svelte/radio-group.json