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.
import { css } from 'remix/component'
import { Label } from '#app/ui/label.tsx'
import { RadioGroup, RadioGroupItem } from '#app/ui/radio-group.tsx'
const rowStyles = css({ display: 'flex', alignItems: 'center', gap: '0.75rem' })import { css } from 'remix/component'
import { Label } from '#app/ui/label.tsx'
import { RadioGroup, RadioGroupItem } from '#app/ui/radio-group.tsx'
const rowStyles = css({ display: 'flex', alignItems: 'center', gap: '0.75rem' })
export function RadioGroupDemo() {
return () => (
<RadioGroup defaultValue="comfortable">
<div mix={rowStyles}>
<RadioGroupItem value="default" id="r1" />
<Label htmlFor="r1">Default</Label>
</div>
<div mix={rowStyles}>
<RadioGroupItem value="comfortable" id="r2" />
<Label htmlFor="r2">Comfortable</Label>
</div>
<div mix={rowStyles}>
<RadioGroupItem value="compact" id="r3" />
<Label htmlFor="r3">Compact</Label>
</div>
</RadioGroup>
)
}Installation
$ bunx --bun shadcn@latest add https://www.shadcn-remix.com/r/radio-group.json$ npx shadcn@latest add https://www.shadcn-remix.com/r/radio-group.json$ pnpm dlx shadcn@latest add https://www.shadcn-remix.com/r/radio-group.json$ yarn dlx shadcn@latest add https://www.shadcn-remix.com/r/radio-group.jsonInstall the following dependencies:
$ bun add remix$ npm install remix$ pnpm add remix$ yarn add remixCopy and paste the following code into your project.
import { css, on, ref, type Handle, type Props } from 'remix/component'
import { CircleIcon } from './icons.tsx'
export interface RadioGroupProps extends Omit<Props<'div'>, 'defaultValue'> {
/** Controlled value of the checked item. */
value?: string
/** Initial value of the checked item when uncontrolled. */
defaultValue?: string
onValueChange?: (value: string) => void
/** Name of the group, submitted with the form along with the checked item's value. */
name?: string
required?: boolean
disabled?: boolean
orientation?: 'horizontal' | 'vertical'
dir?: 'ltr' | 'rtl'
/** Whether arrow-key navigation wraps from the last item to the first and vice versa. */
loop?: boolean
}
export interface RadioGroupItemProps extends Omit<Props<'button'>, 'value' | 'type'> {
value: string
required?: boolean
}
interface RadioGroupItemRegistration {
value: string
disabled: () => boolean
}
interface RadioGroupContext {
readonly name: string | undefined
readonly required: boolean
readonly disabled: boolean
readonly orientation: 'horizontal' | 'vertical' | undefined
readonly dir: 'ltr' | 'rtl' | undefined
readonly loop: boolean
readonly value: string | undefined
/** The value of the item that receives focus when tabbing into the group. */
readonly tabStop: string | undefined
register(item: RadioGroupItemRegistration, signal: AbortSignal): void
select(value: string): void
focus(value: string): void
}
const groupStyles = css({ display: 'grid', gap: '0.75rem' })
const itemStyles = css({
aspectRatio: '1 / 1',
width: '1rem',
height: '1rem',
flexShrink: 0,
borderRadius: '9999px',
borderWidth: '1px',
borderColor: 'var(--input)',
color: 'var(--primary)',
'--tw-shadow': '0 1px 2px 0 rgb(0 0 0 / 0.05)',
boxShadow: 'var(--tw-ring-shadow), var(--tw-shadow)',
transitionProperty: 'color, box-shadow',
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
transitionDuration: '150ms',
outlineStyle: 'none',
'&:focus-visible': {
borderColor: 'var(--ring)',
'--tw-ring-shadow':
'0 0 0 3px var(--tw-ring-color, color-mix(in oklab, var(--ring) 50%, transparent))',
},
'&:disabled': { cursor: 'not-allowed', opacity: 0.5 },
'&[aria-invalid="true"]': {
borderColor: 'var(--destructive)',
'--tw-ring-color': 'color-mix(in oklab, var(--destructive) 20%, transparent)',
},
'.dark &': { backgroundColor: 'color-mix(in oklab, var(--input) 30%, transparent)' },
'.dark &[aria-invalid="true"]': {
'--tw-ring-color': 'color-mix(in oklab, var(--destructive) 40%, transparent)',
},
})
const indicatorStyles = css({
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
})
const circleStyles = css({
position: 'absolute',
top: '50%',
left: '50%',
width: '0.5rem',
height: '0.5rem',
translate: '-50% -50%',
fill: 'var(--primary)',
})
/** The visually hidden native input that lets the item take part in form submission. */
const bubbleInputStyles = css({
position: 'absolute',
transform: 'translateX(-100%)',
pointerEvents: 'none',
opacity: 0,
margin: 0,
})
const ARROW_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']
/**
* A set of checkable buttons where no more than one can be checked at a time. Arrow keys move
* focus between items and check the focused one; only one item is in the tab order.
*/
export function RadioGroup(handle: Handle<RadioGroupProps, RadioGroupContext>) {
let uncontrolled = handle.props.defaultValue
let focused: string | undefined
let items: RadioGroupItemRegistration[] = []
let currentValue = () => handle.props.value ?? uncontrolled
handle.context.set({
get name() {
return handle.props.name
},
get required() {
return handle.props.required ?? false
},
get disabled() {
return handle.props.disabled ?? false
},
get orientation() {
return handle.props.orientation
},
get dir() {
return handle.props.dir
},
get loop() {
return handle.props.loop ?? true
},
get value() {
return currentValue()
},
get tabStop() {
let enabled = items.filter((item) => !item.disabled())
if (focused !== undefined && enabled.some((item) => item.value === focused)) return focused
let value = currentValue()
if (enabled.some((item) => item.value === value)) return value
// Items register while rendering; a checked item may not have registered yet.
return value || enabled[0]?.value
},
register(item, signal) {
items.push(item)
signal.addEventListener('abort', () => {
items = items.filter((registered) => registered !== item)
})
},
select(value) {
if (value === currentValue()) return
if (handle.props.value === undefined) uncontrolled = value
handle.props.onValueChange?.(value)
handle.update()
},
focus(value) {
if (focused === value) return
focused = value
handle.update()
},
})
return () => {
let {
value: _value,
defaultValue: _defaultValue,
onValueChange: _onValueChange,
name: _name,
loop: _loop,
required,
disabled,
orientation,
dir,
mix,
...props
} = handle.props
return (
<div
role="radiogroup"
aria-required={required}
aria-orientation={orientation}
data-disabled={disabled ? '' : undefined}
dir={dir}
data-slot="radio-group"
{...props}
mix={[groupStyles, mix]}
/>
)
}
}
export function RadioGroupItem(handle: Handle<RadioGroupItemProps>) {
let group = handle.context.get(RadioGroup)
let isFormControl = true
group.register(
{ value: handle.props.value, disabled: () => group.disabled || !!handle.props.disabled },
handle.signal,
)
function move(event: KeyboardEvent, button: HTMLButtonElement) {
let rtl = (group.dir ?? getComputedStyle(button).direction) === 'rtl'
let horizontal = group.orientation !== 'vertical'
let vertical = group.orientation !== 'horizontal'
let intent: 'prev' | 'next' | 'first' | 'last' | undefined
if (horizontal && event.key === 'ArrowLeft') intent = rtl ? 'next' : 'prev'
else if (horizontal && event.key === 'ArrowRight') intent = rtl ? 'prev' : 'next'
else if (vertical && event.key === 'ArrowUp') intent = 'prev'
else if (vertical && event.key === 'ArrowDown') intent = 'next'
else if (event.key === 'Home' || event.key === 'PageUp') intent = 'first'
else if (event.key === 'End' || event.key === 'PageDown') intent = 'last'
if (!intent) return
let root = button.closest('[role="radiogroup"]')
if (!root) return
let candidates = Array.from(root.querySelectorAll<HTMLButtonElement>('[role="radio"]')).filter(
(item) => !item.disabled && item.closest('[role="radiogroup"]') === root,
)
let index = candidates.indexOf(button)
let target: HTMLButtonElement | undefined
if (intent === 'first') target = candidates[0]
else if (intent === 'last') target = candidates[candidates.length - 1]
else {
let next = index + (intent === 'next' ? 1 : -1)
if (group.loop) next = (next + candidates.length) % candidates.length
target = candidates[next]
}
if (!target) return
event.preventDefault()
target.focus()
// Arrow-key focus checks the item, like native radios.
if (ARROW_KEYS.includes(event.key)) target.click()
}
return () => {
let { value, required, disabled: itemDisabled, form, mix, ...props } = handle.props
let disabled = group.disabled || !!itemDisabled
let checked = group.value === value
let state = checked ? 'checked' : 'unchecked'
return (
<>
<button
type="button"
role="radio"
aria-checked={checked}
data-state={state}
data-disabled={disabled ? '' : undefined}
disabled={disabled}
value={value}
form={form}
tabIndex={group.tabStop === value ? 0 : -1}
data-slot="radio-group-item"
{...props}
mix={[
itemStyles,
ref((node) => {
if (!node.form) {
isFormControl = false
queueMicrotask(() => handle.update())
}
}),
on('keydown', (event) => {
// WAI-ARIA: radio buttons don't activate on Enter.
if (event.key === 'Enter') event.preventDefault()
move(event, event.currentTarget)
}),
on('focus', () => group.focus(value)),
on('click', () => group.select(value)),
mix,
]}
>
{checked && (
<span
data-slot="radio-group-indicator"
data-state={state}
data-disabled={disabled ? '' : undefined}
mix={indicatorStyles}
>
<CircleIcon mix={circleStyles} />
</span>
)}
</button>
{isFormControl && (
<input
type="radio"
aria-hidden="true"
tabIndex={-1}
name={group.name}
value={value}
checked={checked}
required={required ?? group.required}
disabled={disabled}
form={form}
mix={[
bubbleInputStyles,
ref((node) => {
let control = node.previousElementSibling as HTMLElement | null
if (control) {
// `important` so layout rules like Field's `[&>*]:w-full` can't resize it.
node.style.setProperty('width', `${control.offsetWidth}px`, 'important')
node.style.setProperty('height', `${control.offsetHeight}px`, 'important')
}
}),
]}
/>
)}
</>
)
}
}Update the import paths to match your project setup.
Usage
import { css } from 'remix/component'
import { Label } from '#app/ui/label.tsx'
import { RadioGroup, RadioGroupItem } from '#app/ui/radio-group.tsx'<RadioGroup defaultValue="option-one">
<div mix={css({ display: 'flex', alignItems: 'center', gap: '0.75rem' })}>
<RadioGroupItem value="option-one" id="option-one" />
<Label htmlFor="option-one">Option One</Label>
</div>
<div mix={css({ display: 'flex', alignItems: 'center', gap: '0.75rem' })}>
<RadioGroupItem value="option-two" id="option-two" />
<Label htmlFor="option-two">Option Two</Label>
</div>
</RadioGroup>API Reference
RadioGroup
A set of checkable buttons where no more than one can be checked at a time. Arrow keys move focus between items and check the focused one; only one item is in the tab order.
| Prop | Type | Default |
|---|---|---|
value | string | - |
defaultValue | string | - |
onValueChange | ((value: string) => void) | - |
name | string | - |
required | boolean | - |
disabled | boolean | - |
orientation | "horizontal" | "vertical" | - |
dir | "rtl" | "ltr" | - |
loop | boolean | - |
mix | MixValue | - |
RadioGroupItem
| Prop | Type | Default |
|---|---|---|
value | string | - |
required | boolean | - |
mix | MixValue | - |