Radio Group

A set of checkable buttons, known as radio buttons, where no more than one of the buttons can be checked at a time.

Basic
With Descriptions
With FieldSet
Battery Level

Choose your preferred battery level.

Grid Layout
Disabled
Invalid
Notification Preferences

Choose how you want to receive notifications.

Installation

Install the dependencies

pnpm add cn class-variance-authority github:sveltery/base

Copy the component

Copy the following files into src/lib/components/ui/radio-group.

src/lib/components/ui/radio-group/RadioGroup.svelte
<script lang="ts">
  import { RadioGroup as Primitive, type RadioGroupProps } from '@sveltery/base/radio-group';
  import { cn } from 'cn';
  let { children, class: classProp, value = $bindable(), ...props }: RadioGroupProps = $props();
</script>
<Primitive data-slot="radio-group" bind:value {...props} class={cn('grid gap-2 w-full', classProp)}>
  {@render children?.()}
</Primitive>
src/lib/components/ui/radio-group/RadioGroupItem.svelte
<script lang="ts">
  import { Radio as Primitive, type RadioRootProps } from '@sveltery/base/radio';
  import { cn } from 'cn';
  let { class: classProp, ...props }: Omit<RadioRootProps, 'children'> = $props();
</script>
<Primitive.Root
  data-slot="radio-group-item"
  {...props}
  class={cn('border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:aria-invalid:border-destructive/50 flex size-4 rounded-full focus-visible:ring-3 aria-invalid:ring-3 group-has-[:focus-visible]/field-label:ring-0 group-has-[:focus-visible]/field-label:not-data-checked:border-input group-has-[:focus-visible]/field-label:data-checked:border-primary group/radio-group-item peer relative aspect-square shrink-0 border outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50', classProp)}
>
  <Primitive.Indicator data-slot="radio-group-indicator" class="flex size-4 items-center justify-center">
    <span class="bg-primary-foreground absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full"></span>
  </Primitive.Indicator>
</Primitive.Root>
src/lib/components/ui/radio-group/index.ts
export { default as RadioGroup } from './RadioGroup.svelte';
export { default as RadioGroupItem } from './RadioGroupItem.svelte';
export type { RadioGroupProps, RadioGroupState } from '@sveltery/base/radio-group';
export type { RadioRootProps as RadioGroupItemProps } from '@sveltery/base/radio';

Update the import paths

Change the imports to match your project setup.

Usage

<script lang="ts">
  import { RadioGroup, RadioGroupItem } from '$lib/components/ui/radio-group';
  import { Label } from '$lib/components/ui/label';
</script>
<RadioGroup name="density" defaultValue="comfortable">
  <div class="flex items-center gap-2">
    <RadioGroupItem value="default" id="r1" />
    <Label for="r1">Default</Label>
  </div>
  <div class="flex items-center gap-2">
    <RadioGroupItem value="comfortable" id="r2" />
    <Label for="r2">Comfortable</Label>
  </div>
</RadioGroup>

Bind value to read or change the selection.

<script lang="ts">
  let density = $state("comfortable");
</script>

<RadioGroup bind:value={density}>...</RadioGroup>

API reference

RadioGroup is styled on top of the Sveltery Base RadioGroup and Radio primitives, which own the selection, arrow-key navigation and the hidden inputs that take part in form submission. RadioGroup keeps the HTML form semantics: name submits the checked value, defaultValue sets the initial selection, and bind:value shares it with the parent. See the Base UI Radio Group documentation and the Radio documentation for the behavior they port.