Radio Group

A set of checkable buttons where only one can be checked at a time.

The Radio Group component provides a set of radio buttons where only one option can be selected at a time. It supports controlled and uncontrolled modes, keyboard navigation, and accessibility features. Built from scratch using React and native HTML elements. No dependencies on any UI library.

Code

TypeScript: Copy this code into components/ui/radio-group.tsx:

tsx
"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

interface RadioGroupContextValue {
  value?: string
  onValueChange?: (value: string) => void
  name?: string
}

const RadioGroupContext = React.createContext<RadioGroupContextValue | undefined>(undefined)

const useRadioGroup = () => {
  const context = React.useContext(RadioGroupContext)
  return context
}

interface RadioGroupProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
  value?: string
  defaultValue?: string
  onValueChange?: (value: string) => void
  disabled?: boolean
  name?: string
}

const RadioGroup = React.forwardRef<HTMLDivElement, RadioGroupProps>(
  ({ className, value: controlledValue, defaultValue, onValueChange, disabled, name, ...props }, ref) => {
    const [uncontrolledValue, setUncontrolledValue] = React.useState<string | undefined>(defaultValue)
    
    const isControlled = controlledValue !== undefined
    const value = isControlled ? controlledValue : uncontrolledValue

    const handleValueChange = React.useCallback((newValue: string) => {
      if (disabled) return
      if (!isControlled) {
        setUncontrolledValue(newValue)
      }
      onValueChange?.(newValue)
    }, [disabled, isControlled, onValueChange])

    const groupName = name || React.useId()

    return (
      <RadioGroupContext.Provider value={{ value, onValueChange: handleValueChange, name: groupName }}>
        <div
          ref={ref}
          className={cn("space-y-2", className)}
          role="radiogroup"
          {...props}
        />
      </RadioGroupContext.Provider>
    )
  }
)
RadioGroup.displayName = "RadioGroup"

interface RadioGroupItemProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {
  value: string
}

const RadioGroupItem = React.forwardRef<HTMLInputElement, RadioGroupItemProps>(
  ({ className, value, disabled, ...props }, ref) => {
    const context = useRadioGroup()
    const isChecked = context?.value === value
    const isDisabled = disabled || false

    const handleChange = () => {
      if (!isDisabled) {
        context?.onValueChange?.(value)
      }
    }

    return (
      <div className="flex items-center space-x-2">
        <input
          ref={ref}
          type="radio"
          value={value}
          checked={isChecked}
          onChange={handleChange}
          disabled={isDisabled}
          name={context?.name}
          className="sr-only"
          {...props}
        />
        <button
          type="button"
          role="radio"
          aria-checked={isChecked}
          disabled={isDisabled}
          onClick={handleChange}
          className={cn(
            "relative flex h-5 w-5 items-center justify-center rounded-full border-2 border-foreground transition-colors",
            "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
            isDisabled && "opacity-50 cursor-not-allowed",
            !isDisabled && "cursor-pointer hover:bg-muted",
            className
          )}
        >
          {isChecked && (
            <div className="h-2.5 w-2.5 rounded-full bg-foreground" />
          )}
        </button>
      </div>
    )
  }
)
RadioGroupItem.displayName = "RadioGroupItem"

export {
  RadioGroup,
  RadioGroupItem,
}

JavaScript: Copy this code into components/ui/radio-group.jsx:

jsx
"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

const RadioGroupContext = React.createContext(undefined)

const useRadioGroup = () => {
  const context = React.useContext(RadioGroupContext)
  return context
}

const RadioGroup = React.forwardRef(
  ({ className, value: controlledValue, defaultValue, onValueChange, disabled, name, ...props }, ref) => {
    const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue)
    
    const isControlled = controlledValue !== undefined
    const value = isControlled ? controlledValue : uncontrolledValue

    const handleValueChange = React.useCallback((newValue) => {
      if (disabled) return
      if (!isControlled) {
        setUncontrolledValue(newValue)
      }
      onValueChange?.(newValue)
    }, [disabled, isControlled, onValueChange])

    const groupName = name || React.useId()

    return (
      <RadioGroupContext.Provider value={{ value, onValueChange: handleValueChange, name: groupName }}>
        <div
          ref={ref}
          className={cn("space-y-2", className)}
          role="radiogroup"
          {...props}
        />
      </RadioGroupContext.Provider>
    )
  }
)
RadioGroup.displayName = "RadioGroup"

const RadioGroupItem = React.forwardRef(
  ({ className, value, disabled, ...props }, ref) => {
    const context = useRadioGroup()
    const isChecked = context?.value === value
    const isDisabled = disabled || false

    const handleChange = () => {
      if (!isDisabled) {
        context?.onValueChange?.(value)
      }
    }

    return (
      <div className="flex items-center space-x-2">
        <input
          ref={ref}
          type="radio"
          value={value}
          checked={isChecked}
          onChange={handleChange}
          disabled={isDisabled}
          name={context?.name}
          className="sr-only"
          {...props}
        />
        <button
          type="button"
          role="radio"
          aria-checked={isChecked}
          disabled={isDisabled}
          onClick={handleChange}
          className={cn(
            "relative flex h-5 w-5 items-center justify-center rounded-full border-2 border-foreground transition-colors",
            "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
            isDisabled && "opacity-50 cursor-not-allowed",
            !isDisabled && "cursor-pointer hover:bg-muted",
            className
          )}
        >
          {isChecked && (
            <div className="h-2.5 w-2.5 rounded-full bg-foreground" />
          )}
        </button>
      </div>
    )
  }
)
RadioGroupItem.displayName = "RadioGroupItem"

export {
  RadioGroup,
  RadioGroupItem,
}

Usage

TypeScript:

tsx
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Label } from "@/components/ui/label"
import * as React from "react"

function MyComponent() {
  const [value, setValue] = React.useState("default")

  return (
    <RadioGroup value={value} onValueChange={setValue}>
      <div className="flex items-center space-x-2">
        <RadioGroupItem value="default" id="default" />
        <Label htmlFor="default">Default</Label>
      </div>
      <div className="flex items-center space-x-2">
        <RadioGroupItem value="comfortable" id="comfortable" />
        <Label htmlFor="comfortable">Comfortable</Label>
      </div>
    </RadioGroup>
  )
}

JavaScript:

jsx
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Label } from "@/components/ui/label"
import * as React from "react"

function MyComponent() {
  const [value, setValue] = React.useState("default")

  return (
    <RadioGroup value={value} onValueChange={setValue}>
      <div className="flex items-center space-x-2">
        <RadioGroupItem value="default" id="default" />
        <Label htmlFor="default">Default</Label>
      </div>
      <div className="flex items-center space-x-2">
        <RadioGroupItem value="comfortable" id="comfortable" />
        <Label htmlFor="comfortable">Comfortable</Label>
      </div>
    </RadioGroup>
  )
}

Make sure you also have the lib/utils.ts file with the cn helper function.

Examples

Default

Disabled