Cómo crear un portfolio React con tarjetas apiladas al hacer scroll
Un portfolio React con tarjetas apiladas usa position:sticky con desplazamientos top incrementales por tarjeta, de modo que cada tarjeta se fija a una profundidad distinta a medida que el usuario hace scroll. useScroll y useTransform de Framer Motion animan el scale de 0.88 a 1 y la opacidad de 0.4 a 1 cuando cada tarjeta entra en el viewport, creando un efecto de mazo por capas.
- Stack: React 18 + Framer Motion 11 + Lucide React, ~190 líneas, cero dependencias adicionales.
- APIs clave: useScroll (target + offset), useTransform, position:sticky por tarjeta.
- Admite de 3 a 5 proyectos; más allá de 5, la profundidad de la pila se vuelve incómoda en pantallas pequeñas.
- Accesible: las tarjetas son un marcado estándar de tipo artículo; la animación es decorativa y no oculta el contenido.
- Responsive: la cuadrícula de 2 columnas dentro de cada tarjeta se colapsa de forma natural en viewports estrechos.
Portfolio Stacked Scroll es una sección React que presenta las tarjetas de proyecto como un mazo por capas: cada tarjeta se fija con position:sticky a un desplazamiento progresivamente más profundo, de modo que recorrer la lista se siente como levantar tarjetas de un montón. Los transforms de scale y opacidad de Framer Motion dan a cada tarjeta una aparición satisfactoria al entrar en vista. Para agencias y freelancers que quieren mostrar su trabajo sin una cuadrícula, este patrón comunica cuidado en el detalle sin necesitar un CMS personalizado.
Anatomía
La sección externa contiene un contenedor centrado con un bloque de encabezado (etiqueta superior + título H2) y la lista de tarjetas debajo. Cada tarjeta es un componente StackedCard que envuelve un motion.div dentro de un div sticky. El div sticky mantiene el desplazamiento top (base de 80px más 40px por índice de tarjeta) y un margen inferior de 20vh entre tarjetas salvo la última. Dentro del motion.div, una cuadrícula CSS de dos columnas coloca un placeholder de bloque de color 16:10 a la izquierda y los metadatos del proyecto (etiqueta de categoría, título, descripción, enlace CTA) a la derecha.
Cómo funciona
Cada StackedCard tiene una ref adjunta a su div sticky externo. useScroll de Framer Motion lee scrollYProgress para ese elemento con el offset ['start end', 'start start'], es decir 0 cuando la parte inferior de la tarjeta toca la parte inferior del viewport y 1 cuando la parte superior de la tarjeta se alinea con la parte superior del viewport. useTransform mapea esa progresión al scale (0.88 a 1) y a la opacidad (0.4 en 0, 0.8 en 0.5, 1 en 1). La transición y de whileInView (60px a 0, once:true) gestiona la animación de entrada inicial de forma independiente a la progresión del scroll. El desplazamiento top sticky aumenta 40px por índice de tarjeta, creando la profundidad visual del mazo sin cálculos de layout en JavaScript.
Cómo crearlo en React
Configurar cada tarjeta como contenedor sticky
Renderiza cada tarjeta dentro de un div con position:sticky y un valor top calculado. El desplazamiento base es 80px y cada tarjeta siguiente suma 40px, de modo que las tarjetas se superponen visualmente. Establece marginBottom a 20vh para todas las tarjetas salvo la última, así hay suficiente distancia de scroll para que la animación se reproduzca.
style={{ position: "sticky", top: `calc(80px + ${index * 40}px)`, marginBottom: index < total - 1 ? "20vh" : 0, zIndex: index, }}Rastrear la progresión de scroll por tarjeta con useScroll
Adjunta una ref al contenedor sticky y pásala como target de useScroll. La opción offset controla cuándo scrollYProgress empieza y termina: 'start end' se dispara cuando la parte inferior de la tarjeta entra en la parte inferior del viewport, y 'start start' cuando la parte superior de la tarjeta alcanza la parte superior. Esto da un rango completo de 0 a 1 por tarjeta.
const ref = useRef<HTMLDivElement>(null); const { scrollYProgress } = useScroll({ target: ref, offset: ["start end", "start start"], });Mapear la progresión al scale y a la opacidad
Usa useTransform para derivar un valor de scale (0.88 cuando la tarjeta aparece por primera vez, 1 cuando está plenamente visible) y un valor de opacidad con una curva de tres fotogramas clave para que la tarjeta se funda hacia arriba con suavidad. Aplica ambos al motion.div que envuelve el contenido de la tarjeta.
const scale = useTransform(scrollYProgress, [0, 1], [0.88, 1]); const opacity = useTransform(scrollYProgress, [0, 0.5, 1], [0.4, 0.8, 1]); <motion.div style={{ scale, opacity }} initial={{ y: 60 }} whileInView={{ y: 0 }} viewport={{ once: true }} transition={{ duration: 0.7, ease: [0.16, 1, 0.3, 1] }} >Construir el layout de la tarjeta con una cuadrícula de 2 columnas
Dentro del motion.div, usa una cuadrícula CSS con columnas 1.2fr 1fr y un gap de 2.5rem. La columna izquierda contiene el placeholder de imagen del proyecto (ratio 16:10, color de fondo proveniente de los datos), la columna derecha contiene la etiqueta de categoría, el título del proyecto, el párrafo de descripción y un enlace CTA. Todos los colores referencian propiedades CSS personalizadas de los tokens de tema para que el componente funcione en los 7 presets.
Cuándo usarlo
Este patrón encaja en portfolios de agencias, páginas de estudios de caso de freelancers y showcases de funcionalidades de producto donde mostrar de 3 a 5 elementos de forma secuencial crea impulso narrativo. La superposición sticky recompensa el scroll cuidadoso y transmite una ejecución premium. Evítalo cuando tengas más de 5 elementos (la pila se vuelve demasiado profunda) o cuando los usuarios necesiten comparar proyectos lado a lado. En móvil la pila sticky funciona, pero los márgenes de 20vh ocupan bastante espacio de pantalla, así que prueba en un dispositivo real antes de publicar.
Usado por
- Stripe, Usa secciones sticky apiladas en sus páginas de producto para recorrer revelaciones de funcionalidades secuenciales a medida que el usuario hace scroll.
- Lottiefiles, Emplea el apilamiento de tarjetas dirigido por el scroll para presentar los pasos de integración con profundidad y movimiento en sus landing pages.
- Pitch, Apila tarjetas de funcionalidades que se fijan y escalan al entrar en vista durante las secciones de scroll de su sitio de marketing.
FAQ
¿Por qué cada tarjeta necesita su propia ref de useScroll en lugar de un único listener de scroll global?
useScroll de Framer Motion con una ref de target rastrea la progresión de scroll relativa a ese elemento concreto, así cada tarjeta obtiene su propio rango de 0 a 1 ligado al momento en que entra y llena el viewport. Un único listener global daría el mismo número a todas las tarjetas y haría imposibles las animaciones independientes por tarjeta.
¿Cómo reemplazo el placeholder de bloque de color por una imagen real?
Cambia la div interna por un componente Next.js Image (o un img simple) con objectFit:cover dentro del mismo contenedor con ratio 16:10. Mantén overflow:hidden en el wrapper para que las esquinas redondeadas recorten la imagen correctamente. Pasa el src de la imagen a través de la interfaz Project junto al campo color existente.
Las tarjetas se solapan visualmente. ¿Cómo controlo el orden de apilamiento?
El zIndex se establece según el valor de índice de la tarjeta (0, 1, 2…) para que las tarjetas posteriores queden por encima de las anteriores. Si quieres que las tarjetas anteriores aparezcan por encima (un mazo invertido), establece zIndex a total - index.
¿Funciona la pila sticky dentro de una modal o un padre con overflow:hidden?
No. position:sticky deja de funcionar cuando algún ancestro tiene overflow:hidden o overflow:auto. Asegúrate de que el contenedor de scroll que impulsa el efecto sea el body del documento o un contenedor con overflow:visible a lo largo de todo el árbol.