Ir al contenido
Evaluar mi proyecto
Componentes · TarjetasDisponible

Pila de polaroids

Fotos de papel fotográfico apiladas en abanico; la elegida se endereza y sube al frente con sombra más profunda.

Código completo

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

tarjeta-polaroid.tsx62 líneas
Descargar
"use client";

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

/** Fotos polaroid apiladas: al elegir una se endereza y sube, las demás se abren en abanico. Estilo skeuomórfico de papel fotográfico. React + TypeScript, sin dependencias. */
export type PolaroidItem = { id: string; caption: string; hue: number };

export type TarjetaPolaroidProps = {
  items?: PolaroidItem[];
  className?: string;
  style?: CSSProperties;
};

const CSS = `
.gctpo-root { box-sizing: border-box; container-type: inline-size; display: block; width: 100%; max-width: 420px; font: 400 15px/1.2 "Caveat", "Segoe Script", "Bradley Hand", "Comic Sans MS", cursive; }
.gctpo-root *, .gctpo-root *::before, .gctpo-root *::after { box-sizing: border-box; }
.gctpo-root button { font: inherit; }
@media (prefers-reduced-motion: reduce) { .gctpo-root *, .gctpo-root *::before, .gctpo-root *::after { transition-duration: .01ms !important; } }
.gctpo-stack { position: relative; height: 330px; }
.gctpo-photo { position: absolute; left: 50%; top: 10px; width: 210px; padding: 12px 12px 8px; border: 0; background: #fbfaf5; text-align: center; cursor: pointer; color: #3a3631; box-shadow: 0 1px 0 #fff inset, 0 8px 18px -6px rgba(0, 0, 0, .45), 0 1px 3px rgba(0, 0, 0, .25); transform: translateX(-50%) translateX(calc(var(--s) * min(56px, 12cqi))) rotate(var(--r)) translateY(var(--y, 0)); transition: transform .55s cubic-bezier(.16, 1, .3, 1), box-shadow .3s; }
.gctpo-photo:focus-visible { outline: 3px solid #2563eb; outline-offset: 4px; }
.gctpo-photo[data-front="true"] { z-index: 5; --r: 0deg !important; --s: 0 !important; --y: -8px; box-shadow: 0 1px 0 #fff inset, 0 22px 30px -10px rgba(0, 0, 0, .55), 0 2px 4px rgba(0, 0, 0, .25); }
.gctpo-img { position: relative; display: block; aspect-ratio: 1; background: linear-gradient(160deg, hsl(var(--h) 70% 72%), hsl(calc(var(--h) + 40) 65% 42%)); box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .15), inset 0 6px 14px rgba(0, 0, 0, .25); overflow: hidden; }
.gctpo-img::before { content: ""; position: absolute; inset: 0; background: radial-gradient(circle at 30% 25%, rgba(255, 255, 255, .55), transparent 40%), radial-gradient(circle at 70% 85%, rgba(0, 0, 0, .3), transparent 55%); }
.gctpo-img::after { content: ""; position: absolute; left: -10%; right: -10%; bottom: -30%; height: 60%; border-radius: 50%; background: hsl(calc(var(--h) + 20) 50% 28% / .75); }
.gctpo-cap { display: block; padding: 10px 0 4px; font-size: 20px; }`;

const DEFAULT_ITEMS: PolaroidItem[] = [
  { id: "a", caption: "Primer prototipo", hue: 20 },
  { id: "b", caption: "Taller con el equipo", hue: 160 },
  { id: "c", caption: "Salida a producción", hue: 230 },
  { id: "d", caption: "Mes uno, todo en orden", hue: 320 },
];

export function TarjetaPolaroid({ items = DEFAULT_ITEMS, className = "", style }: TarjetaPolaroidProps) {
  const [front, setFront] = useState(items[items.length - 1]?.id);
  return (
    <div className={`gctpo-root ${className}`} style={style}>
      <style>{CSS}</style>
      <div className="gctpo-stack">
        {items.map((item, index) => {
          const spread = index - (items.length - 1) / 2;
          return (
            <button
              key={item.id}
              className="gctpo-photo"
              type="button"
              data-front={front === item.id ? "true" : undefined}
              aria-pressed={front === item.id}
              onClick={() => setFront(item.id)}
              style={{ "--s": spread, "--r": `${spread * 6 + (index % 2 ? 1.5 : -1.5)}deg`, "--h": item.hue, zIndex: front === item.id ? 5 : index } as CSSProperties}
            >
              <span className="gctpo-img" />
              <span className="gctpo-cap">{item.caption}</span>
            </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/tarjeta-polaroid

Ejemplo de uso de Pila de polaroids

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

ejemplo.tsx5 líneas
import { TarjetaPolaroid } from "./tarjeta-polaroid";

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

Propiedades de TarjetaPolaroid

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

PropiedadTipoDescripción
items?PolaroidItem[]Opcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

Personaliza Pila de polaroids con variables CSS

Define estas variables en el contenedor o en tu hoja de estilos para cambiar los colores sin tocar el componente. Si no las defines, se usa el valor por defecto.

VariableValor por defecto
--y0

Accesibilidad y rendimiento

El archivo tiene 62 líneas (4 KB) y no necesita @keyframes. Esto es lo que hace Pila de polaroids 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)