Cómo crear una bento grid animada en React con revelado al scroll
Una bento grid animada en React coloca 6 tarjetas en una grid CSS asimétrica y dispara la animación de entrada de cada tarjeta de forma independiente al entrar en pantalla mediante whileInView de Framer Motion. Cada celda recibe un estado inicial distinto (scale, desplazamiento x o y) y un retardo escalonado, de modo que la grid se ensambla progresivamente en lugar de hacerlo de golpe.
- Stack: React + Framer Motion 11 + CSS custom properties, ~140 líneas en BentoScrollReveal.tsx más ~145 líneas en BentoCells.tsx.
- API de animación: motion.div con whileInView/viewport (once: true), array CELL_ANIMATIONS por celda, stagger mediante delay: i * 0.07.
- Seis tipos de celda distintos: stat, chart (gráfico de barras animado), tags, highlight, quote, feature, todos gobernados por una interface BentoCell tipada.
- Estado hover: whileHover eleva cada tarjeta 4px y cambia el color del borde a --color-accent en 200ms.
- Móvil: la grid se reduce a 2 columnas mediante una regla @media inyectada; el contenido es HTML semántico simple, sin necesidad de aria roles adicionales.
Bento Scroll Reveal es una grid de features asimétrica de 6 celdas donde cada tarjeta entra en el viewport con su propia animación direccional: scale, deslizamiento desde la izquierda, desde la derecha o desde abajo. El timing escalonado convierte lo que podría ser un bloque de información estático en algo que se siente vivo. Funciona para secciones de features SaaS, casos de estudio de portfolio o cualquier lugar donde necesites mostrar una mezcla de métricas, prueba social y texto en un bloque compacto.
Anatomía
El componente es una estructura en dos partes. Un bloque de encabezado centrado (badge, h2, subtítulo) se anima primero como una sola unidad mediante whileInView. Debajo, una grid CSS con gridTemplateAreas define tres filas: la primera divide en 4+2 columnas, la segunda en 2+2+2 y la tercera en 3+3, dando a cada una de las seis áreas nombradas (de la a a la f) una huella distinta. Cada celda es un motion.div que se asigna a un área. El contenido interno de la celda se delega a BentoCells.tsx, que renderiza el subcomponente adecuado según el tipo de celda.
Cómo funciona
Un array estático CELL_ANIMATIONS contiene seis pares initial/animate predefinidos, uno por posición en la grid: la celda 0 escala desde 0.92, las celdas 1 y 5 se deslizan desde x: 30, la celda 2 desde x: -30, y las celdas 3 y 4 desde y: 30. Cada motion.div lee de este array por índice y pasa ambos estados directamente a whileInView. La prop viewport usa once: true para que las animaciones se disparen una sola vez y se queden fijas. El retardo se calcula como i * 0.07 segundos, de modo que las seis tarjetas se escalonan a lo largo de unos 420ms en total. La curva de easing [0.16, 1, 0.3, 1] es un cubic-bezier personalizado que acelera rápido y desacelera de forma brusca, dando a cada entrada un snap físico. Dentro de las celdas chart, las barras de MiniChart tienen su propio whileInView anidado con un stagger adicional (delay: 0.4 + i * 0.06), de modo que crecen después de que la propia tarjeta se haya asentado.
Cómo crearlo en React
Definir la grid asimétrica con gridTemplateAreas
Usa una grid CSS de 6 columnas y asigna áreas nombradas. Tres filas te dan un reparto 4-2, 2-2-2 y 3-3. Cada motion.div recibe un style={{ gridArea: area }} donde area es una de las letras de la a a la f.
gridTemplateColumns: "repeat(6, 1fr)", gridTemplateAreas: ` "a a a a b b" "c c d d b b" "e e e f f f" `,Construir la tabla de búsqueda CELL_ANIMATIONS
Declara el array fuera del componente para que nunca se recree en cada render. Cada entrada contiene objetos initial y animate indexados por posición. La variedad de direcciones (scale, x, y) es lo que da a la grid su ritmo visual al ensamblarse.
const CELL_ANIMATIONS = [ { initial: { opacity: 0, scale: 0.92 }, animate: { opacity: 1, scale: 1 } }, { initial: { opacity: 0, x: 30 }, animate: { opacity: 1, x: 0 } }, { initial: { opacity: 0, x: -30 }, animate: { opacity: 1, x: 0 } }, { initial: { opacity: 0, y: 30 }, animate: { opacity: 1, y: 0 } }, ];Conectar whileInView con retardos escalonados
Recorre las celdas, lee la config de animación por índice y calcula el retardo como i * 0.07. Pasa viewport={{ once: true, margin: '-50px' }} para que cada tarjeta se dispare un poco antes de entrar por completo en pantalla, manteniendo el revelado ágil en lugar de tardío.
<motion.div key={cell.id} initial={anim.initial} whileInView={anim.animate} viewport={{ once: true, margin: "-50px" }} transition={{ delay: i * 0.07, duration: 0.65, ease: [0.16, 1, 0.3, 1] }} whileHover={{ y: -4, borderColor: "var(--color-accent)", transition: { duration: 0.2, ease: "easeOut" } }} style={{ gridArea: area }} >Usar tokens CSS para el tema, nunca colores codificados a mano
Cada referencia de color usa var(--color-background-card), var(--color-accent), var(--color-border) y var(--color-foreground-muted). Esto permite que el componente funcione en los 7 presets de tema sin cambiar una sola prop. MiniChart tiñe las barras con color-mix(in srgb, var(--color-accent) N%, transparent) para derivar tonos más claros a partir del mismo token.
Cuándo usarlo
Recurre a este componente en el centro de una landing page, después del hero y antes del CTA, cuando necesites mostrar una mezcla de cifras, prueba social y texto de features en una sección que se capta de un vistazo. Encaja con dashboards SaaS, portfolios de agencias y páginas de marketing de producto. Evítalo cuando las celdas comparten tipos de contenido demasiado parecidos (seis tarjetas de features idénticas, por ejemplo), porque el layout asimétrico necesita contraste visual entre celdas para justificarse. Pásalo por alto también si necesitas más de seis celdas: por encima de esa cifra el stagger pierde su ritmo.
Usado por
- Stripe, Usa bento grids de contenido mixto en sus páginas de producto para mostrar métricas, fragmentos de código y highlights de features uno al lado del otro.
- Vercel, Grids de features asimétricas con reveals escalonados aparecen a lo largo de las páginas de marketing de la plataforma.
- Linear, Layouts de estilo bento que combinan tarjetas de stats, previsualizaciones de features y testimonials cortos en una sola sección de grid.
- Loom, Tarjetas de tamaños variados en la homepage muestran cifras de uso, features de equipo y prueba social en un solo bloque fácil de escanear.
FAQ
¿Por qué usar el whileInView de Framer Motion en lugar de animaciones CSS al scroll?
Las animaciones CSS al scroll requieren un IntersectionObserver para alternar una clase, y luego gestionas los retardos de stagger en CSS o JS por separado. El whileInView de Framer Motion gestiona el observer, los estados initial/animate y el retardo por elemento en un solo conjunto de props. Menos boilerplate, y cambiar la dirección o el timing de una celda no toca ninguna hoja de estilos.
¿Cómo cambio cuántas columnas abarca cada celda?
Edita la cadena gridTemplateAreas y el array GRID_AREAS correspondiente. Cada letra de la cadena corresponde a una celda por índice. Mantén cada área rectangular y conserva las letras de GRID_AREAS en el mismo orden que el array de celdas.
¿Puedo añadir más de 6 celdas?
El componente recorta las celdas a 6 con cells.slice(0, 6) y el gridTemplateAreas está codificado a mano para exactamente seis áreas nombradas. Para añadir más celdas, amplía tanto la cadena areas como el array CELL_ANIMATIONS. Por encima de 8 celdas, el retardo de stagger acumulado empieza a notarse lento en la carga inicial.
¿La animación al scroll se vuelve a reproducir al volver a subir?
No. viewport={{ once: true }} significa que cada celda se anima la primera vez que cruza el umbral de margen y permanece en su estado final. Quita el flag once si quieres que las tarjetas se reinicien y vuelvan a animarse en cada ciclo de scroll, aunque eso suele resultar excesivo en una sección de features.