A set of options where exactly one can be checked.
View codeHide code
import { RadioGroup, RadioGroupIndicator, RadioGroupItem } from '@syncclouds/ui'
const REGIONS = ['Frankfurt', 'Virginia', 'Singapore']
export function RadioGroupDemo() {
return (
<RadioGroup aria-label="Region" defaultValue="virginia">
{REGIONS.map(region => (
<RadioGroupItem key={region} value={region.toLowerCase()}>
<RadioGroupIndicator />
{region}
</RadioGroupItem>
))}
</RadioGroup>
)
}Installation
Available at launch
pnpm add @syncclouds/uinpm install @syncclouds/uiyarn add @syncclouds/uibun add @syncclouds/uiAvailable at launch
pnpm dlx shadcn@latest add https://ui.syncclouds.com/r/radio-group.jsonnpx shadcn@latest add https://ui.syncclouds.com/r/radio-group.jsonyarn dlx shadcn@latest add https://ui.syncclouds.com/r/radio-group.jsonbunx --bun shadcn@latest add https://ui.syncclouds.com/r/radio-group.jsonThe CLI copies 3 files into your ui folder.
1. Copy these files into one folder
radio-group.tsxShowHide
'use client'
import {
createContext,
use,
type ComponentPropsWithRef,
type KeyboardEvent
} from 'react'
import { useControllableState } from './sui-use-controllable-state'
import { nextIndex, ownItems, type Orientation } from './sui-keyboard'
interface RadioGroupContextValue {
value: string
setValue: (value: string) => void
disabled: boolean
}
const RadioGroupContext = createContext<RadioGroupContextValue | null>(null)
const ItemContext = createContext<'checked' | 'unchecked'>('unchecked')
const useRadioGroup = (part: string) => {
const context = use(RadioGroupContext)
if (!context) throw new Error(`<${part}> must be used inside <RadioGroup>`)
return context
}
export interface RadioGroupProps
extends Omit<ComponentPropsWithRef<'div'>, 'defaultValue' | 'onChange'> {
value?: string
defaultValue?: string
onValueChange?: (value: string) => void
/** Adds a hidden input so the value submits with a form. */
name?: string
disabled?: boolean
orientation?: Orientation
}
/**
* A radio group (APG Radio Group). Tab enters on the checked item, and all
* four arrow keys move and select, wrapping at the ends. Put the label
* text inside each <RadioGroupItem>, and name the group with aria-label or
* aria-labelledby.
*/
export function RadioGroup({
value,
defaultValue = '',
onValueChange,
name,
disabled = false,
orientation,
onKeyDown,
children,
...props
}: RadioGroupProps) {
const [current, setCurrent] = useControllableState({
prop: value,
defaultProp: defaultValue,
onChange: onValueChange
})
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(event)
if (event.defaultPrevented || disabled) return
const radios = ownItems(
event.currentTarget,
'[role="radio"]',
'[data-scope="radio-group"][data-part="root"]'
)
const index = radios.indexOf(document.activeElement as HTMLElement)
/* Home and End are not part of the radio pattern. */
if (event.key === 'Home' || event.key === 'End') return
const next = nextIndex(event.key, index, radios.length, 'both')
if (next === null) return
event.preventDefault()
radios[next].focus()
if (radios[next].dataset.value) setCurrent(radios[next].dataset.value)
}
return (
<RadioGroupContext value={{ value: current, setValue: setCurrent, disabled }}>
<div
{...props}
role="radiogroup"
aria-orientation={orientation}
aria-disabled={disabled || undefined}
data-scope="radio-group"
data-part="root"
data-orientation={orientation}
data-disabled={disabled ? '' : undefined}
onKeyDown={handleKeyDown}
>
{children}
{name && <input type="hidden" name={name} value={current} />}
</div>
</RadioGroupContext>
)
}
export interface RadioGroupItemProps
extends Omit<ComponentPropsWithRef<'button'>, 'value'> {
value: string
}
export function RadioGroupItem({
value,
disabled: itemDisabled,
onClick,
onKeyDown,
children,
...props
}: RadioGroupItemProps) {
const { value: current, setValue, disabled: groupDisabled } =
useRadioGroup('RadioGroupItem')
const disabled = groupDisabled || itemDisabled
const checked = current === value
const state = checked ? 'checked' : 'unchecked'
/* With nothing checked yet, every item stays reachable by Tab. */
const tabIndex = checked || current === '' ? 0 : -1
return (
<ItemContext value={state}>
<button
type="button"
{...props}
role="radio"
aria-checked={checked}
tabIndex={tabIndex}
disabled={disabled}
data-scope="radio-group"
data-part="item"
data-state={state}
data-disabled={disabled ? '' : undefined}
data-value={value}
onClick={event => {
onClick?.(event)
if (!event.defaultPrevented) setValue(value)
}}
onKeyDown={event => {
onKeyDown?.(event)
/* Enter submits forms; it never checks a radio. */
if (event.key === 'Enter') event.preventDefault()
}}
>
{children}
</button>
</ItemContext>
)
}
/** The drawn circle. Purely visual; state comes from the item. */
export function RadioGroupIndicator(props: ComponentPropsWithRef<'span'>) {
const state = use(ItemContext)
return (
<span
aria-hidden="true"
{...props}
data-scope="radio-group"
data-part="indicator"
data-state={state}
/>
)
}
sui-use-controllable-state.tsShowHide
import { useCallback, useState } from 'react'
/**
* Controlled when `prop` is defined, uncontrolled otherwise. `onChange`
* fires for every change either way.
*/
export function useControllableState<T>({
prop,
defaultProp,
onChange
}: {
prop: T | undefined
defaultProp: T
onChange?: (value: T) => void
}) {
const [uncontrolled, setUncontrolled] = useState(defaultProp)
const controlled = prop !== undefined
const value = controlled ? prop : uncontrolled
const setValue = useCallback(
(next: T) => {
if (!controlled) setUncontrolled(next)
if (!Object.is(next, value)) onChange?.(next)
},
[controlled, onChange, value]
)
return [value, setValue] as const
}
sui-keyboard.tsShowHide
import { useRef } from 'react'
export type Orientation = 'horizontal' | 'vertical'
/**
* The next index for a list navigated with arrow keys, Home and End, or
* null when the key is not a navigation key. Lists wrap, as the APG
* patterns used here recommend. `both` accepts all four arrows (radio).
*/
export function nextIndex(
key: string,
current: number,
count: number,
orientation: Orientation | 'both'
): number | null {
if (count === 0) return null
const forward =
(orientation !== 'horizontal' && key === 'ArrowDown') ||
(orientation !== 'vertical' && key === 'ArrowRight')
const backward =
(orientation !== 'horizontal' && key === 'ArrowUp') ||
(orientation !== 'vertical' && key === 'ArrowLeft')
if (key === 'Home') return 0
if (key === 'End') return count - 1
if (forward) return current < 0 ? 0 : (current + 1) % count
if (backward) return current < 0 ? count - 1 : (current - 1 + count) % count
return null
}
/**
* Items that belong to this container, skipping disabled ones and any that
* sit inside a nested instance of the same component.
*/
export function ownItems(
container: HTMLElement,
itemSelector: string,
containerSelector: string
): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>(itemSelector)).filter(
el =>
el.closest(containerSelector) === container &&
!el.hasAttribute('disabled') &&
el.getAttribute('aria-disabled') !== 'true'
)
}
/**
* Typeahead: printable keys typed within half a second build a search
* string, and the first matching label after the current item wins.
*/
export function useTypeahead() {
const buffer = useRef('')
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
return (key: string, labels: string[], current: number): number | null => {
if (key.length !== 1 || key === ' ') return null
clearTimeout(timer.current)
timer.current = setTimeout(() => (buffer.current = ''), 500)
buffer.current += key.toLowerCase()
const search = buffer.current
const repeated = search.split('').every(char => char === search[0])
const query = repeated ? search[0] : search
const start = repeated ? current + 1 : current
for (let i = 0; i < labels.length; i++) {
const index = (start + i + labels.length) % labels.length
if (labels[index].trim().toLowerCase().startsWith(query)) return index
}
return null
}
}
/** Makes a value safe to use inside an id attribute. */
export const idSafe = (value: string) => value.replace(/[^\w-]/g, '_')
2. Import from that folder
The files import each other with relative paths, so keep them together, for example in components/ui.
Usage
import { RadioGroup, RadioGroupIndicator, RadioGroupItem } from '@syncclouds/ui'<RadioGroup aria-label="Region" defaultValue="virginia">
<RadioGroupItem value="frankfurt">
<RadioGroupIndicator />
Frankfurt
</RadioGroupItem>
<RadioGroupItem value="virginia">
<RadioGroupIndicator />
Virginia
</RadioGroupItem>
</RadioGroup>Anatomy
RadioGroup
└── RadioGroupItem
└── RadioGroupIndicatorHorizontal
View codeHide code
import { RadioGroup, RadioGroupIndicator, RadioGroupItem } from '@syncclouds/ui'
export function RadioGroupHorizontal() {
return (
<RadioGroup aria-label="Billing cycle" orientation="horizontal" defaultValue="monthly">
<RadioGroupItem value="monthly">
<RadioGroupIndicator />
Monthly
</RadioGroupItem>
<RadioGroupItem value="yearly">
<RadioGroupIndicator />
Yearly
</RadioGroupItem>
</RadioGroup>
)
}Disabled
Disable one item, or the whole group with disabled on RadioGroup.
View codeHide code
import { RadioGroup, RadioGroupIndicator, RadioGroupItem } from '@syncclouds/ui'
export function RadioGroupDisabled() {
return (
<RadioGroup aria-label="Delivery" defaultValue="standard">
<RadioGroupItem value="standard">
<RadioGroupIndicator />
Standard, 3 to 5 days
</RadioGroupItem>
<RadioGroupItem value="express">
<RadioGroupIndicator />
Express, next day
</RadioGroupItem>
<RadioGroupItem value="same-day" disabled>
<RadioGroupIndicator />
Same day (not in your area)
</RadioGroupItem>
</RadioGroup>
)
}In a form
With name, the value submits with the form.
View codeHide code
'use client'
import { useState, type FormEvent } from 'react'
import { RadioGroup, RadioGroupIndicator, RadioGroupItem } from '@syncclouds/ui'
/* With name, a hidden input carries the value into FormData. */
export function RadioGroupForm() {
const [sent, setSent] = useState<string | null>(null)
const onSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
setSent(String(new FormData(event.currentTarget).get('plan')))
}
return (
<form onSubmit={onSubmit} className="flex flex-col items-start gap-4">
<p id="plan-label" className="text-sm font-semibold">
Plan
</p>
<RadioGroup aria-labelledby="plan-label" name="plan" defaultValue="team">
{['Starter', 'Team', 'Enterprise'].map(plan => (
<RadioGroupItem key={plan} value={plan.toLowerCase()}>
<RadioGroupIndicator />
{plan}
</RadioGroupItem>
))}
</RadioGroup>
<button type="submit" className="rounded-md border border-current/40 px-3 py-1.5 text-sm font-medium">
Submit
</button>
<p className="text-sm">{sent ? `Form sent plan=${sent}` : 'Not sent yet.'}</p>
</form>
)
}Without the theme
View codeHide code
import { RadioGroup, RadioGroupItem } from '@syncclouds/ui'
const SIZES = ['S', 'M', 'L', 'XL']
export function RadioGroupUnstyled() {
return (
<RadioGroup aria-label="Size" orientation="horizontal" defaultValue="M" className="flex gap-2">
{SIZES.map(size => (
<RadioGroupItem
key={size}
value={size}
className="flex size-11 items-center justify-center rounded-lg border border-zinc-300 text-sm font-semibold text-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 data-[state=checked]:border-indigo-600 data-[state=checked]:bg-indigo-600 data-[state=checked]:text-white dark:border-zinc-600 dark:text-zinc-100 dark:data-[state=checked]:border-indigo-400 dark:data-[state=checked]:bg-indigo-400 dark:data-[state=checked]:text-zinc-950"
>
{size}
</RadioGroupItem>
))}
</RadioGroup>
)
}Accessibility
Follows the WAI-ARIA Radio Group pattern.
| Keys | What they do |
|---|---|
| Tab | Moves focus to the checked item. The group is one tab stop. |
| Arrow keys | Move to the next or previous item and check it, wrapping at the ends. |
| Space | Checks the focused item. |
- Enter never checks an item, so it can still submit a form.
API reference
Every part also accepts the props of the element it renders, including className and ref.
RadioGrouprenders <div>
The group. Name it with aria-label or aria-labelledby.
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | none | The checked item, when you control it. |
defaultValue | string | "" | The item checked at first. |
onValueChange | (value: string) => void | none | Called when another item is checked. |
name | string | none | Adds a hidden input so the value submits with a form. |
disabled | boolean | false | Disables every item. |
orientation | "horizontal" | "vertical" | none | Sets aria-orientation. All four arrow keys work either way. |
| Data attribute | Values |
|---|---|
data-orientation | "horizontal" | "vertical" |
data-disabled | present when disabled |
RadioGroupItemrenders <button>
One option. Put its label text inside.
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | none | Required. Identifies the item. |
disabled | boolean | false | Disables this item. |
| Data attribute | Values |
|---|---|
data-state | "checked" | "unchecked" |
data-disabled | present when disabled |
RadioGroupIndicatorrenders <span>
The drawn circle. Purely visual; hidden from assistive technology.
| Data attribute | Values |
|---|---|
data-state | "checked" | "unchecked" |