Radio group
Radios nativos en una columna, y tarjetas de opción que muestran los mismos datos en el mismo orden.
Instalación
$ pnpm dlx shadcn@latest add @ssntl/radio-groupUso
import { Radio, RadioCard, RadioCardDescription, RadioCardPrice, RadioCardTitle, RadioGroup, RadioGroupLegend, RadioLabel } from "@/components/ui/radio-group"
<RadioGroup>
<RadioGroupLegend>Entrega</RadioGroupLegend>
<RadioLabel><Radio name="entrega" value="tienda" defaultChecked /> Recoger en tienda</RadioLabel>
<RadioLabel><Radio name="entrega" value="ciudad" /> Entrega el mismo día</RadioLabel>
</RadioGroup>
<RadioCard name="plan" value="equipo">
<RadioCardTitle>Equipo</RadioCardTitle>
<RadioCardPrice amount="Q 199" period="/ mes" />
<RadioCardDescription>Productos y reportes ilimitados.</RadioCardDescription>
</RadioCard>- Opciones en una columna: apila radios y checkboxes en una sola columna, así la vista baja por un solo borde.
- Etiquetas largas: el radio queda junto a la primera línea; una etiqueta larga se envuelve bajo su propio texto, no bajo el control.
- Opciones comparables: cada RadioCard muestra los mismos datos en el mismo orden (nombre, precio, para qué sirve, límite).
- Un fieldset con legend nombra el grupo; los radios nativos dan una sola parada de tab y flechas entre opciones.
Código
import * as React from "react"
import { cn } from "@/lib/utils"
// Native radios in a fieldset, so a group is one tab stop with arrow keys
// and the legend names it for screen readers. Choices stack in one column:
// the eye runs down a single edge instead of zigzagging across a grid.
function RadioGroup({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="radio-group"
className={cn("grid min-w-0 gap-1", className)}
{...props}
/>
)
}
/** The group's question, in the heading role. */
function RadioGroupLegend({
className,
...props
}: React.ComponentProps<"legend">) {
return (
<legend
data-slot="radio-group-legend"
className={cn("mb-2 text-heading", className)}
{...props}
/>
)
}
function Radio({
className,
...props
}: Omit<React.ComponentProps<"input">, "type">) {
return (
<input
type="radio"
data-slot="radio"
className={cn(
"mt-0.5 size-6 shrink-0 cursor-pointer appearance-none rounded-full border border-input bg-card",
"transition-[border-color,border-width] duration-(--duration-fast) ease-out checked:border-7 checked:border-primary hover:border-foreground",
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring disabled:cursor-not-allowed",
className
)}
{...props}
/>
)
}
// Same row as CheckboxLabel: the whole 48px row is the target, the text is
// not selectable, and the radio sits beside the first line when it wraps.
function RadioLabel({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="radio-label"
className={cn(
"flex min-h-12 cursor-pointer items-start gap-3 py-2.5 text-body select-none has-disabled:cursor-not-allowed has-disabled:opacity-40",
className
)}
{...props}
/>
)
}
// A choice that needs more than a word: every card shows the same facts in
// the same order (name, price, what it is for, its limit), so options compare
// at a glance. The selected card gets a 2px ring, not just a color change.
function RadioCard({
className,
children,
...props
}: Omit<React.ComponentProps<"input">, "type" | "children"> & {
children: React.ReactNode
}) {
return (
<label
data-slot="radio-card"
className={cn(
"relative grid cursor-pointer content-start gap-1 rounded-lg bg-card p-6 text-left outline-1 -outline-offset-1 outline-border select-none",
"transition-[outline-color] duration-(--duration-fast) ease-out hover:outline-input",
"has-checked:outline-2 has-checked:-outline-offset-2 has-checked:outline-foreground",
"has-focus-visible:ring-2 has-focus-visible:ring-ring has-focus-visible:ring-offset-2 has-focus-visible:ring-offset-background",
"has-disabled:cursor-not-allowed has-disabled:opacity-40",
className
)}
>
<Radio className="absolute end-6 top-6 mt-0" {...props} />
{children}
</label>
)
}
function RadioCardTitle({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="radio-card-title"
className={cn("pe-10 text-label", className)}
{...props}
/>
)
}
/** The price: the value leads, the period steps back. */
function RadioCardPrice({
amount,
period,
className,
...props
}: React.ComponentProps<"p"> & { amount: string; period?: string }) {
return (
<p
data-slot="radio-card-price"
className={cn("mt-2 flex items-baseline gap-1", className)}
{...props}
>
<span className="text-title tabular-nums">{amount}</span>
{period && (
<span className="text-caption text-muted-foreground">{period}</span>
)}
</p>
)
}
function RadioCardDescription({
className,
...props
}: React.ComponentProps<"p">) {
return (
<p
data-slot="radio-card-description"
className={cn(
"text-caption text-pretty text-muted-foreground",
className
)}
{...props}
/>
)
}
export {
Radio,
RadioCard,
RadioCardDescription,
RadioCardPrice,
RadioCardTitle,
RadioGroup,
RadioGroupLegend,
RadioLabel,
}