Pila de tarjetas pegajosas
Cada tarjeta queda fija bajo la anterior mientras esta se encoge y se oscurece con el avance; solo sticky y una variable.
Etapa por etapa
Diagnóstico
Una conversación para ordenar el problema y priorizar.
Propuesta
Alcance, tiempos y precio, todo por escrito.
Desarrollo
Sprints cortos con entregas que puedes probar.
Lanzamiento
Puesta en producción, capacitación y soporte.
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"use client";
import { useEffect, useRef, type CSSProperties } from "react";
/** Pila de tarjetas que se apilan: cada tarjeta queda fija bajo la anterior mientras esta se encoge y se oscurece con el avance. Solo sticky más una variable de progreso. Autocontenida en su panel. React + TypeScript, sin dependencias. */
export type ScrollPilaTarjeta = { title: string; text: string; color: string };
export type ScrollPilaProps = {
cards?: ScrollPilaTarjeta[];
height?: number;
className?: string;
style?: CSSProperties;
};
const CSS = `
.gcspi-root { box-sizing: border-box; position: relative; width: 100%; height: var(--h, 460px); overflow-y: auto; overflow-x: hidden; background: #f3f1ec; color: #1c1a17; font: 400 16px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; scrollbar-width: thin; outline: 0; }
.gcspi-root *, .gcspi-root *::before, .gcspi-root *::after { box-sizing: border-box; }
.gcspi-root:focus-visible { box-shadow: inset 0 0 0 2px #1c1a17; }
.gcspi-pad { padding: 24px clamp(16px, 5vw, 48px) 60px; }
.gcspi-head { margin: 0 0 24px; font: 500 clamp(28px, 4vw, 44px)/1.05 "Newsreader", Georgia, ui-serif, serif; letter-spacing: -.03em; }
.gcspi-wrap { position: sticky; top: calc(14px + var(--i) * 18px); height: calc(var(--h, 460px) * .62); margin-bottom: 36px; }
.gcspi-card { height: 100%; display: grid; grid-template-columns: 1fr auto; align-items: end; gap: 18px; padding: clamp(18px, 3vw, 32px); border-radius: 24px; background: var(--c); transform-origin: 50% 0; transform: scale(var(--s, 1)); filter: brightness(var(--b, 1)); box-shadow: 0 16px 30px -18px rgba(0, 0, 0, .4); will-change: transform; }
.gcspi-card h3 { margin: 0 0 8px; font: 500 clamp(26px, 4vw, 44px)/1 "Newsreader", Georgia, ui-serif, serif; letter-spacing: -.03em; }
.gcspi-card p { margin: 0; max-width: 34ch; opacity: .78; }
.gcspi-n { font: 900 clamp(60px, 10vw, 120px)/.8 "Arial Black", ui-sans-serif, sans-serif; letter-spacing: -.06em; opacity: .16; }`;
const DEFAULT: ScrollPilaTarjeta[] = [
{ title: "Diagnóstico", text: "Una conversación para ordenar el problema y priorizar.", color: "#ffd9c7" },
{ title: "Propuesta", text: "Alcance, tiempos y precio, todo por escrito.", color: "#cfe6ff" },
{ title: "Desarrollo", text: "Sprints cortos con entregas que puedes probar.", color: "#d6f0cf" },
{ title: "Lanzamiento", text: "Puesta en producción, capacitación y soporte.", color: "#f3e0ff" },
];
export function ScrollPila({ cards = DEFAULT, height = 460, className = "", style }: ScrollPilaProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let raf = 0;
const update = () => {
raf = 0;
if (reduced) return;
const wraps = el.querySelectorAll<HTMLElement>(".gcspi-wrap");
wraps.forEach((wrap, idx) => {
const next = wraps[idx + 1];
const card = wrap.firstElementChild as HTMLElement;
if (!next) { card.style.removeProperty("--s"); return; }
const rect = next.getBoundingClientRect();
const box = el.getBoundingClientRect();
const dist = rect.top - box.top - (14 + (idx + 1) * 18);
const p = Math.max(0, Math.min(1, 1 - dist / (rect.height * 0.9)));
card.style.setProperty("--s", String(1 - p * 0.06));
card.style.setProperty("--b", String(1 - p * 0.12));
});
};
const onScroll = () => { if (!raf) raf = requestAnimationFrame(update); };
update();
el.addEventListener("scroll", onScroll, { passive: true });
return () => { el.removeEventListener("scroll", onScroll); cancelAnimationFrame(raf); };
}, [cards.length]);
return (
<div ref={ref} className={`gcspi-root ${className}`} style={{ "--h": `${height}px`, ...style } as CSSProperties} tabIndex={0} role="region" aria-label="Etapas apiladas">
<style>{CSS}</style>
<div className="gcspi-pad">
<h2 className="gcspi-head">Etapa por etapa</h2>
{cards.map((card, i) => (
<div key={card.title} className="gcspi-wrap" style={{ "--i": i } as CSSProperties}>
<article className="gcspi-card" style={{ "--c": card.color } as CSSProperties}>
<div>
<h3>{card.title}</h3>
<p>{card.text}</p>
</div>
<span className="gcspi-n" aria-hidden="true">{String(i + 1).padStart(2, "0")}</span>
</article>
</div>
))}
<div style={{ height: height * 0.4 }} aria-hidden="true" />
</div>
</div>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/scroll-pilaEjemplo de uso de Pila de tarjetas pegajosas
Después de copiar scroll-pila.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { ScrollPila } from "./scroll-pila";
export default function Ejemplo() {
return <ScrollPila />;
}Propiedades de ScrollPila
ScrollPila acepta 4 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.
| Propiedad | Tipo | Descripción |
|---|---|---|
cards? | ScrollPilaTarjeta[] | Opcional. |
height? | number | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
Personaliza Pila de tarjetas pegajosas 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.
| Variable | Valor por defecto |
|---|---|
--h | 460px |
--s | 1 |
--b | 1 |
Accesibilidad y rendimiento
El archivo tiene 85 líneas (5 KB) y no necesita @keyframes. Esto es lo que hace Pila de tarjetas pegajosas 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: region.
- Anima con requestAnimationFrame, sincronizado con el refresco de pantalla.
- Es un componente de cliente de Next.js: incluye la directiva "use client".