Forms
Radio
Accessible radio and radio group built on react-aria-components, styled with the quebi design system. Selected state fills with brand teal; supports invalid and disabled states, descriptions, and grouping.
forminputradiochoiceinteractive
Default
A basic radio group with several options.
With descriptions
Each option pairs a label with a muted hint.
Invalid
An invalid radio group uses the red accent.
Disabled
Disable the whole group or a single option.
Controlled
Selected: a
Source
Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.
"use client"
import type { RadioGroupProps, RadioProps } from "react-aria-components"
import {
composeRenderProps,
RadioGroup as RadioGroupPrimitive,
Radio as RadioPrimitive,
} from "react-aria-components"
import { Label } from "@/components/field"
import { cn } from "@/lib/utils"
/**
* Radio — quebi design system
*
* Built on react-aria-components. An 18px circle with a cyan-tinted border;
* selected state fills with brand teal and shows a white center dot. Focus
* uses the quebi teal ring; invalid uses red.
*/
export function RadioGroup({ className, ...props }: RadioGroupProps) {
return (
<RadioGroupPrimitive
{...props}
data-slot="control"
className={composeRenderProps(className, (className) =>
cn(
"flex flex-col gap-3 **:data-[slot=label]:font-normal",
"has-[[slot=description]]:gap-6 has-[[slot=description]]:**:data-[slot=label]:font-medium",
className,
),
)}
/>
)
}
export function Radio({ className, children, ...props }: RadioProps) {
return (
<RadioPrimitive
{...props}
className={composeRenderProps(className, (className) =>
cn("group block disabled:opacity-50 disabled:cursor-not-allowed", className),
)}
>
{composeRenderProps(children, (children, { isSelected, isFocusVisible, isInvalid }) => {
const isStringChild = typeof children === "string"
const content = isStringChild ? <RadioLabel>{children}</RadioLabel> : children
return (
<div
className={cn(
"grid grid-cols-[1.125rem_1fr] gap-x-3 gap-y-1",
"*:data-[slot=indicator]:col-start-1 *:data-[slot=indicator]:row-start-1 *:data-[slot=indicator]:mt-0.5",
"*:data-[slot=label]:col-start-2 *:data-[slot=label]:row-start-1",
"*:[[slot=description]]:col-start-2 *:[[slot=description]]:row-start-2",
"has-[[slot=description]]:**:data-[slot=label]:font-medium",
)}
>
<span
data-slot="indicator"
className={cn(
"relative flex size-[18px] shrink-0 items-center justify-center rounded-full border bg-transparent",
"transition-colors duration-150",
"border-cyan-500/30",
"before:content-[''] before:size-2 before:rounded-full",
isSelected && "border-quebi-brand bg-quebi-brand before:bg-quebi-bg",
isFocusVisible &&
"ring-2 ring-quebi-brand/50 ring-offset-2 ring-offset-quebi-bg",
isInvalid && "border-red-500",
isInvalid && isSelected && "bg-red-500",
isInvalid && isFocusVisible && "ring-red-500/50",
)}
/>
{content}
</div>
)
})}
</RadioPrimitive>
)
}
export function RadioLabel(props: React.ComponentProps<typeof Label>) {
return <Label elementType="span" {...props} />
}