shadimage
typedora-ui
componenttyped-radio-groupsource: ok · hit

TypedRadioGroup

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

npx shadcn add "https://typedora-ui.netlify.app/r/typed-radio-group.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light

Registry dependencies

radio-grouphttps://typedora-ui.netlify.app/r/typed-utils.json

Files1 · source cached 10/14/2026, 1:20:39 PM

registry/default/typed-radio-group/typed-radio-group.tsx
"use client";

import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { CircleIcon } from "lucide-react";
import * as React from "react";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import type { ExtractOption, ExtractOptionValue } from "@/lib/typed-utils";

// =============================================================================
// Internal Indicator Component (matches shadcn/ui style)
// =============================================================================

function RadioGroupIndicator(
  props: React.ComponentProps<typeof RadioGroupPrimitive.Indicator>,
) {
  return (
    <RadioGroupPrimitive.Indicator
      data-slot="radio-group-indicator"
      className="relative flex items-center justify-center"
      {...props}
    >
      <CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
    </RadioGroupPrimitive.Indicator>
  );
}

// =============================================================================
// Types
// =============================================================================

/**
 * Supported value types for TypedRadioGroup.
 * Values are serialized to strings internally but typed correctly externally.
 */
export type RadioValueType = string | number | boolean;

/**
 * Default option type with value and label.
 * Use this as a reference for your custom option types.
 */
export type RadioOption<T extends RadioValueType = string> = {
  value: T;
  label: string;
  disabled?: boolean;
};

// =============================================================================
// Render Props Types
// =============================================================================

/**
 * Props passed to renderItem function.
 * @template TOption - The full option type including custom properties
 */
export type RadioItemRenderProps<TOption> = {
  /** The option data */
  option: TOption;
  /** Whether this option is currently selected */
  isSelected: boolean;
  /** Whether this option is disabled */
  isDisabled: boolean;
  /** Props to spread on RadioGroupItem */
  itemProps: {
    value: string;
    id: string;
    disabled?: boolean;
  };
  /** Default item component */
  Item: typeof RadioGroupItem;
  /** Default indicator component */
  Indicator: typeof RadioGroupIndicator;
};

/**
 * Props passed to renderGroup function.
 * Used to customize the layout wrapper around radio items.
 */
export type RadioGroupRenderProps = {
  /** Rendered items to be placed inside your custom wrapper */
  children: React.ReactNode;
};

/**
 * Core props for TypedRadioGroup (for documentation).
 * The actual component also accepts all RadioGroup props (className, disabled, etc.)
 */
export interface TypedRadioGroupBaseProps<
  TOptions extends readonly Record<string, unknown>[],
  TValueKey extends string = "value",
  TLabelKey extends string = "label",
> {
  /** Array of radio options */
  options: TOptions;
  /** The key to use for the option value (default: "value") */
  valueKey?: TValueKey;
  /** The key to use for the option label (default: "label") */
  labelKey?: TLabelKey;
  /** Controlled value */
  value?: ExtractOptionValue<TOptions, TValueKey>;
  /** Default value for uncontrolled usage */
  defaultValue?: ExtractOptionValue<TOptions, TValueKey>;
  /** Callback when value changes - receives the typed value */
  onValueChange?: (value: ExtractOptionValue<TOptions, TValueKey>) => void;
  /** Custom render for each item */
  renderItem?: (
    props: RadioItemRenderProps<ExtractOption<TOptions>>,
  ) => React.ReactNode;
  /**
   * Custom render for wrapping the items inside RadioGroup.
   * Use this for adding custom elements between items (e.g., dividers).
   * For simple layout changes, prefer using the `className` prop directly.
   */
  renderGroup?: (props: RadioGroupRenderProps) => React.ReactNode;
  /** CSS class name for the RadioGroup container */
  className?: string;
  /** Whether the entire radio group is disabled */
  disabled?: boolean;
  /** The name of the group (for form submission) */
  name?: string;
  /** Whether selection is required */
  required?: boolean;
  /** The orientation of the radio group */
  orientation?: "horizontal" | "vertical";
  /** When true, keyboard navigation will loop */
  loop?: boolean;
}

/**
 * Props for the TypedRadioGroup component.
 * @template TOptions - The options array type
 * @template TValueKey - The key used for the value property (default: "value")
 * @template TLabelKey - The key used for the label property (default: "label")
 */
export interface TypedRadioGroupProps<
  TOptions extends readonly Record<string, unknown>[],
  TValueKey extends string = "value",
  TLabelKey extends string = "label",
> extends Omit<
      React.ComponentProps<typeof RadioGroup>,
      "value" | "defaultValue" | "onValueChange" | "children" | "onValueChange"
    >,
    TypedRadioGroupBaseProps<TOptions, TValueKey, TLabelKey> {}

// =============================================================================
// Helpers
// =============================================================================

/**
 * Serialize a value to string for internal use with Radix RadioGroup
 */
function serializeValue<T extends RadioValueType>(value: T): string {
  if (typeof value === "boolean") {
    return value ? "__boolean_true__" : "__boolean_false__";
  }
  if (typeof value === "number") {
    return `__number_${value}__`;
  }
  return String(value);
}

/**
 * Deserialize a string value back to its original type
 */
function deserializeValue<TOption extends Record<string, unknown>>(
  serialized: string,
  options: readonly TOption[],
  valueKey: keyof TOption,
): TOption[keyof TOption] {
  const found = options.find(
    (opt) => serializeValue(opt[valueKey] as RadioValueType) === serialized,
  );
  return found?.[valueKey] as TOption[keyof TOption];
}

/**
 * Generate a unique ID for a radio item
 */
function generateItemId(index: number, groupId: string): string {
  return `${groupId}-item-${index}`;
}

// =============================================================================
// Default Render Functions
// =============================================================================

function createDefaultRenderItem<
  TOption extends Record<string, unknown>,
  TLabelKey extends keyof TOption,
>(labelKey: TLabelKey) {
  return function defaultRenderItem({
    option,
    itemProps,
    Item,
    Indicator,
  }: RadioItemRenderProps<TOption>): React.ReactNode {
    const label = option[labelKey];
    return (
      <label
        htmlFor={itemProps.id}
        className="flex items-center gap-2 cursor-pointer"
      >
        <Item {...itemProps}>
          <Indicator />
        </Item>
        <span>{String(label ?? "")}</span>
      </label>
    );
  };
}

function defaultRenderGroup({
  children,
}: RadioGroupRenderProps): React.ReactNode {
  return children;
}

// =============================================================================
// Component
// =============================================================================

/**
 * A headless, type-safe radio group component with full TypeScript inference
 * and customizable rendering via render props.
 *
 * @example
 * ```tsx
 * // Default usage with value/label keys
 * <TypedRadioGroup
 *   options={[
 *     { value: "apple", label: "Apple" },
 *     { value: "banana", label: "Banana", description: "Yellow fruit" },
 *   ] as const}
 *   renderItem={({ option, itemProps, Item, Indicator }) => (
 *     <label htmlFor={itemProps.id} className="flex items-center gap-2">
 *       <Item {...itemProps}>
 *         <Indicator />
 *       </Item>
 *       <div>
 *         <span>{option.label}</span>
 *         {option.description && <p>{option.description}</p>}
 *       </div>
 *     </label>
 *   )}
 *   onValueChange={(value) => {
 *     // value: "apple" | "banana"
 *   }}
 * />
 *
 * // Custom keys usage with id/name
 * <TypedRadioGroup
 *   options={[
 *     { id: 1, name: "John Doe" },
 *     { id: 2, name: "Jane Smith" },
 *   ] as const}
 *   valueKey="id"
 *   labelKey="name"
 *   onValueChange={(value) => {
 *     // value: 1 | 2
 *   }}
 * />
 * ```
 */
function TypedRadioGroup<
  TOptions extends readonly Record<string, unknown>[],
  TValueKey extends string = "value",
  TLabelKey extends string = "label",
>({
  options,
  valueKey = "value" as TValueKey,
  labelKey = "label" as TLabelKey,
  value,
  defaultValue,
  onValueChange,
  renderItem,
  renderGroup = defaultRenderGroup,
  ...props
}: TypedRadioGroupProps<TOptions, TValueKey, TLabelKey>) {
  // Extract the actual option type for internal use
  type TOption = ExtractOption<TOptions> & Record<string, unknown>;
  type TValue = ExtractOptionValue<TOptions, TValueKey>;

  // Generate a stable ID for this radio group instance
  const groupId = React.useId();

  // Track internal value for uncontrolled mode
  const [internalValue, setInternalValue] = React.useState<TValue | undefined>(
    defaultValue,
  );

  // Use controlled value if provided, otherwise use internal state
  const currentValue = value !== undefined ? value : internalValue;

  const handleValueChange = (serialized: string) => {
    const deserialized = deserializeValue(
      serialized,
      options as unknown as readonly TOption[],
      valueKey as keyof TOption,
    );

    // Update internal state for uncontrolled mode
    if (value === undefined) {
      setInternalValue(deserialized as TValue);
    }

    if (onValueChange) {
      onValueChange(deserialized as TValue);
    }
  };

  const serializedValue =
    currentValue !== undefined
      ? serializeValue(currentValue as RadioValueType)
      : undefined;
  const serializedDefaultValue =
    defaultValue !== undefined
      ? serializeValue(defaultValue as RadioValueType)
      : undefined;

  // Use provided renderItem or create default with labelKey
  const actualRenderItem =
    renderItem ??
    createDefaultRenderItem<TOption, keyof TOption>(labelKey as keyof TOption);

  // Render all items
  const renderItems = () => {
    return (options as unknown as readonly TOption[]).map((option, index) => {
      const optionValue = option[valueKey as keyof TOption] as RadioValueType;
      const serialized = serializeValue(optionValue);
      const isSelected = serializedValue === serialized;
      const itemId = generateItemId(index, groupId);
      const isDisabled = (option as { disabled?: boolean }).disabled ?? false;

      return (
        <React.Fragment key={serialized}>
          {actualRenderItem({
            option,
            isSelected,
            isDisabled,
            itemProps: {
              value: serialized,
              id: itemId,
              disabled: isDisabled,
            },
            Item: RadioGroupItem,
            Indicator: RadioGroupIndicator,
          })}
        </React.Fragment>
      );
    });
  };

  // Render the group content (items wrapped by renderGroup)
  const groupContent = renderGroup({
    children: renderItems(),
  });

  return (
    <RadioGroup
      value={serializedValue}
      defaultValue={serializedDefaultValue}
      onValueChange={handleValueChange}
      {...props}
    >
      {groupContent}
    </RadioGroup>
  );
}

export { TypedRadioGroup };

More from typedora-ui

Browse all components in this library →