Cómo crear una grilla bento asimétrica en React
Una grilla bento en React renderiza tarjetas dentro de un CSS Grid con columnas extendidas variables. Los staggered scroll reveals de Framer Motion animan cada tarjeta cuando entra en el viewport, mientras que un tipo de tarjeta dual (text o visual) controla si un icono Lucide ocupa el fondo de la tarjeta.
- Stack: React 18 + Framer Motion 11 + Lucide React + Tailwind v4, ~90 líneas, cero dependencias adicionales.
- Grid: 3 columnas a partir de md, filas automáticas de 180px, col-span arbitrario mediante una prop string.
- Accesible: las tarjetas usan títulos h3 semánticos y el placeholder del icono es decorativo (opacity-20, sin aria label).
- Theming: todos los colores son propiedades CSS custom (--color-background, --color-accent, --color-border), sin valores fijos en el código.
- Móvil: colapsa a una sola columna por debajo de md, las alturas de fila fijas pueden necesitar ajustes para textos largos en pantallas pequeñas.
Bento Text Image es una grilla de funcionalidades editorial que alterna grandes tarjetas de texto con tarjetas visuales ilustradas con iconos, en un layout asimétrico de 3 columnas. Cada tarjeta se anima al entrar en el viewport mediante un staggered reveal de Framer Motion, dando ritmo a la sección sin necesidad de lógica de scroll personalizada. El resultado es una presentación premium estilo revista, ideal para funcionalidades SaaS, capacidades de agencia o momentos destacados de un portfolio.
Anatomía
La sección envuelve un header centrado (badge opcional, título h2, subtítulo) y un contenedor CSS Grid. Cada BentoItem tiene un id, un título, una descripción, un indicador de type ('text' o 'visual'), una clase Tailwind de span opcional (p. ej. 'col-span-2') y un nombre de icono Lucide opcional. Las tarjetas de tipo visual renderizan el icono a 64px con 20% de opacidad como fondo decorativo antes del bloque de texto. Las tarjetas text muestran solo el título y la descripción alineados en la parte inferior de la tarjeta.
Cómo funciona
Cada tarjeta es un motion.div con initial={{ opacity: 0, y: 24 }} y whileInView={{ opacity: 1, y: 0 }}. La opción viewport { once: true, margin: '-40px' } dispara la animación 40px antes de que la tarjeta entre por completo en pantalla, evitando una aparición brusca. Un delay de i * 0.1 segundos escalona las tarjetas de forma secuencial sin sobrecarga de orquestación. La resolución del icono usa una clave dinámica en el espacio de nombres Lucide (LucideIcons[name]) para que cualquiera de los más de 1400 iconos pueda referenciarse por string desde la capa de datos.
Cómo crearlo en React
Definir la interfaz BentoItem y el contenedor de grilla
Crea un type BentoItem con id, title, description, type ('text' | 'visual'), un string span opcional y un nombre de icono opcional. Renderiza un CSS Grid con 3 columnas en pantallas medium y filas automáticas de 180px. La prop span se mapea directamente a una clase Tailwind en cada tarjeta.
// types interface BentoItem { id: string; title: string; description: string; type: "text" | "visual"; span?: string; // e.g. "col-span-2 row-span-2" icon?: string; // Lucide icon name } // grid container <div className="grid grid-cols-1 md:grid-cols-3 auto-rows-[180px] gap-5"> {items.map((item, i) => ( <BentoCard key={item.id} item={item} index={i} /> ))} </div>Resolver los iconos Lucide por clave string
Importa el espacio de nombres Lucide completo y resuelve el componente del icono por nombre en el momento del render. Esto mantiene la capa de datos libre de imports React y permite controlar los iconos desde un CMS o un archivo JSON.
import * as LucideIcons from "lucide-react"; function getIcon(name?: string) { if (!name) return null; return (LucideIcons as unknown as Record<string, React.ElementType>)[name] || null; } // inside the card const Icon = getIcon(item.icon); {Icon && <Icon className="h-16 w-16" style={{ color: "var(--color-foreground)" }} />}Añadir scroll reveals escalonados con Framer Motion
Envuelve cada tarjeta en un motion.div. Ajusta el delay a i * 0.1 para que las tarjetas caigan en cascada de izquierda a derecha. El margin: '-40px' en viewport hace que la animación arranque ligeramente antes de que la tarjeta esté completamente en pantalla, para un efecto fluido en lugar de una aparición abrupta.
<motion.div initial={{ opacity: 0, y: 24 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true, margin: "-40px" }} transition={{ delay: i * 0.1, duration: 0.5 }} className={`rounded-2xl border p-6 flex flex-col justify-end ${item.span || "col-span-1"}`} >Usar CSS tokens para el theming
Aplica los colores exclusivamente mediante propiedades CSS custom para que la grilla respete cualquier preset de tema sin tocar el código. Las tarjetas visuales usan --color-background-alt para un tinte sutil; las tarjetas de texto usan --color-background-card. El badge y los bordes siguen --color-accent y --color-border.
// visual card style={{ backgroundColor: "var(--color-background-alt, var(--color-background-card))", borderColor: "var(--color-border)", }} // text card style={{ backgroundColor: "var(--color-background-card)", borderColor: "var(--color-border)", }}
Cuándo usarlo
Recurre a esta grilla cuando necesites presentar de 3 a 6 funcionalidades con pesos visuales variables, algunas requieren una descripción larga, otras solo un título y una pista gráfica. Funciona bien como bloque 'Qué hacemos' para agencias, una sección 'Capacidades clave' para SaaS, o un carrete de momentos destacados en un portfolio. Evítala cuando todos los elementos tengan la misma importancia (usa una grilla de tarjetas uniforme en su lugar) y cuando las tarjetas lleven controles interactivos que necesiten un dimensionado homogéneo.
Usado por
- Linear, Usa grillas de funcionalidades asimétricas con tamaños de tarjeta mixtos para mostrar las capacidades del producto en sus páginas de marketing.
- Vercel, Despliega bloques editoriales estilo bento para resaltar funcionalidades de infraestructura con densidades de tarjetas de texto y visuales variadas.
- Stripe, Combina tarjetas visuales acentuadas con iconos junto a tarjetas de texto completo en sus secciones de funcionalidades de producto para equilibrar la facilidad de lectura rápida con la profundidad.
- Loom, Usa grillas de funcionalidades con spans variados en su página de inicio para dar más espacio visual a los diferenciadores clave que a los puntos secundarios.
FAQ
¿Cómo hago que una tarjeta ocupe 2 columnas?
Pasa una clase Tailwind de span en el campo span del elemento, por ejemplo span: 'col-span-2'. El componente la aplica directamente al classname de la motion.div, así que cualquier clase Tailwind de span de grilla válida funciona, incluidas las variantes row-span.
¿Puedo reemplazar el icono Lucide por una imagen personalizada?
Sí. Cambia la resolución getIcon por un map de URL de imágenes y renderiza un img o Next.js Image dentro del contenedor flex-1. Mantén la clase opacity-20 o quítala según si quieres que la imagen se lea como decorativa o como principal.
¿Las alturas de fila fijas causan problemas de desbordamiento con textos largos?
Pueden hacerlo en móvil, donde una sola columna deja disponible todo el ancho de texto pero la altura de fila de 180px puede recortar las descripciones. Aumenta la altura de fila base, cambia a auto-rows-auto en pantallas pequeñas, o limita las descripciones a 120 caracteres en tus datos.
¿Cómo desactivo la animación de scroll para los usuarios que prefieren el movimiento reducido?
Condiciona las props transition e initial a una comprobación de la media query prefers-reduced-motion. Léela con un hook React (useReducedMotion de Framer Motion funciona directamente) y pasa valores estáticos cuando la preferencia esté activa.