Radio
One choice out of a short, visible list, with the ring and dot the client's settings panels use.
A radio group is for a choice that is short enough to show in full. Where a select menu hides its options until asked, this shows all of them at once — which is what the client’s settings panels do when there are two or three and the difference between them matters.
The state is in the ring and the dot, not in motion: the selected row reads as selected in a screenshot, and in greyscale.
"use client"
import * as React from "react"
import { Radio, RadioGroup } from "@/registry/bases/newt/ui/radio"
export default function RadioDemo() {
const [mode, setMode] = React.useState("voice-activity")
return (
<div className="flex w-full max-w-sm flex-col gap-6">
<RadioGroup label="Input mode" value={mode} onValueChange={setMode}>
<Radio value="voice-activity">Voice Activity</Radio>
<Radio value="push-to-talk">Push to Talk</Radio>
</RadioGroup>
<RadioGroup label="Who can reply" defaultValue="everyone">
<Radio
value="everyone"
description="Anyone in the channel can reply to this message."
>
Everyone
</Radio>
<Radio
value="nobody"
description="Replies are turned off for this message."
disabled
>
Nobody
</Radio>
</RadioGroup>
</div>
)
}<script setup lang="ts">
import { ref } from "vue"
import { Radio, RadioGroup } from "~~/registry/bases/newt/ui/radio"
const mode = ref("voice-activity")
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-6">
<RadioGroup v-model="mode" label="Input mode">
<Radio value="voice-activity">Voice Activity</Radio>
<Radio value="push-to-talk">Push to Talk</Radio>
</RadioGroup>
<RadioGroup label="Who can reply" default-value="everyone">
<Radio
value="everyone"
description="Anyone in the channel can reply to this message."
>
Everyone
</Radio>
<Radio
value="nobody"
description="Replies are turned off for this message."
disabled
>
Nobody
</Radio>
</RadioGroup>
</div>
</template>Installation
pnpm dlx newtui@latest add radionpx newtui@latest add radioyarn dlx newtui@latest add radiobunx newtui@latest add radioCopy the source into your project
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
/*
* A radio group: one choice out of a short, visible list. Where a select menu
* hides its options until asked, this shows them all — which is what the
* client's settings panels use when there are two or three of them and the
* difference between them matters.
*
* The group owns the value and the keyboard. Each row is a button rather than
* an `<input>`, so the ring and the dot are stylable, and the roles restate
* what the native element would have said.
*/
interface RadioGroupContextValue {
readonly value: string | undefined
readonly select: (value: string) => void
readonly register: (value: string, disabled: boolean) => void
readonly name: string
}
const RadioGroupContext = React.createContext<RadioGroupContextValue | null>(
null
)
function useRadioGroup(component: string): RadioGroupContextValue {
const context = React.useContext(RadioGroupContext)
if (context === null) {
throw new Error(`<${component}> must be used inside a <RadioGroup>.`)
}
return context
}
export interface RadioGroupProps extends Omit<
React.HTMLAttributes<HTMLDivElement>,
"onChange" | "defaultValue"
> {
/** Controlled selection. Leave undefined to let the group own it. */
value?: string
defaultValue?: string
onValueChange?: (value: string) => void
/** Accessible name for the group. */
label?: string
}
const RadioGroup = React.forwardRef<HTMLDivElement, RadioGroupProps>(
(
{
className,
value,
defaultValue,
onValueChange,
label,
children,
...props
},
ref
) => {
const [internal, setInternal] = React.useState(defaultValue)
const isControlled = value !== undefined
const current = isControlled ? value : internal
/*
* The order rows register in is the order the arrow keys walk, which is
* the order they were rendered. A ref keeps that out of render.
*/
const order = React.useRef<{ value: string; disabled: boolean }[]>([])
const register = React.useCallback(
(rowValue: string, disabled: boolean) => {
const existing = order.current.find((row) => row.value === rowValue)
if (existing) existing.disabled = disabled
else order.current.push({ value: rowValue, disabled })
},
[]
)
const select = React.useCallback(
(next: string) => {
if (!isControlled) setInternal(next)
onValueChange?.(next)
},
[isControlled, onValueChange]
)
const move = (event: React.KeyboardEvent<HTMLDivElement>) => {
const options = order.current.filter((row) => !row.disabled)
if (options.length === 0) return
const index = options.findIndex((row) => row.value === current)
const next = {
ArrowDown: index + 1,
ArrowRight: index + 1,
ArrowUp: index - 1,
ArrowLeft: index - 1,
Home: 0,
End: options.length - 1,
}[event.key]
if (next === undefined) return
event.preventDefault()
// A group with nothing selected starts from the first row.
const target = options[(next + options.length) % options.length]
if (target) select(target.value)
}
const context = React.useMemo<RadioGroupContextValue>(
() => ({ value: current, select, register, name: label ?? "radiogroup" }),
[current, select, register, label]
)
return (
<RadioGroupContext.Provider value={context}>
<div
ref={ref}
role="radiogroup"
aria-label={label}
onKeyDown={move}
className={cn("flex flex-col gap-1", className)}
{...props}
>
{children}
</div>
</RadioGroupContext.Provider>
)
}
)
RadioGroup.displayName = "RadioGroup"
export interface RadioProps extends Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
"value"
> {
value: string
/** A second line, when the choice needs a sentence to explain it. */
description?: React.ReactNode
}
const Radio = React.forwardRef<HTMLButtonElement, RadioProps>(
(
{
className,
value,
description,
children,
disabled = false,
onClick,
...props
},
ref
) => {
const group = useRadioGroup("Radio")
const checked = group.value === value
React.useEffect(() => {
group.register(value, disabled)
}, [group, value, disabled])
return (
<button
ref={ref}
type="button"
role="radio"
aria-checked={checked}
aria-disabled={disabled || undefined}
// Roving tabindex: the group is one tab stop, and the arrows move
// within it.
tabIndex={checked ? 0 : -1}
onClick={(event) => {
onClick?.(event)
if (event.defaultPrevented || disabled) return
group.select(value)
}}
className={cn(
"group flex cursor-pointer items-center gap-3 border-0 bg-transparent px-1 py-2 text-start font-sans text-[15px] leading-5 text-newt-text-secondary",
"hover:text-newt-text-primary focus-visible:rounded-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-newt-text-link",
checked && "text-newt-text-primary",
disabled && "cursor-not-allowed opacity-50",
className
)}
{...props}
>
<span
aria-hidden="true"
data-checked={checked || undefined}
className={cn(
"relative h-5 w-5 shrink-0 rounded-full border-2 border-newt-text-muted transition-colors duration-fast ease-newt",
"after:absolute after:top-1/2 after:start-1/2 after:h-2 after:w-2 after:rounded-full after:bg-white after:content-['']",
// Centring, and the dot's grow — both carry the writing direction.
"after:[transform:translate(calc(var(--newt-dir)*-50%),-50%)_scale(0)]",
"data-[checked]:border-newt-brand data-[checked]:bg-newt-brand",
"data-[checked]:after:[transform:translate(calc(var(--newt-dir)*-50%),-50%)_scale(1)]",
!disabled && "group-hover:border-newt-text-secondary"
)}
/>
{description === undefined ? (
children
) : (
<span className="flex min-w-0 flex-col gap-0.5">
<span>{children}</span>
<span className="text-[13px] leading-[17px] text-newt-text-muted">
{description}
</span>
</span>
)}
</button>
)
}
)
Radio.displayName = "Radio"
export { Radio, RadioGroup }<script setup lang="ts">
import { computed, onMounted, watch } from "vue"
import { cn } from "@/lib/utils"
import { useRadioGroup } from "./useRadioGroup"
const props = withDefaults(
defineProps<{
class?: string
value: string
/** A second line, when the choice needs a sentence to explain it. */
description?: string
disabled?: boolean
}>(),
{ disabled: false }
)
const group = useRadioGroup()
const checked = computed(() => group.value.value === props.value)
onMounted(() => group.register(props.value, props.disabled))
watch(
() => props.disabled,
(disabled) => group.register(props.value, disabled)
)
function pick() {
if (props.disabled) return
group.select(props.value)
}
</script>
<template>
<button
type="button"
role="radio"
:aria-checked="checked"
:aria-disabled="props.disabled || undefined"
:tabindex="checked ? 0 : -1"
:class="
cn(
'group flex cursor-pointer items-center gap-3 border-0 bg-transparent px-1 py-2 text-start font-sans text-[15px] leading-5 text-newt-text-secondary',
'hover:text-newt-text-primary focus-visible:rounded-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-newt-text-link',
checked && 'text-newt-text-primary',
props.disabled && 'cursor-not-allowed opacity-50',
props.class
)
"
@click="pick"
>
<span
aria-hidden="true"
:data-checked="checked || undefined"
:class="
cn(
'relative h-5 w-5 shrink-0 rounded-full border-2 border-newt-text-muted transition-colors duration-fast ease-newt',
'after:absolute after:top-1/2 after:start-1/2 after:h-2 after:w-2 after:rounded-full after:bg-white after:content-[\'\']',
'after:[transform:translate(calc(var(--newt-dir)*-50%),-50%)_scale(0)]',
'data-[checked]:border-newt-brand data-[checked]:bg-newt-brand',
'data-[checked]:after:[transform:translate(calc(var(--newt-dir)*-50%),-50%)_scale(1)]',
!props.disabled && 'group-hover:border-newt-text-secondary'
)
"
/>
<span v-if="props.description" class="flex min-w-0 flex-col gap-0.5">
<span><slot /></span>
<span class="text-[13px] leading-[17px] text-newt-text-muted">
{{ props.description }}
</span>
</span>
<slot v-else />
</button>
</template><script setup lang="ts">
import { computed, ref } from "vue"
import { cn } from "@/lib/utils"
import { provideRadioGroup } from "./useRadioGroup"
/*
* A radio group: one choice out of a short, visible list. Where a select menu
* hides its options until asked, this shows them all — which is what the
* client's settings panels use when there are two or three of them and the
* difference between them matters.
*/
const props = defineProps<{
class?: string
/** Controlled selection. Leave undefined to let the group own it. */
modelValue?: string
defaultValue?: string
/** Accessible name for the group. */
label?: string
}>()
const emit = defineEmits<{
"update:modelValue": [value: string]
valueChange: [value: string]
}>()
const internal = ref(props.defaultValue)
const isControlled = computed(() => props.modelValue !== undefined)
const current = computed(() =>
isControlled.value ? props.modelValue : internal.value
)
/* Registration order is render order, which is the order the arrows walk. */
const order = ref<{ value: string; disabled: boolean }[]>([])
function register(value: string, disabled: boolean) {
const existing = order.value.find((row) => row.value === value)
if (existing) existing.disabled = disabled
else order.value.push({ value, disabled })
}
function select(value: string) {
if (!isControlled.value) internal.value = value
emit("update:modelValue", value)
emit("valueChange", value)
}
provideRadioGroup({ value: current, select, register })
function move(event: KeyboardEvent) {
const options = order.value.filter((row) => !row.disabled)
if (options.length === 0) return
const index = options.findIndex((row) => row.value === current.value)
const target = {
ArrowDown: index + 1,
ArrowRight: index + 1,
ArrowUp: index - 1,
ArrowLeft: index - 1,
Home: 0,
End: options.length - 1,
}[event.key]
if (target === undefined) return
event.preventDefault()
const chosen = options[(target + options.length) % options.length]
if (chosen) select(chosen.value)
}
</script>
<template>
<div
role="radiogroup"
:aria-label="props.label"
:class="cn('flex flex-col gap-1', props.class)"
@keydown="move"
>
<slot />
</div>
</template>import { inject, provide, type InjectionKey, type Ref } from "vue"
/*
* The group owns the value and the order rows were rendered in — the order the
* arrow keys walk. Rows register themselves, so the group never has to inspect
* its own slot content.
*/
export interface RadioGroupContext {
readonly value: Ref<string | undefined>
readonly select: (value: string) => void
readonly register: (value: string, disabled: boolean) => void
}
export const RADIO_GROUP_KEY: InjectionKey<RadioGroupContext> =
Symbol("newt-radio-group")
export function provideRadioGroup(context: RadioGroupContext): void {
provide(RADIO_GROUP_KEY, context)
}
export function useRadioGroup(): RadioGroupContext {
const context = inject(RADIO_GROUP_KEY, undefined)
if (context === undefined) {
throw new Error("<Radio> must be used inside a <RadioGroup>.")
}
return context
}export { default as Radio } from "./Radio.vue"
export { default as RadioGroup } from "./RadioGroup.vue"
export {
RADIO_GROUP_KEY,
useRadioGroup,
type RadioGroupContext,
} from "./useRadioGroup"Update the import paths
The files import @/lib/utils and sibling components through the aliases in your components.json. Point them at wherever those live in your project.
Usage
import { Radio } from "@/components/ui/radio"
export function Example() {
return <Radio />
}<script setup lang="ts">
import { Radio, RadioGroup } from "@/components/ui/radio"
</script>
<template>
<Radio />
</template>The group owns the value; each Radio only names the value it stands for. Leave
value off the group and it manages its own selection from defaultValue.
<RadioGroup label="Input mode" value={mode} onValueChange={setMode}>
<Radio value="voice-activity">Voice Activity</Radio>
<Radio value="push-to-talk">Push to Talk</Radio>
</RadioGroup><RadioGroup v-model="mode" label="Input mode">
<Radio value="voice-activity">Voice Activity</Radio>
<Radio value="push-to-talk">Push to Talk</Radio>
</RadioGroup>Second lines
A choice that needs a sentence to explain it takes a description, which sits
under the label in muted text. Use it when the label alone would make somebody
guess; leave it off when it would only restate the label.
API reference
RadioGroup
value?string
The selected value, when you control it. Leave it off to let the group manage its own.
stringdefaultValue?string
The value selected on first render, for an uncontrolled group.
stringonValueChange / v-model?(value: string) => void
Called with the value that was chosen.
(value: string) => voidlabel?string
Accessible name for the group. A radio group without one is announced as an unlabelled set.
stringRadio
value?string
Which value this row stands for. Required, and unique within the group.
stringdescription?ReactNode / string
A second line under the label, for a choice that needs explaining.
ReactNode / stringdisabled?boolean
Skipped by the arrow keys and not selectable.
booleanfalsechildren / default slot?React.ReactNode / Vue slot
The label.
React.ReactNode / Vue slotBoth parts also accept the native attributes of the element they render, and a
className / class that is merged rather than replaced.
Accessibility
The group is role="radiogroup" with an accessible name; each row is
role="radio" with aria-checked. That is the contract a screen reader
announces, and it is why the rows are buttons rather than styled <input>
elements — the ring and dot stay stylable without losing the semantics.
The group is one tab stop, not one per option: Tab reaches the selected row, and ↑ ↓ ← → move the selection within it, wrapping at the ends. Home and End jump to the first and last enabled row. Disabled rows are skipped rather than focused and refused.
Give the group a label. Without one the set is announced with no indication of
what is being chosen, which is the most common way this pattern fails.
Tokens
Radio reads its colours, radii and motion from the --newt-* tokens installed with the library. Override a token and every component that reads it follows, including this one — see the token layer.