Typedora UI
Components

TypedRadioGroup

A headless, type-safe radio group component with full TypeScript inference and customizable rendering.

Introduction

TypedRadioGroup is a headless, type-safe wrapper around the base RadioGroup component. It provides full TypeScript inference for option values while giving you complete control over rendering through render props.

Key features:

  • Full type-safety for string, number, and boolean values
  • Headless design with render props for complete customization
  • Accessible by default (keyboard navigation, ARIA)
  • Controlled and uncontrolled usage

Installation

npx shadcn@latest add https://typedora-ui.netlify.app/r/typed-radio-group.json
pnpm dlx shadcn@latest add https://typedora-ui.netlify.app/r/typed-radio-group.json
npx shadcn@latest add https://typedora-ui.netlify.app/r/typed-radio-group.json
bunx --bun shadcn@latest add https://typedora-ui.netlify.app/r/typed-radio-group.json

Usage

Basic Usage

basic-usage.tsx
import {  } from "@/components/typed-radio-group";

export function () {
  return (
    <>
      <
        ={[
          { : "Apple", : "apple" },
          { : "Banana", : "banana" },
          { : "Orange", : "orange" },
        ]}
        ={(value) => {
value: string
}} /> </> ); }

Number Values

number-values.tsx
import {  } from "@/components/typed-radio-group";

export function () {
  return (
    <>
      <
        ={[
          { : "Small", : 1 },
          { : "Medium", : 2 },
          { : "Large", : 3 },
        ]}
        ={(value) => {
value: number
}} /> </> ); }

Boolean Values

boolean-values.tsx
import {  } from "@/components/typed-radio-group";

export function () {
  return (
    <>
      <
        ={[
          { : "Yes", : true },
          { : "No", : false },
        ]}
        ={(value) => {
value: boolean
}} /> </> ); }

Card Style

card-style.tsx
import {  } from "lucide-react";
import {  } from "@/components/typed-radio-group";
import {  } from "@/lib/utils";

export function () {
  return (
    < ="not-prose">
      <
        ="free"
        ="grid grid-cols-3 gap-4"
        ={[
          { : "free", : "Free", : "Basic features" },
          { : "pro", : "Pro", : "Advanced features" },
          { : "team", : "Team", : "For teams" },
        ]}
        ={({ , , ,  }) => (
          <
            ={.}
            ={(
              "relative flex flex-col p-4 border rounded-lg cursor-pointer",
               ? "border-primary bg-primary/5" : "border-border",
            )}
          >
            < {...} ="sr-only" />
            { && (
              < ="absolute top-3 right-3 size-4 text-primary" />
            )}
            < ="grid grow gap-2">
              < ="font-semibold">{.}</>
              <
                ="text-muted-foreground text-xs"
                ={`${.}-description`}
              >
                {.}
              </>
            </>
          </>
        )}
      />
    </>
  );
}

Custom Keys (id/name)

By default, TypedRadioGroup uses value and label keys. You can customize this using valueKey and labelKey props:

custom-keys.tsx
"use client";

import {  } from "react";
import {  } from "@/components/typed-radio-group";
import {  } from "@/components/ui/label";

// Example data using id/name keys instead of value/label
const  = [
  {
    : "admin",
    : "Administrator",
    : "Full access to all features",
  },
  { : "editor", : "Editor", : "Can edit content" },
  { : "viewer", : "Viewer", : "Read-only access" },
] as ;

export function () {
  const [, ] = <(typeof )[number]["id"]>();

  return (
    < ="space-y-4">
      <>Select Role</>
      <
        ={}
        ="id"
        ="name"
        ={}
        ={}
        ={({ , , ,  }) => (
          <
            ={.}
            ="flex items-start gap-3 cursor-pointer p-3 rounded-lg border hover:bg-accent"
          >
            < {...} ="mt-1">
              < />
            </>
            < ="flex flex-col">
              < ="font-medium">{.}</>
              < ="text-sm text-muted-foreground">
                {.}
              </>
            </>
          </>
        )}
      />
      { && (
        < ="text-sm text-muted-foreground">
          Selected role: <>{}</>
        </>
      )}
    </>
  );
}

Disabled Options

disabled-options.tsx
import {  } from "@/components/typed-radio-group";

export function () {
  return (
    <>
      <
        ={[
          { : "Available", : "available" },
          { : "Coming Soon", : "coming-soon", : true },
          { : "Premium", : "premium" },
        ]}
      />
    </>
  );
}

Controlled Component

controlled.tsx
"use client";

import {  } from "react";
import {  } from "@/components/typed-radio-group";

export function () {
  const [, ] = <"apple" | "banana">("apple");

  return (
    < ="space-y-4">
      <
        ={}
        ={
          [
            { : "Apple", : "apple" },
            { : "Banana", : "banana" },
          ] as 
        }
        ={}
      />
      <>Selected: {}</>
    </>
  );
}

Selected: apple

API Reference

TypedRadioGroupProps

PropType
optionsarray
valueKey?TValueKey
labelKey?TLabelKey
value?ExtractOptionValue<TOption, TValueKey>
defaultValue?ExtractOptionValue<TOption, TValueKey>
onValueChange?function
renderItem?function
renderGroup?function
className?string
disabled?boolean
name?string
required?boolean
orientation?"vertical" | "horizontal"
loop?boolean

RadioOption

PropType
valueT
labelstring
disabled?boolean

RadioItemRenderProps

PropType
optionTOption
isSelectedboolean
isDisabledboolean
itemPropsobject
Itemfunction
Indicatorfunction

RadioGroupRenderProps

PropType
childrenReactNode

Keyboard Interactions

KeyDescription
TabMoves focus to the checked radio item or first item
SpaceChecks the focused radio item
ArrowDownMoves focus and checks the next item
ArrowRightMoves focus and checks the next item
ArrowUpMoves focus and checks the previous item
ArrowLeftMoves focus and checks the previous item

On this page