Ir al contenido
Evaluar mi proyecto
Componentes · FormulariosDisponible

Formulario de tarjeta con vista previa

Tarjeta skeuomórfica con chip y relieve que se llena en vivo y gira al capturar el CVC; formato automático de número y fecha.

Código completo

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

formulario-tarjeta.tsx103 líneas
Descargar
"use client";

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

/** Formulario de tarjeta con vista previa skeuomórfica en vivo: número agrupado, relieve estampado y giro al capturar el CVC. Datos de muestra, sin envío. React + TypeScript, sin dependencias. */
export type FormularioTarjetaProps = {
  className?: string;
  style?: CSSProperties;
};

const CSS = `
.gccct-root { box-sizing: border-box; display: grid; gap: 22px; width: 100%; max-width: 360px; color: #e9ebf2; font: 500 14px/1.3 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
.gccct-root *, .gccct-root *::before, .gccct-root *::after { box-sizing: border-box; }
.gccct-root input { font: inherit; }
@media (prefers-reduced-motion: reduce) { .gccct-root *, .gccct-root *::before, .gccct-root *::after { animation: none !important; transition-duration: .01ms !important; } }
.gccct-scene { perspective: 900px; }
.gccct-card { position: relative; aspect-ratio: 1.586; transform-style: preserve-3d; transition: transform .7s cubic-bezier(.16, 1, .3, 1); }
.gccct-card[data-back="true"] { transform: rotateY(180deg); }
.gccct-face { position: absolute; inset: 0; display: flex; flex-direction: column; justify-content: space-between; padding: 20px 22px; border-radius: 18px; backface-visibility: hidden; -webkit-backface-visibility: hidden; overflow: hidden; background: linear-gradient(135deg, #3b4a6b 0%, #1c2540 55%, #0d1226 100%); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .25), inset 0 -1px 0 rgba(0, 0, 0, .5), 0 22px 40px -14px rgba(0, 0, 0, .7); }
.gccct-face::before { content: ""; position: absolute; inset: 0; background: linear-gradient(115deg, transparent 30%, rgba(255, 255, 255, .13) 42%, transparent 54%); pointer-events: none; }
.gccct-back { transform: rotateY(180deg); padding: 0; }
.gccct-top { display: flex; justify-content: space-between; align-items: center; }
.gccct-chip { width: 44px; height: 34px; border-radius: 7px; background: linear-gradient(135deg, #f1d98a, #b8933a 50%, #f1d98a); box-shadow: inset 0 0 0 1px rgba(90, 60, 0, .4); position: relative; }
.gccct-chip::before { content: ""; position: absolute; inset: 7px 0; border-top: 1px solid rgba(90, 60, 0, .35); border-bottom: 1px solid rgba(90, 60, 0, .35); }
.gccct-chip::after { content: ""; position: absolute; inset: 0 14px; border-left: 1px solid rgba(90, 60, 0, .35); border-right: 1px solid rgba(90, 60, 0, .35); }
.gccct-brand { font: italic 800 20px/1 ui-serif, Georgia, serif; letter-spacing: -.02em; opacity: .9; }
.gccct-number { font: 600 21px/1 ui-monospace, "SF Mono", Menlo, monospace; letter-spacing: .08em; color: #eef1fa; text-shadow: 0 -1px 0 rgba(0, 0, 0, .6), 0 1px 0 rgba(255, 255, 255, .15); min-height: 21px; }
.gccct-meta { display: flex; justify-content: space-between; align-items: flex-end; gap: 16px; font: 600 13px/1.2 ui-monospace, "SF Mono", Menlo, monospace; letter-spacing: .08em; text-transform: uppercase; text-shadow: 0 -1px 0 rgba(0, 0, 0, .6); }
.gccct-meta small { display: block; font-size: 8px; opacity: .6; margin-bottom: 3px; letter-spacing: .12em; }
.gccct-stripe { height: 44px; margin-top: 26px; background: #05060a; }
.gccct-sig { display: flex; align-items: center; justify-content: flex-end; height: 38px; margin: 22px 22px 0; padding: 0 12px; border-radius: 4px; background: repeating-linear-gradient(135deg, #f4f1e8 0 6px, #e8e4d6 6px 12px); color: #222; font: italic 700 16px/1 ui-serif, Georgia, serif; }
.gccct-form { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.gccct-field { display: grid; gap: 6px; min-width: 0; }
.gccct-field[data-wide="true"] { grid-column: 1 / -1; }
.gccct-field label { font-size: 12px; font-weight: 600; color: #9aa1b8; letter-spacing: .03em; }
.gccct-input { width: 100%; height: 46px; padding: 0 14px; border: 1px solid #2d3550; border-radius: 10px; background: #131a30; color: inherit; font-variant-numeric: tabular-nums; transition: border-color .15s, box-shadow .15s; }
.gccct-input::placeholder { color: #5b6485; }
.gccct-input:focus-visible { outline: 0; border-color: #7b8cff; box-shadow: 0 0 0 3px rgba(123, 140, 255, .25); }`;

function group(raw: string) {
  return raw.replace(/\D/g, "").slice(0, 16).replace(/(.{4})/g, "$1 ").trim();
}

export function FormularioTarjeta({ className = "", style }: FormularioTarjetaProps) {
  const id = useId();
  const [number, setNumber] = useState("");
  const [name, setName] = useState("");
  const [exp, setExp] = useState("");
  const [cvc, setCvc] = useState("");
  const [back, setBack] = useState(false);
  const digits = number.replace(/\D/g, "");
  const display = (digits + "•".repeat(16)).slice(0, 16).replace(/(.{4})/g, "$1 ").trim();

  return (
    <div className={`gccct-root ${className}`} style={style}>
      <style>{CSS}</style>
      <div className="gccct-scene" aria-hidden="true">
        <div className="gccct-card" data-back={back ? "true" : undefined}>
          <div className="gccct-face">
            <div className="gccct-top">
              <span className="gccct-chip" />
              <span className="gccct-brand">banco</span>
            </div>
            <div className="gccct-number">{display}</div>
            <div className="gccct-meta">
              <span>
                <small>Titular</small>
                {name || "Nombre Apellido"}
              </span>
              <span>
                <small>Vence</small>
                {exp || "MM/AA"}
              </span>
            </div>
          </div>
          <div className="gccct-face gccct-back">
            <div className="gccct-stripe" />
            <div className="gccct-sig">{cvc || "•••"}</div>
          </div>
        </div>
      </div>
      <form className="gccct-form" onSubmit={(event) => event.preventDefault()} autoComplete="off">
        <div className="gccct-field" data-wide="true">
          <label htmlFor={`${id}-n`}>Número de tarjeta</label>
          <input id={`${id}-n`} className="gccct-input" inputMode="numeric" placeholder="4242 4242 4242 4242" value={number} onFocus={() => setBack(false)} onChange={(event) => setNumber(group(event.target.value))} />
        </div>
        <div className="gccct-field" data-wide="true">
          <label htmlFor={`${id}-t`}>Titular</label>
          <input id={`${id}-t`} className="gccct-input" placeholder="Como aparece en la tarjeta" value={name} maxLength={26} onFocus={() => setBack(false)} onChange={(event) => setName(event.target.value)} />
        </div>
        <div className="gccct-field">
          <label htmlFor={`${id}-e`}>Vencimiento</label>
          <input id={`${id}-e`} className="gccct-input" inputMode="numeric" placeholder="MM/AA" value={exp} onFocus={() => setBack(false)} onChange={(event) => setExp(event.target.value.replace(/\D/g, "").slice(0, 4).replace(/^(\d{2})(\d)/, "$1/$2"))} />
        </div>
        <div className="gccct-field">
          <label htmlFor={`${id}-c`}>CVC</label>
          <input id={`${id}-c`} className="gccct-input" inputMode="numeric" placeholder="123" value={cvc} onFocus={() => setBack(true)} onBlur={() => setBack(false)} onChange={(event) => setCvc(event.target.value.replace(/\D/g, "").slice(0, 4))} />
        </div>
      </form>
    </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/formulario-tarjeta

Ejemplo de uso de Formulario de tarjeta con vista previa

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

ejemplo.tsx5 líneas
import { FormularioTarjeta } from "./formulario-tarjeta";

export default function Ejemplo() {
  return <FormularioTarjeta />;
}

Propiedades de FormularioTarjeta

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

PropiedadTipoDescripción
className?stringOpcional.
style?CSSPropertiesOpcional.

Accesibilidad y rendimiento

El archivo tiene 103 líneas (7 KB) y no necesita @keyframes. Esto es lo que hace Formulario de tarjeta con vista previa 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).
  • Expone su estado a tecnologías de apoyo con atributos aria-*.
  • 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)