Ir al contenido
Evaluar mi proyecto
Componentes · ScrollDisponible

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.

scroll-pila.tsx85 líneas
Descargar
"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.

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

Ejemplo 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.

ejemplo.tsx5 líneas
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.

PropiedadTipoDescripción
cards?ScrollPilaTarjeta[]Opcional.
height?numberOpcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

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.

VariableValor por defecto
--h460px
--s1
--b1

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".
Licencia
MIT
Dependencias
React >=18
Compatibilidad
React 18 y 19 · Next.js (componente de cliente)