Ir al contenido
Evaluar mi proyecto
Componentes · FormulariosDisponible

Selector de opciones en tarjetas

Radio group presentado como tarjetas de papel con sello al elegir; flechas del teclado mueven la selección.

¿Por dónde quieres empezar?

Código completo

Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.

selector-tarjetas.tsx95 líneas
Descargar
"use client";

import { useId, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from "react";

/** Selector de opciones como tarjetas (radio group con flechas), estilo editorial de papel con sello al elegir. React + TypeScript, sin dependencias. */
export type SelectorTarjetasOpcion = { id: string; title: string; detail: string; price?: string; icon?: ReactNode };

export type SelectorTarjetasProps = {
  label?: string;
  options?: SelectorTarjetasOpcion[];
  value?: string;
  defaultValue?: string;
  onChange?: (id: string) => void;
  className?: string;
  style?: CSSProperties;
};

const CSS = `
.gcstj-root { box-sizing: border-box; display: block; width: 100%; max-width: 560px; color: #1c1a17; font: 400 15px/1.45 "Newsreader", Georgia, ui-serif, serif; }
.gcstj-root *, .gcstj-root *::before, .gcstj-root *::after { box-sizing: border-box; }
.gcstj-root button { font: inherit; }
@media (prefers-reduced-motion: reduce) { .gcstj-root *, .gcstj-root *::before, .gcstj-root *::after { animation: none !important; transition-duration: .01ms !important; } }
.gcstj-legend { margin: 0 0 14px; font-size: 20px; font-weight: 600; letter-spacing: -.01em; }
.gcstj-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; }
.gcstj-card { position: relative; display: grid; gap: 6px; align-content: start; min-height: 132px; padding: 16px 16px 14px; text-align: left; color: inherit; border: 1px solid #cfc7b8; border-radius: 4px; background: #fffdf8; cursor: pointer; box-shadow: 0 1px 0 #e6dfd0; transition: transform .25s cubic-bezier(.16, 1, .3, 1), box-shadow .25s, border-color .2s; }
.gcstj-card:hover { transform: translateY(-2px); box-shadow: 0 8px 14px -8px rgba(60, 40, 10, .35); }
.gcstj-card:focus-visible { outline: 2px solid #1c1a17; outline-offset: 3px; }
.gcstj-card[aria-checked="true"] { border-color: #1c1a17; box-shadow: 0 0 0 1px #1c1a17, 0 8px 14px -8px rgba(60, 40, 10, .4); background: #fff9e8; }
.gcstj-title { font-size: 18px; font-weight: 600; }
.gcstj-detail { font-size: 13.5px; color: #5d584d; }
.gcstj-price { margin-top: auto; font: 600 13px/1 ui-monospace, "SF Mono", Menlo, monospace; letter-spacing: .04em; }
.gcstj-stamp { position: absolute; top: 10px; right: 10px; display: grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; border: 1.5px solid #b3261e; color: #b3261e; transform: scale(.4) rotate(-30deg); opacity: 0; transition: transform .35s cubic-bezier(.34, 1.56, .64, 1), opacity .15s; }
.gcstj-card[aria-checked="true"] .gcstj-stamp { transform: scale(1) rotate(-8deg); opacity: 1; }`;

const DEFAULT_OPTIONS: SelectorTarjetasOpcion[] = [
  { id: "diagnostico", title: "Diagnóstico", detail: "Una sesión para ordenar el problema y priorizar.", price: "DESDE $2,500" },
  { id: "mvp", title: "MVP", detail: "Primera versión funcional en pocas semanas.", price: "DESDE $38,000" },
  { id: "sistema", title: "Sistema a medida", detail: "Desarrollo por etapas con acompañamiento.", price: "A COTIZAR" },
];

export function SelectorTarjetas({ label = "¿Por dónde quieres empezar?", options = DEFAULT_OPTIONS, value, defaultValue, onChange, className = "", style }: SelectorTarjetasProps) {
  const id = useId();
  const [inner, setInner] = useState(defaultValue ?? options[0]?.id);
  const current = value ?? inner;
  const refs = useRef<(HTMLButtonElement | null)[]>([]);

  function choose(next: string) {
    setInner(next);
    onChange?.(next);
  }
  function key(event: KeyboardEvent<HTMLButtonElement>, index: number) {
    const move = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 0;
    if (!move) return;
    event.preventDefault();
    const next = (index + move + options.length) % options.length;
    choose(options[next].id);
    refs.current[next]?.focus();
  }

  return (
    <div className={`gcstj-root ${className}`} style={style} role="radiogroup" aria-labelledby={`${id}-l`}>
      <style>{CSS}</style>
      <p className="gcstj-legend" id={`${id}-l`}>
        {label}
      </p>
      <div className="gcstj-grid">
        {options.map((option, index) => (
          <button
            key={option.id}
            ref={(node) => {
              refs.current[index] = node;
            }}
            className="gcstj-card"
            type="button"
            role="radio"
            aria-checked={current === option.id}
            tabIndex={current === option.id ? 0 : -1}
            onClick={() => choose(option.id)}
            onKeyDown={(event) => key(event, index)}
          >
            <span className="gcstj-stamp" aria-hidden="true">
              <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
                <path d="m5 12.5 4.5 4.5L19 7.5" />
              </svg>
            </span>
            <span className="gcstj-title">{option.title}</span>
            <span className="gcstj-detail">{option.detail}</span>
            {option.price ? <span className="gcstj-price">{option.price}</span> : null}
          </button>
        ))}
      </div>
    </div>
  );
}

Úsalo en tu proyecto

Instálalo con la CLI de shadcn.

terminal.sh1 líneas
npx shadcn@latest add https://www.gassierconsulting.com/r/selector-tarjetas

Ejemplo de uso de Selector de opciones en tarjetas

Después de copiar selector-tarjetas.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.

ejemplo.tsx5 líneas
import { SelectorTarjetas } from "./selector-tarjetas";

export default function Ejemplo() {
  return <SelectorTarjetas label="Ejemplo" />;
}

Propiedades de SelectorTarjetas

SelectorTarjetas acepta 7 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.

PropiedadTipoDescripción
label?stringOpcional.
options?SelectorTarjetasOpcion[]Opcional.
value?stringOpcional.
defaultValue?stringOpcional.
onChange?(id: string) => voidOpcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

Accesibilidad y rendimiento

El archivo tiene 95 líneas (5 KB) y no necesita @keyframes. Esto es lo que hace Selector de opciones en tarjetas por quien lo usa:

  • Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
  • Muestra un anillo de foco visible al navegar con teclado (:focus-visible).
  • Declara roles ARIA para lectores de pantalla: radiogroup, radio.
  • Responde al teclado, no solo al ratón.
  • Es un componente de cliente de Next.js: incluye la directiva "use client".
Licencia
MIT
Dependencias
React >=18
Compatibilidad
React 18 y 19 · Next.js (componente de cliente)