Styled native HTML radio buttons that look like the Radio Group and work without JavaScript.
Installation
Install the dependencies
Copy the component
Copy the following files into src/lib/components/ui/native-radio-group.
These files use the Nova style and Lucide icons. npx @sveltery/ui@latest add writes the style and icon library from your components.json.
<script lang="ts">
import type { NativeRadioProps } from './types.js';
import { cn } from 'cn';
let { class: classProp, ...props }: NativeRadioProps = $props();
</script>
<input
type="radio"
data-slot="radio-group-item"
{...props}
class={cn('border-input dark:bg-input/30 checked:bg-primary checked:text-primary-foreground dark:checked:bg-primary checked:border-primary aria-invalid: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-checked:border-input group-has-[:focus-visible]/field-label:checked:border-primary checked:bg-[radial-gradient(circle,var(--color-primary-foreground)_calc(var(--spacing)*1),transparent_calc(var(--spacing)*1_+_0.5px))] peer relative aspect-square shrink-0 appearance-none border outline-none disabled:cursor-not-allowed disabled:opacity-50', classProp)}
/> <script lang="ts">
import type { NativeRadioGroupProps } from './types.js';
import { cn } from 'cn';
let { children, class: classProp, ...props }: NativeRadioGroupProps = $props();
</script>
<div role="radiogroup" data-slot="radio-group" {...props} class={cn('grid gap-2 w-full', classProp)}>
{@render children?.()}
</div> import type { HTMLAttributes, HTMLInputAttributes } from 'svelte/elements';
export type NativeRadioGroupProps = HTMLAttributes<HTMLDivElement>;
export type NativeRadioProps = Omit<HTMLInputAttributes, 'type'> & { type?: 'radio' }; export { default as NativeRadioGroup } from './NativeRadioGroup.svelte';
export { default as NativeRadio } from './NativeRadio.svelte';
export type { NativeRadioGroupProps, NativeRadioProps } from './types.js'; Update the import paths
The component folders import each other with relative paths, so keep them side by side. If you place them elsewhere, change the imports to match your project setup.
Usage
<script lang="ts">
import { NativeRadio, NativeRadioGroup } from '#lib/components/ui/native-radio-group';
import { Label } from '#lib/components/ui/label';
</script> <NativeRadioGroup aria-label="Density">
<div class="flex items-center gap-2">
<NativeRadio name="density" value="default" id="r1" />
<Label for="r1">Default</Label>
</div>
<div class="flex items-center gap-2">
<NativeRadio name="density" value="comfortable" id="r2" defaultChecked />
<Label for="r2">Comfortable</Label>
</div>
</NativeRadioGroup> NativeRadio is a real <input type="radio">, and every prop goes on it. Give the radios of one group the same name. Mark the initial choice with defaultChecked. A form reset returns to it, as it does for a native radio. checked sets the current choice, which is what a remote form field passes.
When to use it
Use NativeRadioGroup when the form must work before the page hydrates or with JavaScript off. The browser does all of the work:
- A click or tap on the radio or its label picks it.
- The arrow keys move the choice, and the group is one tab stop.
- The checked radio submits with its form.
Radio Group runs on the Sveltery Base RadioGroup, which renders a <span> and a hidden input. Without JavaScript, only a click on its label picks a radio there, and the keyboard does nothing. Radio Group also gives you what NativeRadioGroup does not have:
- Base UI state attributes, such as
data-checkedanddata-disabled, to style on. StyleNativeRadiowithchecked:,disabled:andaria-invalid:instead. bind:valueandonValueChangefor the group's value.- Base's Field behavior: validation,
data-dirtyanddata-touched, and Formerrors. Radio.Indicatorand its animations. The dot ofNativeRadiois a background.
The Field layout parts, such as Field, FieldLabel, FieldSet and FieldError, work with both.
Remote forms
Spread a remote form field onto each radio with as('radio', value). The field gives each radio its name, value, checked state and aria-invalid.
<script lang="ts">
import { Button } from '#lib/components/ui/button';
import { Field, FieldError, FieldLabel, FieldLegend, FieldSet } from '#lib/components/ui/field';
import { NativeRadio, NativeRadioGroup } from '#lib/components/ui/native-radio-group';
import { planSchema, plans } from '#lib/forms/plan';
import { choosePlan } from '#lib/forms/plan.remote';
const f = choosePlan.preflight(planSchema);
</script>
<form {...f}>
<FieldSet>
<FieldLegend>Plan</FieldLegend>
<NativeRadioGroup>
{#each plans as plan (plan)}
<Field orientation="horizontal" data-invalid={!!f.fields.plan.issues()}>
<NativeRadio id="plan-{plan}" {...f.fields.plan.as('radio', plan)} />
<FieldLabel for="plan-{plan}" class="font-normal">{plan}</FieldLabel>
</Field>
{/each}
</NativeRadioGroup>
<FieldError errors={f.fields.plan.issues()} />
</FieldSet>
<Button type="submit" disabled={f.pending > 0}>Choose plan</Button>
</form> Without JavaScript, the form posts to the server, and the page comes back with the submitted radio checked and the issue shown.