A Radix RadioGroup whose dot scales in from 0.5 with the house ease-out, plus a selectable bordered card variant.
npx shadcn@latest add @paragon/radio-group"use client";
import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { cn } from "@/lib/utils";
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-2.5", className)}
{...props}
/>
);
}
/**
* Standard radio. The dot is permanently mounted and scales in from 0.5 with
* the house ease-out, so rapid re-selection retargets instead of restarting.
*/
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"group relative aspect-square size-4 shrink-0 rounded-full border border-input bg-transparent",
"transition-[border-color,box-shadow] duration-150 ease-out",
"outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"data-[state=checked]:border-primary",
"disabled:cursor-not-allowed disabled:opacity-50",
// Extend the hit area to 40px without growing the visual.
"after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-x-1/2 after:-translate-y-1/2",
className,
)}
{...props}
>
<span
aria-hidden
className={cn(
"pointer-events-none absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary",
"scale-50 opacity-0 transition-[scale,opacity] duration-200 ease-out motion-reduce:transition-[opacity]",
"group-data-[state=checked]:scale-100 group-data-[state=checked]:opacity-100",
)}
/>
</RadioGroupPrimitive.Item>
);
}
/**
* Card variant: a selectable bordered card. Compose children freely; a radio
* dot renders in the top-right corner.
*/
function RadioGroupCard({
className,
children,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-card"
className={cn(
"group relative w-full rounded-xl border bg-card p-4 text-left",
"transition-[border-color,box-shadow,background-color] duration-150 ease-out",
"outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"data-[state=checked]:border-primary data-[state=checked]:ring-1 data-[state=checked]:ring-primary",
"disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<span
aria-hidden
className={cn(
"pointer-events-none absolute top-3.5 right-3.5 flex size-4 items-center justify-center rounded-full border border-input",
"transition-[border-color] duration-150 ease-out",
"group-data-[state=checked]:border-primary",
)}
>
<span
className={cn(
"size-2 rounded-full bg-primary",
"scale-50 opacity-0 transition-[scale,opacity] duration-200 ease-out motion-reduce:transition-[opacity]",
"group-data-[state=checked]:scale-100 group-data-[state=checked]:opacity-100",
)}
/>
</span>
<div className="pr-7">{children}</div>
</RadioGroupPrimitive.Item>
);
}
export { RadioGroup, RadioGroupItem, RadioGroupCard };