Radio Group

A set of checkable buttons—known as radio buttons—where no more than one of the buttons can be checked at a time.
Airplane Mode
Installation

Install the following dependencies:

pnpm
npm
yarn
bun
yarn add @zag-js/radio-group @zag-js/react

This component is built directly on the Zag.js state machine (not a wrapper). No Portal or presence hook needed, the selected dot just toggles opacity, no exit animation. The hidden native input renders automatically inside RadioGroupItem. Copy and paste the following code into your project.

components/ui/radio-group.tsx
Expand

import { createContext, useContext, useId } from "react";
import { twMerge } from "tailwind-merge";
import { useMachine, normalizeProps } from "@zag-js/react";
import * as radioGroup from "@zag-js/radio-group";
import { Frame, parsePaths } from "@/components/ui/frame";

const RadioGroupContext = createContext<ReturnType<typeof radioGroup.connect> | null>(
  null
);
const RadioGroupItemContext = createContext<{ value: string } | null>(null);

function useRadioGroupContext() {
  const api = useContext(RadioGroupContext);
  if (!api) throw new Error("RadioGroup parts must be used within <RadioGroupRoot>");
  return api;
}

function useRadioGroupItemContext() {
  const item = useContext(RadioGroupItemContext);
  if (!item) throw new Error("RadioGroupItem parts must be used within <RadioGroupItem>");
  return item;
}

function RadioGroupRoot({
  children,
  className,
  ...rest
}: React.PropsWithChildren<Partial<radioGroup.Props> & { className?: string }>) {
  const service = useMachine(radioGroup.machine, { id: useId(), ...rest });
  const api = radioGroup.connect(service, normalizeProps);

  return (
    <RadioGroupContext.Provider value={api}>
      <div {...api.getRootProps()} className={twMerge(["flex flex-col gap-3", className])}>
        {children}
      </div>
    </RadioGroupContext.Provider>
  );
}

function RadioGroupLabel({
  children,
  className,
}: React.PropsWithChildren<{ className?: string }>) {
  const api = useRadioGroupContext();

  return (
    <div {...api.getLabelProps()} className={twMerge(["font-bold", className])}>
      {children}
    </div>
  );
}

function RadioGroupItem({
  children,
  className,
  value,
}: React.PropsWithChildren<{ className?: string; value: string }>) {
  const api = useRadioGroupContext();

  return (
    <RadioGroupItemContext.Provider value={{ value }}>
      <label
        {...api.getItemProps({ value })}
        className={twMerge(["flex gap-3.5 items-center cursor-pointer", className])}
      >
        {children}
        <input {...api.getItemHiddenInputProps({ value })} />
      </label>
    </RadioGroupItemContext.Provider>
  );
}

function RadioGroupItemText({
  children,
  className,
}: React.PropsWithChildren<{ className?: string }>) {
  const api = useRadioGroupContext();
  const { value } = useRadioGroupItemContext();

  return (
    <span {...api.getItemTextProps({ value })} className={twMerge(["order-2", className])}>
      {children}
    </span>
  );
}

function RadioGroupItemControl({ className }: { className?: string }) {
  const api = useRadioGroupContext();
  const { value } = useRadioGroupItemContext();

  return (
    <div
      {...api.getItemControlProps({ value })}
      className={twMerge([
        "group relative size-5 flex items-center justify-center data-[state=checked]:drop-shadow-[0_0px_20px_var(--color-primary)]",
        "[--color-frame-1-stroke:var(--color-primary)]/70",
        "[--color-frame-1-fill:var(--color-primary)]/10",
        className,
      ])}
    >
      <Frame
        paths={parsePaths(
          '[{"show":true,"style":{"strokeWidth":"1","stroke":"var(--color-frame-1-stroke)","fill":"var(--color-frame-1-fill)"},"path":[["M","50% - 28.125%","0"],["L","50% + 28.125%","0"],["L","100% + 0","50% - 28.125%"],["L","100% + 0","50% + 28.125%"],["L","50% + 28.125%","100% - 0"],["L","50% - 28.125%","100% + 0"],["L","0","50% + 28.125%"],["L","0","50% - 28.125%"],["L","50% - 28.125%","0"]]}]'
        )}
      />
      <div
        className={twMerge([
          "group-data-[state=checked]:opacity-100 opacity-0 relative size-3 transition-all duration-100",
          "[--color-frame-1-stroke:var(--color-primary)]",
          "[--color-frame-1-fill:var(--color-primary)]/30",
        ])}
      >
        <Frame
          paths={parsePaths(
            '[{"show":true,"style":{"strokeWidth":"1","stroke":"var(--color-frame-1-stroke)","fill":"var(--color-frame-1-fill)"},"path":[["M","50% - 28.125%","0"],["L","50% + 28.125%","0"],["L","100% + 0","50% - 28.125%"],["L","100% + 0","50% + 28.125%"],["L","50% + 28.125%","100% - 0"],["L","50% - 28.125%","100% + 0"],["L","0","50% + 28.125%"],["L","0","50% - 28.125%"],["L","50% - 28.125%","0"]]}]'
          )}
        />
      </div>
    </div>
  );
}

export {
  RadioGroupRoot,
  RadioGroupLabel,
  RadioGroupItem,
  RadioGroupItemText,
  RadioGroupItemControl,
};
 
Expand

Update the import paths to match your project setup.

Usage
Expand

import {
  RadioGroupRoot,
  RadioGroupLabel,
  RadioGroupItem,
  RadioGroupItemText,
  RadioGroupItemControl,
} from "@/components/ui/radio-group";
 
Expand
Expand

const items = [
  { id: "apple", label: "Apples" },
  { id: "orange", label: "Oranges" },
  { id: "mango", label: "Mangoes" },
  { id: "grape", label: "Grapes" },
];

<RadioGroupRoot defaultValue="apple">
  <RadioGroupLabel>Airplane Mode</RadioGroupLabel>
  {items.map((opt) => (
    <RadioGroupItem value={opt.id} key={opt.id}>
      <RadioGroupItemText>{opt.label}</RadioGroupItemText>
      <RadioGroupItemControl />
    </RadioGroupItem>
  ))}
</RadioGroupRoot>
 
Expand
Powered by synthetic caffeine · Deployed by Left4code · Signal traceable on GitHub.