
Implementar un grid masonry con React
Una distribución masonry de momento no la tenemos de serie con css
, con lo que tenemos que o bien utilizar alguna librería, o bien hacer una solución manual
Aquí vamos a explorar la opciones manual con grid
La Técnica Masonry
Distribuir elementos de tipo masonry quiere decir sin ninguna cuadrícula concreta sino ir añadiendo elementos a las columnas más vacías e ir rellenándolas a medida
El efecto es el que consiguen sitios como pinterest
Y por qué no utilizar lo tradicional? Lo máximo que podemos hacer con css-grid
es esto

Y un masonry sería esto

Cómo lo hacemos? Con JavaScript
en formato React
y utilizando css-grid
Vamos allá
JavaScript con css-grid
Para implementar el masonry con el css-grid
se trata de tejer una malla con filas relativamente estrechas, y luego definir cada imagen para que ocupe un número determinado de imágenes
(inspirado en esta implementación)
De momento nos centramos en leer la altura de cada imagen
// importamos las librerías
import React, { useState, useLayoutEffect, useCallback } from 'react'
import styled from 'styled-components'
// construimos el array de imágenes y lo asignamos al estado
const useImages = num => {
const [items, setItems] = useState([]) // guardaremos las imágenes aquí
// con useLayoutEffect nos aseguramos que este código sólo lo ejecutamos una vez
// .. y justo antes de que el DOM esté listo
useLayoutEffect(() => {
// almacenamos en el estado una lista de 20 imágenes random
setItems(
Array.from({ length: num - 1 }, () => {
const rand = Math.random()
const r = rand > 0.1 ? parseInt(500 * rand) : parseInt(500 * 0.1)
return `https://picsum.photos/200/${r}`
})
)
}, [])
return [items]
}
// utilizamos este hook para calcular las dimensiones de cada imagen
const useClientRect = load => {
const [rect, setRect] = useState() // asignamos un estado para las dimensiones
// useCallback nos memoriza la función (no su valor)
const ref = useCallback(
el => {
if (el !== null) {
console.log(el.getBoundingClientRect())
setRect(el.getBoundingClientRect()) // nos devuelve las dimensiones del elemento
}
},
[load]
) // las dependencias incluyen la variable load
return [rect, ref]
}
export default () => {
const [images] = useImages(20) // crearemos 20 imágenes
return (
<Grid>
{images.map((el, i) => (
<Card key={i} num={i} src={el} />
))}
</Grid>
)
}
// Genera una imagen y un texto
const Card = ({ num, src }) => {
const [load, loaded] = useState({}) // un estado que cambiará cuando la imagen se cargue
const [rect, ref] = useClientRect(load)
return (
<CardDiv ref={ref}>
<Label>{num}</Label>
<Img src={src} alt="" onLoad={() => loaded({})} />
</CardDiv>
)
}
const Img = styled.img`
border: 1px solid #000;
min-height: 50px;
margin: 0;
padding: 0;
`
const Label = styled.div`
position: absolute;
color: #fff;
font-weight: 700;
font-size: 3em;
`
const CardDiv = styled.div`
box-sizing: content-box;
background: #eaeaea;
border-radius: 8px;
`
const Grid = styled.div`
display: grid;
grid-gap: 10px;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
`
Con éste código
- Hemos generado una lista de 20 imágenes con la función (un custom hook)
useImages
Este hook simplemente almacena en un useState
una lista que genera sólo una vez con useLayoutEffect
y que se trata de direcciones distintas de picsum.photos
- Con
useClientRect
recogemos las dimensiones de las imágenes que se actualizan una vez se cargan
Utilizamos una función como referencia, esa función la memoizamos con useCallback
, y recalculamos la función cuando la imagen se carga
Esto último lo hacemos con un estado load
loaded
al cual le asignamos un nuevo objeto {}
simplemente para cambiarle el estado y forzar la actualización
De momento no hacemos nada excepto imprimir el valor en la consola
Ahora se trata de utilizar esas dimensiones (sólo nos interesa el height
) para asignar un número de filas a esa imagen
Esto lo haremos simplemente dividiendo el height
de la imagen por el valor que le damos a cada fila
Esa fila, cuanto más estrecha más precisión pero también más necesidad de cálculo, con lo que es un compromiso
Aquí la definimos de 20px
// importamos las librerías
import React, { useState, useLayoutEffect, useEffect, useCallback } from 'react'
import styled from 'styled-components'
// construimos el array de imágenes y lo asignamos al estado
const useImages = num => {
const [items, setItems] = useState([]) // guardaremos las imágenes aquí
// con useLayoutEffect nos aseguramos que este código sólo lo ejecutamos una vez
// .. y justo antes de que el DOM esté listo
useLayoutEffect(() => {
// almacenamos en el estado una lista de 20 imágenes random
setItems(
Array.from({ length: num - 1 }, () => {
const rand = Math.random()
const r = rand > 0.1 ? parseInt(500 * rand) : parseInt(500 * 0.1)
return `https://picsum.photos/200/${r}`
})
)
}, [])
return [items]
}
// utilizamos este hook para calcular las dimensiones de cada imagen
const useClientRect = load => {
const [rect, setRect] = useState() // asignamos un estado para las dimensiones
// useCallback nos memoriza la función (no su valor)
const ref = useCallback(
el => {
if (el !== null) setRect(el.getBoundingClientRect()) // nos devuelve las dimensiones del elemento
},
[load]
) // las dependencias incluyen la variable load
return [rect, ref]
}
export default () => {
const [images] = useImages(20) // crearemos 20 imágenes
const defaultGridRow = 20 // cada fila será de 20 píxeles
const columnWidth = 250 // cada fila la defino de 250 px
const imagesGap = 5 // Hay un espacio mínimo entre imágenes (vertical) de 5px
return (
<Grid row={defaultGridRow} col={columnWidth}>
{images.map((el, i) => (
<Card key={i} num={i} src={el} row={defaultGridRow} gap={imagesGap} />
))}
</Grid>
)
}
// Genera una imagen y un texto
const Card = ({ num, src, row, gap }) => {
const [load, loaded] = useState({}) // un estado que cambiará cuando la imagen se cargue
const [rect, ref] = useClientRect(load)
const [gridRows, setGridRows] = useState(1)
useEffect(() => {
rect && setGridRows(Math.ceil(rect.height / parseInt(row)))
}, [rect])
return (
<CardDiv span={gridRows}>
<div ref={ref}>
<Label>{num}</Label>
<Img src={src} alt="" onLoad={() => loaded({})} gap={gap}/>
</div>
</CardDiv>
)
}
const Img = styled.img`
border: 1px solid #000;
min-height: 50px;
margin: 0;
margin-bottom: ${props => props.gap}px;
padding: 0;
`
const Label = styled.div`
position: absolute;
color: #fff;
font-weight: 700;
font-size: 3em;
`
const CardDiv = styled.div`
box-sizing: content-box;
background: #eaeaea;
border-radius: 8px;
grid-row-end: span ${props => props.span};
`
const Grid = styled.div`
display: grid;
grid-gap: 0px; /* no puede haber gap entre filas */
grid-template-columns: ${props => `repeat(auto-fill, minmax(${props.col}px, 1fr))`};
grid-auto-rows: ${props => props.row}px;
`
Y ya lo tenemos
Con este código
- Una vez tenemos el
height
de las imágenes, calculamos cuántas filas ocuparía en el grid
Y este valor lo calculamos cada vez que se actualizan las dimensiones mediante un useEffect
, y almacenamos el valor en un useState
- Y pasamos ese valor al componente
css
para que lo incluya en ungrid-row-end: span X
El resto de cambios son para poder definir las variables de forma más cómoda en el código y que después estos se vayan al css
, y listos
El único detalle interesante es que he tenido que añadir un <div>
extra que antes no estaba entre la imagen y el <CardDiv>
Esto lo he tenido que hacer porque sino no podía medir su height
ya que este venía dado por el grid
Con un div
intermedio, este div es el que quedaba bloqueado, y la imagen bajo este podía expresar su height
con libertad
Por lo demás, sólo faltaría pulir la estética final para centrar los elementos
Por ejemplo, cambiando ligeramente los estilos
const Img = styled.img`
box-shadow: 1px 1px 1px #d2d2d2;
border-radius: 8px;
min-height: 50px;
margin: 0;
margin-bottom: ${props => props.gap}px;
`
const Label = styled.div`
position: absolute;
color: #fff;
font-weight: 700;
font-size: 3em;
margin: 0px 0px 0px 15px;
`
const CardDiv = styled.div`
box-sizing: content-box;
border-radius: 8px;
grid-row-end: span ${props => props.span};
justify-self: center;
`
const Grid = styled.div`
display: grid;
grid-gap: 0px; /* no puede haber gap entre filas */
grid-template-columns: ${props => `repeat(auto-fill, minmax(${props.col}px, 1fr))`};
grid-auto-rows: ${props => props.row}px;
`
Para terminar de esta manera

Otras opciones en formato librería?
Las tienes en esta entrada
🙋♂️
Lista de correo: escribo algo de tanto en cuanto