Radio Group
Single-select with full-row targets. Selected, hover and focus indicators ride shared motion layers across items — the dot springs in and the label's variable weight steps up on selection.
Usage
radio-group.svelte
<script lang="ts">
import * as RadioGroup from '$lib/components/ui/radio-group';
let plan = $state('pro');
</script>
<RadioGroup.Root bind:value={plan}>
<RadioGroup.Item value="free">Free</RadioGroup.Item>
<RadioGroup.Item value="pro">Pro</RadioGroup.Item>
<RadioGroup.Item value="team">Team</RadioGroup.Item>
</RadioGroup.Root>Density
radio-group.svelte
<script lang="ts" module>
type Box = { x: number; y: number; w: number; h: number } | null;
</script>
<script lang="ts">
import { RadioGroup as RadioGroupPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
value = $bindable(''),
children,
label,
labelledby,
...restProps
}: RadioGroupPrimitive.RootProps & {
/** Accessible name for the radio group. Set this or `labelledby`. */
label?: string;
/** ID of an element labeling the radio group. Set this or `label`. */
labelledby?: string;
} = $props();
let selectedBox = $state<Box>(null);
let hoverBox = $state<Box>(null);
let focusBox = $state<Box>(null);
function boxOf(el: HTMLElement, host: HTMLElement): Box {
const r = el.getBoundingClientRect();
const hr = host.getBoundingClientRect();
return { x: r.left - hr.left, y: r.top - hr.top, w: r.width, h: r.height };
}
function refreshSelected() {
if (!ref) return;
const active = ref.querySelector<HTMLElement>(
'[data-slot="radio-group-item"][data-state="checked"]'
);
selectedBox = active ? boxOf(active, ref) : null;
}
$effect(() => {
if (!ref) return;
refreshSelected();
const mo = new MutationObserver(refreshSelected);
mo.observe(ref, { attributes: true, subtree: true, attributeFilter: ['data-state'] });
const ro = new ResizeObserver(refreshSelected);
ro.observe(ref);
window.addEventListener('resize', refreshSelected);
return () => {
mo.disconnect();
ro.disconnect();
window.removeEventListener('resize', refreshSelected);
};
});
function itemFor(target: EventTarget | null): HTMLElement | null {
return target instanceof Element
? target.closest<HTMLElement>('[data-slot="radio-group-item"]')
: null;
}
function onPointerOver(event: PointerEvent) {
if (!ref) return;
const item = itemFor(event.target);
if (!item) {
hoverBox = null;
return;
}
hoverBox = item.getAttribute('data-state') === 'checked' ? null : boxOf(item, ref);
}
function onPointerLeave() {
hoverBox = null;
}
function onFocusIn(event: FocusEvent) {
if (!ref) return;
const item = itemFor(event.target);
focusBox = item ? boxOf(item, ref) : null;
}
function onFocusOut(event: FocusEvent) {
if (!itemFor(event.relatedTarget)) focusBox = null;
}
function styleFor(box: Box): string {
if (!box) return 'opacity: 0;';
return `opacity: 1; transform: translate3d(${box.x}px, ${box.y}px, 0); width: ${box.w}px; height: ${box.h}px;`;
}
</script>
<RadioGroupPrimitive.Root bind:value {...restProps}>
{#snippet child({ props })}
<div
bind:this={ref}
{...props}
data-slot="radio-group"
aria-label={label}
aria-labelledby={labelledby}
class={cn('group/radio-group relative flex w-full max-w-sm flex-col gap-0.5', className)}
onpointerover={onPointerOver}
onpointerleave={onPointerLeave}
onfocusin={onFocusIn}
onfocusout={onFocusOut}
>
<div
aria-hidden="true"
class="pointer-events-none absolute top-0 left-0 z-0 rounded-(--radius-control) bg-accent/60 transition-[transform,width,height,opacity] duration-(--motion-duration-fast) ease-(--motion-ease-out) will-change-transform dark:bg-accent/30"
style={styleFor(selectedBox)}
></div>
<div
aria-hidden="true"
class="pointer-events-none absolute top-0 left-0 z-0 rounded-(--radius-control) bg-accent/30 transition-[transform,width,height,opacity] duration-(--motion-duration-fast) ease-(--motion-ease-out) will-change-transform dark:bg-accent/20"
style={styleFor(hoverBox)}
></div>
<div
aria-hidden="true"
class="pointer-events-none absolute top-0 left-0 z-0 rounded-(--radius-control) ring-(length:--ring-width) ring-ring/50 transition-[transform,width,height,opacity] duration-(--motion-duration-fast) ease-(--motion-ease-out) will-change-transform"
style={styleFor(focusBox)}
></div>
{@render children?.()}
</div>
{/snippet}
</RadioGroupPrimitive.Root>
RadioGroup.Root props
Inherits bits-ui RadioGroup.Root props via spread. Tracks selected/hover/focus elements internally and drives three shared motion layers over the sibling list.
| Prop | Type | Default | Description |
|---|---|---|---|
string (bindable) | '' | Selected item value. Two-way bindable. | |
'horizontal' | 'vertical' | 'vertical' | Layout axis and roving-focus direction. | |
boolean | true | Roving focus wraps at the ends when true. | |
boolean | false | Disables every item in the group. | |
HTMLDivElement | null | null | Two-way-bindable element reference. | |
string | — | Merged onto the root via tailwind-merge. |
RadioGroup.Item props
The item is the full-row selectable surface. Children render as the label; the glyph is injected automatically.
| Prop | Type | Default | Description |
|---|---|---|---|
string | — | Item value reported when selected. | |
Snippet | — | Row label — rendered beside the radio glyph. | |
boolean | false | Disables this item only. | |
HTMLButtonElement | null | null | Two-way-bindable element reference. | |
string | — | Merged onto the item via tailwind-merge. |