Skip to main content
astrocraft-ui/ components · 101

Radio Group

Free · MIT

Headless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.

Install

Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).

Unstyled — behavior only, you write the CSS

Install command
npx astrocraft-ui add forms/radio-group

Plain-CSS theme — no build step

Install command
npx astrocraft-ui add forms/radio-group --theme css

Tailwind theme — needs Tailwind v4

Install command
npx astrocraft-ui add forms/radio-group --theme tailwind

Plain-CSS theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add forms/radio-group --theme css --bridge lumos

Tailwind theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add forms/radio-group --theme tailwind --bridge lumos

Live demo

RadioGroup & CheckboxGroup

Both are real fieldsets, so the legend names the group. The checkbox group’s first box is the select-all — tick one child and it goes indeterminate, the state HTML has no attribute for.

Plan
Billing period
Notifications

Attribute surface

With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.

ComponentSlotsVariantsRuntime stateNative state
`RadioGroup.astro``radio-group` `radio-group-label``data-orientation`: `vertical` · `horizontal`——
`RadioGroupIndicator.astro``radio-dot`———
`RadioGroupItem.astro``radio-group-item` `radio-group-item-label`———

Source

What the command copies — 4 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.

src/components/ui/forms/radio-group/RadioGroup.astro
---
// src/components/ui/forms/radio-group/RadioGroup.astro — headless primitive (see ../../README.md).
// A native <fieldset> of radios: the browser already does single-select, arrow-key roving and
// wrap-around for any set of radios sharing a `name`, so this ships no JavaScript at all. What it
// adds is the part people leave off — the group role and the group's own accessible name.
//
// `label` renders the <legend>, which is what names the group for a screen reader ("Plan: Pro,
// radio button 2 of 3"). Without it each radio is announced with no idea what it belongs to.
//
// `name` is repeated on each RadioGroupItem rather than set here, and that is not an oversight: the
// browser groups radios by their `name` attribute, and nothing in HTML propagates a name from a
// fieldset down to its inputs. A script could copy it — but then the group would only work once
// JavaScript ran, and a form that submits nothing without JS is a worse trade than one more prop.
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"fieldset"> & {
  label?: string;
  orientation?: "vertical" | "horizontal";
};

const { label, orientation = "vertical", class: className, ...rest } = Astro.props;
---

<fieldset
  class={className}
  role="radiogroup"
  data-slot="radio-group"
  data-orientation={orientation}
  {...rest}
>
  {label && <legend data-slot="radio-group-label">{label}</legend>}
  <slot />
</fieldset>
src/components/ui/forms/radio-group/RadioGroupIndicator.astro
---
// src/components/ui/forms/radio-group/RadioGroupIndicator.astro — RadioGroup compound part (see ../../README.md).
// The selected dot on its own, emitting the SAME `data-slot="radio-dot"` that Radio renders, so one
// theme rule paints both and nothing new has to be styled.
//
// RadioGroupItem already includes it (via Radio). This file exists for the layout Radio cannot make:
// a card-style option where the dot sits apart from the input — put the input first, this after it,
// and `[data-slot="radio"]:checked ~ [data-slot="radio-dot"]` still reaches it.
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"span">;

const { class: className, ...rest } = Astro.props;
---

<span class={className} data-slot="radio-dot" aria-hidden="true" {...rest}></span>
src/components/ui/forms/radio-group/RadioGroupItem.astro
---
// src/components/ui/forms/radio-group/RadioGroupItem.astro — RadioGroup compound part (see ../../README.md).
// One radio + its text, wrapped in a <label> so the text is part of the hit target and no `id`/`for`
// pair is needed (the control is nested, which is the native implicit association). `name` must match
// the rest of the group — see RadioGroup.astro on why it lives here.
//
// SIBLING ORDER IS PART OF THE CONTRACT. Radio renders `<input data-slot="radio">` followed by the
// dot, and the text comes after both, so a theme reaches either from the input's state:
//   [data-slot="radio"]:checked ~ [data-slot="radio-dot"]          — the dot, inside Radio
//   [data-slot="radio-root"]:has(:checked) ~ [data-slot="radio-group-item-label"]  — the text
import type { HTMLAttributes } from "astro/types";

import Radio from "../../forms/radio";

type Props = HTMLAttributes<"input"> & { name: string; value: string };

const { name, value, class: className, ...rest } = Astro.props;
---

{/* eslint-disable-next-line astro/jsx-a11y/label-has-associated-control */}
<label data-slot="radio-group-item">
  <Radio name={name} value={value} class={className} {...rest} />
  <span data-slot="radio-group-item-label"><slot /></span>
</label>
src/components/ui/forms/radio-group/index.ts
import RadioGroup from "./RadioGroup.astro";
import RadioGroupIndicator from "./RadioGroupIndicator.astro";
import RadioGroupItem from "./RadioGroupItem.astro";

export { RadioGroup, RadioGroupIndicator, RadioGroupItem };
export default RadioGroup;

What you get

The component source, copied into your project by npx astrocraft-ui add forms/radio-group — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.