Skip to content

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 radio
npx newtui@latest add radio
yarn dlx newtui@latest add radio
bunx newtui@latest add radio

Copy 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

PropType
value?string

The selected value, when you control it. Leave it off to let the group manage its own.

Typestring
defaultValue?string

The value selected on first render, for an uncontrolled group.

Typestring
onValueChange / v-model?(value: string) => void

Called with the value that was chosen.

Type(value: string) => void
label?string

Accessible name for the group. A radio group without one is announced as an unlabelled set.

Typestring

Radio

PropType
value?string

Which value this row stands for. Required, and unique within the group.

Typestring
description?ReactNode / string

A second line under the label, for a choice that needs explaining.

TypeReactNode / string
disabled?boolean

Skipped by the arrow keys and not selectable.

Typeboolean
Defaultfalse
children / default slot?React.ReactNode / Vue slot

The label.

TypeReact.ReactNode / Vue slot

Both 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.