Saltar al contenido
ssntl/ui

Radio group

Radios nativos en una columna, y tarjetas de opción que muestran los mismos datos en el mismo orden.

Delivery
Choose a plan

Instalación

$ pnpm dlx shadcn@latest add @ssntl/radio-group

Uso

tsx
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

components/ui/radio-group.tsx
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,
}