# Radio Group (Lumen Halo): prompt.md (v1.0.0)

- id: `lumen-radio-group` · version 1.0.0 · component · free
- category: Selection
- build: Base UI (this item also ships a Radix build)
- install (this build): `npx shadcn@latest add https://beautiful-ui.dev/r/lumen-radio-group.json`
- npm dependencies: @base-ui/react@^1
- registry dependencies: utils, https://beautiful-ui.dev/r/lumen-foundation.json
- docs: https://beautiful-ui.dev/components/lumen-radio-group
- The install command carries everything this item needs (files, CSS, tokens, npm and registry dependencies). Prefer it to copying source by hand.

Choices you can hear: each option plays its own note as you move through them, and the chosen one turns a rounded square into place. Works as a list or as option cards. shadcn's RadioGroup, cut from Lumen glass.

## Build it
- Stack: React 19 (`ref` is a plain prop), TypeScript, Tailwind CSS v4 utilities, a shadcn-initialised project with the `@/*` alias.
- Packages: `@base-ui/react@^1` (Base UI build); `radix-ui@^1` (Radix build).
- Files: `components/ui/lumen/radio-group.tsx`; shared code: `lib/beautiful-ui/lumen/sound.ts`.
- Registry dependencies, installed with it automatically: shadcn `utils` (cn), `lumen-foundation`.
- Builds: separate Base UI and Radix files. Install the one that matches the project (see Install): a free item's bare URL installs the Base UI build of it and its dependencies.
- Exports to keep: `RadioGroup`, `RadioGroupItem`, and every exported type.
- CSS: the install merges this item's rules (the registry `css` field) into your global stylesheet, in `@layer components`, and adds the lumen foundation (tokens, keyframes, motion levels) once. Nothing to import by hand.
- Re-running `add` (or `--overwrite`) re-applies those rules: put overrides in your own CSS, never in the installed rules.
- Tokens: retheme with the `--lumen-*` custom properties (`--lumen-accent`, `--lumen-accent-text`, `--lumen-bad`, `--lumen-bad-text`, `--lumen-focus`, `--lumen-good`, `--lumen-good-text`, `--lumen-hairline`, `--lumen-ink`, `--lumen-muted-ink`, `--lumen-series-1`, `--lumen-series-2`, `--lumen-series-3`, `--lumen-series-4`, `--lumen-series-5`, `--lumen-series-6`, `--lumen-warn`, `--lumen-warn-text`); this item's CSS also reads `--lumen-radius-k`. Never add Tailwind colour classes inside the component.

```tsx
import { RadioGroup, RadioGroupItem } from "@/components/ui/lumen/radio-group";
```

## Parts

| Part | data-slot | What it is for |
|---|---|---|
| `RadioGroup` | `radio-group` | The group. |
| `RadioGroupItem` | `radio-group-item` | One glass well and its mark. |

Style a part with `[data-slot="<slot>"]` selectors or its `className`; keep the attributes when editing.

## Sound
- Keep every `data-slot` and `data-sound` attribute: the sound layer reads them.
- Installing this item adds no audio. Nothing plays until the app mounts `GlassSoundProvider` once (install: `npx shadcn@latest add https://beautiful-ui.dev/r/glass-sound.json`, import from `@/components/beautiful-ui/glass-sound`); `GlassSoundToggle` is its mute control. Without a provider the audio engine never loads; `sound={false}` silences one instance.
- This item: select, pitched up the Lumen scale by the option's position.

## Match the original
- Read `components/ui/lumen/radio-group.tsx` as the reference implementation before changing or recreating anything, and match it: sizes, colours per theme, motion timings, copy and behaviour.
- If you deviate (a prop you can't honour, a style you changed, a dependency you swapped), say so in your reply, part by part.
- Keep the accessibility contract, the keyboard map and the motion levels listed below.

## Use it when
- radio group, radio buttons, choose one, plan picker, option cards, pricing plan, shadcn radio group, Lumen
- Picking exactly one of a few visible options
- Option cards (plans, tiers, layouts): wrap each item in FieldLabel and Field, as shadcn does
- A drop-in for shadcn's RadioGroup

### Not when
- More than about 7 options: use a select
- Independent choices: use Lumen Checkbox

## Mistakes
- Give every item an id and a label (or use the FieldLabel card pattern)
- value must be a string

## Usage

```tsx
import { RadioGroup, RadioGroupItem } from "@/components/ui/lumen/radio-group";

<RadioGroup value={plan} onValueChange={setPlan}>
  <RadioGroupItem id="growth" value="growth" />
  <label htmlFor="growth">Growth</label>
</RadioGroup>
```

## Props

| Prop | Type | Default | What it does |
|---|---|---|---|
| `value / defaultValue / onValueChange` | `string / string / (value) => void` |  | Controlled or not, as shadcn. |
| `disabled` | `boolean` |  | On the group or on one item. |
| `aria-invalid` | `boolean` |  | On an item: coral ring. |
| `sound` | `boolean` | `true` | Beautiful UI: false silences the group; otherwise select, pitched by position. |

Full docs: https://beautiful-ui.dev/components/lumen-radio-group

## Customising
- Colours: set the `--lumen-*` tokens on `:root`, or on a container with the `lumen-scope` class to retheme one area. Add the `lumen-inherit` class to follow your shadcn palette instead (`--chart-N`, `--destructive`).
- Dark mode follows the `.dark` class on an ancestor (the shadcn and next-themes convention).
- Update later by re-running the install with `--overwrite` (review the diff if you edited it). Changelog: https://beautiful-ui.dev/r/changelog.json

## Keyboard

| Keys | Action |
|---|---|
| Arrow keys | Move and choose |
| Tab | Into and out of the group |

## Motion inventory

| Interaction | What moves |
|---|---|
| Choose | The well tints; the rounded square turns from -90° and grows (.5s spring) |
| Option card | Lifts 1px on hover, presses to .985, and takes an accent ring when chosen |
| Sound | select, pitched up the Lumen scale by the option's position |

## Accessibility contract (preserve when editing)
- role=radiogroup with arrow-key navigation, from Base UI or Radix
- One Tab stop for the group
- Option cards stay one control: the card is its item's label
- 22px wells on phones; a 44px touch target

## Install

Base UI project (a base-* style in components.json):

```bash
npx shadcn@latest add https://beautiful-ui.dev/r/lumen-radio-group.json
```

Radix project (a radix-*, new-york or default style):

```bash
npx shadcn@latest add https://beautiful-ui.dev/r/radix-nova/lumen-radio-group.json
```

Or add the `@beautiful-ui` registry to components.json and run `npx shadcn@latest add @beautiful-ui/lumen-radio-group`: the CLI picks the build from your style.

## Credits
- Built on shadcn/ui (https://ui.shadcn.com)

## Source (Base UI build)

### components/ui/lumen/radio-group.tsx

```tsx
"use client";

/**
 * Radio Group (Lumen Halo) v1.0.0 · Beautiful UI
 * Docs: https://beautiful-ui.dev/components/lumen-radio-group · Agent prompt: https://beautiful-ui.dev/md/lumen-radio-group.md
 * Licensed to the purchaser under the Beautiful UI license: https://beautiful-ui.dev/license
 */

import * as React from "react";
import { Radio as RadioPrimitive } from "@base-ui/react/radio";
import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group";
import { cn } from "@/lib/utils";
import { emitSound, useMergedRef } from "@/lib/beautiful-ui/lumen/sound";

/*
 * Lumen Radio Group (Base UI build). shadcn's RadioGroup in Lumen Halo: round glass wells; the
 * chosen one fills with a soft tint and a rounded square turns and grows into place. Choosing plays
 * Lumen's select cue, pitched by the option's position (beautiful-ui-sound). For option cards, wrap each
 * item in <FieldLabel><Field orientation="horizontal">, as with shadcn. Same exports and data-slots.
 */

function RadioGroup({ className, sound = true, onValueChange, ref, ...props }: RadioGroupPrimitive.Props & { sound?: boolean; ref?: React.Ref<HTMLDivElement> }) {
  const own = React.useRef<HTMLDivElement>(null);
  const merged = useMergedRef(own, ref);
  return (
    <RadioGroupPrimitive
      ref={merged}
      data-slot="radio-group"
      data-sound={sound ? undefined : "off"}
      className={cn(`lumen-radio-group`, className)}
      onValueChange={(value, details) => {
        onValueChange?.(value, details);
        // After the change lands: the chosen item's position pitches the cue.
        requestAnimationFrame(() => {
          const items = [...(own.current?.querySelectorAll<HTMLElement>('[data-slot="radio-group-item"]') ?? [])];
          const at = items.findIndex((el) => el.hasAttribute("data-checked") || el.getAttribute("aria-checked") === "true");
          if (at >= 0) emitSound(items[at] ?? null, "select", false, at);
        });
      }}
      {...props}
    />
  );
}

function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) {
  return (
    <RadioPrimitive.Root data-slot="radio-group-item" data-sound="none" className={cn(`lumen-radio lumen-control`, className)} {...props}>
      <RadioPrimitive.Indicator keepMounted data-slot="radio-group-indicator" className={`lumen-radio-indicator`}>
        <span aria-hidden className={`lumen-radio-mark`} />
      </RadioPrimitive.Indicator>
    </RadioPrimitive.Root>
  );
}

export { RadioGroup, RadioGroupItem };
```

### lib/beautiful-ui/lumen/sound.ts

```tsx
"use client";

import * as React from "react";

/**
 * Asks the page's sound layer (beautiful-ui-sound) to play `cue`. Silent when nothing listens, and inside
 * anything marked data-sound="off". `index` pitches select cues by position. No audio code ships in
 * the components themselves.
 */
export function emitSound(el: Element | null, cue: string, force?: boolean, index?: number) {
  if (!el || typeof CustomEvent === "undefined" || el.closest('[data-sound="off"]')) return;
  el.dispatchEvent(new CustomEvent("beautiful-ui:sound", { bubbles: true, detail: { cue, force, index } }));
}

/**
 * Plays the cue `pick` returns whenever `value` changes (never on mount): an error when a field
 * turns invalid, a success when a check passes.
 */
export function useCueOnChange<T>(ref: React.RefObject<Element | null>, value: T, pick: (next: T, prev: T) => string | null) {
  const prev = React.useRef(value);
  const pickRef = React.useRef(pick);
  React.useEffect(() => {
    pickRef.current = pick;
  });
  React.useEffect(() => {
    if (Object.is(prev.current, value)) return;
    const before = prev.current;
    prev.current = value;
    const cue = pickRef.current(value, before);
    if (cue) emitSound(ref.current, cue);
  }, [ref, value]);
}

/** Points one ref (callback or object) at `node`; returns how to let go of it. */
function assign<T>(r: React.Ref<T> | undefined, node: T | null): () => void {
  if (typeof r === "function") {
    const cleanup = r(node);
    return typeof cleanup === "function" ? cleanup : () => r(null);
  }
  if (r) {
    (r as React.RefObject<T | null>).current = node;
    return () => ((r as React.RefObject<T | null>).current = null);
  }
  return () => {};
}

/**
 * One stable callback ref for the component's own ref and the caller's. It changes only when one of
 * them does, so a caller's callback ref isn't detached and reattached on every render, and it
 * honours React 19 ref cleanups.
 */
export function useMergedRef<T>(own: React.Ref<T> | undefined, theirs: React.Ref<T> | undefined): React.RefCallback<T> {
  return React.useCallback(
    (node: T | null) => {
      const release = [assign(own, node), assign(theirs, node)];
      return () => release.forEach((f) => f());
    },
    [own, theirs],
  );
}

/** True when `el` sits in an input group whose status part speaks for it (one cue per change). */
export const statusSpeaksFor = (el: Element | null) => Boolean(el?.closest('[data-slot="input-group"]')?.querySelector('[data-slot="input-group-status"]'));
```

### CSS (the registry `css` / `cssVars`, merged into the global stylesheet by the shadcn CLI)

```css
@layer components {
  .lumen-radio {
    --k-sz: 18px;
    --k-r: 6px;
    --k-mk: 7px;
    --x-acc: var(--gc-p-acc);
    --x-ring: var(--gc-p-wr);
    --x-sh: inset 0 0 0 1.5px var(--x-ring),var(--gc-wellIn);
    position: relative;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: none;
    width: var(--k-sz);
    height: var(--k-sz);
    box-sizing: border-box;
    margin: 0;
    padding: 0;
    border: none;
    border-radius: 50%;
    background: var(--gc-well);
    box-shadow: var(--x-sh);
    color: var(--gc-onSolid);
    cursor: pointer;
    outline: none;
    -webkit-appearance: none;
    appearance: none;
    -webkit-tap-highlight-color: transparent;
    touch-action: manipulation;
    transition: background min(0.25s, var(--gcp-cm)) ease,box-shadow min(0.25s, var(--gcp-cm)) ease,transform calc(0.4s * var(--gcp-k)) var(--gc-sp);
  }
  .lumen-radio::after {
    content: "";
    position: absolute;
    inset: min(-4px, calc((var(--k-sz) - 44px) / 2));
  }
  .lumen-radio[aria-invalid="true"] {
    --x-acc: var(--gc-bad);
    --x-ring: color-mix(in srgb, var(--gc-bad) 75%, transparent);
  }
  .lumen-radio:focus-visible {
    box-shadow: 0 0 0 2px var(--gc-gap), 0 0 0 4px var(--gc-p-focus), var(--x-sh);
  }
  .lumen-radio:not(:is([data-disabled],:disabled)):active {
    transform: scale(.88);
  }
  .lumen-radio:is([data-disabled],:disabled) {
    opacity: .4;
    cursor: not-allowed;
  }
  .lumen-radio:is([data-checked],[data-state="checked"]) {
    background: color-mix(in srgb, var(--x-acc) 12%, transparent);
    --x-ring: var(--x-acc);
  }
  .lumen-radio[aria-invalid="true"]:is([data-checked],[data-state="checked"]) {
    --x-ring: color-mix(in srgb, var(--gc-bad) 75%, transparent);
  }
  .lumen-radio-indicator {
    display: flex;
    align-items: center;
    justify-content: center;
  }
  .lumen-radio-mark {
    width: var(--k-mk);
    height: var(--k-mk);
    border-radius: calc(2px * var(--lumen-radius-k, 1));
    background: var(--x-acc);
    transform: scale(0) rotate(-90deg);
    transition: transform calc(0.5s * var(--gcp-k)) var(--gc-sp);
  }
  .lumen-radio:is([data-checked],[data-state="checked"]) .lumen-radio-mark {
    transform: scale(1) rotate(0deg);
  }
  .lumen-radio-group {
    display: grid;
    gap: 10px;
    min-width: 0;
  }
  .lumen-field-label:has(>.lumen-field) {
    --gcp-k: 1;
    --gcp-cm: 9s;
    --gc-sp: cubic-bezier(.34,1.5,.64,1);
    position: relative;
    display: flex;
    width: 100%;
    padding: 14px 16px;
    border-radius: calc(16px * var(--lumen-radius-k, 1));
    background: var(--gc-p-cardIn);
    --c-ring: var(--gc-hair);
    box-shadow: inset 0 0 0 1px var(--c-ring);
    color: var(--gc-ink);
    cursor: pointer;
    transition: box-shadow min(0.25s, var(--gcp-cm)) ease,background min(0.25s, var(--gcp-cm)) ease,transform calc(0.45s * var(--gcp-k)) var(--gc-sp);
  }
  @media (hover:hover) {
    .lumen-radio:not(:is([data-disabled],:disabled)):not([aria-invalid="true"]):not(:is([data-checked],[data-state="checked"])):hover {
      --x-ring: var(--gc-p-wrH);
    }
    .lumen-field-label:has(>.lumen-field):not(:has(:is([data-disabled],:disabled))):hover {
      transform: translateY(-1px);
      --c-ring: var(--gc-p-wrH);
    }
  }
  .lumen-field-label:has(>.lumen-field):not(:has(:is([data-disabled],:disabled))):active {
    transform: scale(.985);
  }
  .lumen-field-label:has(>.lumen-field):has(:is([data-checked],[data-state="checked"])) {
    background: color-mix(in srgb, var(--gc-p-acc) 7%, transparent);
    --c-ring: color-mix(in srgb, var(--gc-p-acc) 55%, transparent);
    box-shadow: inset 0 0 0 1.5px var(--c-ring);
  }
  .lumen-field-label:has(>.lumen-field):has(:focus-visible) {
    box-shadow: 0 0 0 2px var(--gc-gap), 0 0 0 4px var(--gc-p-focus), inset 0 0 0 1.5px var(--c-ring);
  }
  .lumen-field-label:has(>.lumen-field):has(:is([data-disabled],:disabled)) {
    opacity: .55;
    cursor: not-allowed;
  }
  @media (prefers-reduced-motion: reduce) {
    .lumen-field-label:has(>.lumen-field) {
      --gcp-k: 0;
      --gcp-cm: .15s;
    }
  }
  .lumen-field-label>.lumen-field {
    gap: 12px;
  }
  .lumen-field-label>.lumen-field .lumen-field-title {
    font-size: 14px;
  }
  .lumen-field-label>.lumen-field .lumen-field-description {
    font-size: 12.5px;
    line-height: 1.4;
  }
  @media (max-width: 699px) {
    .lumen-radio {
      --k-sz: 22px;
      --k-r: 7px;
      --k-mk: 8px;
      --k-sw: 52px;
      --k-sh: 32px;
      --k-kx: 8px;
      --k-cs: 5px;
      --k-ci: 10px;
    }
  }
}
```

The lumen foundation (the tokens listed above, keyframes and motion levels) installs once with the first component; its CSS is public at https://beautiful-ui.dev/r/lumen-foundation.json.
