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.
"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.
npx shadcn@latest add https://www.gassierconsulting.com/r/selector-tarjetasEjemplo 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.
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.
| Propiedad | Tipo | Descripción |
|---|---|---|
label? | string | Opcional. |
options? | SelectorTarjetasOpcion[] | Opcional. |
value? | string | Opcional. |
defaultValue? | string | Opcional. |
onChange? | (id: string) => void | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
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".