Magic Borders

Un efecto que me encanta y que se ha vuelto muy popular ¡Magic Borders! 🥳

He visto este mismo efecto recreado de varias maneras diferentes y vamos a explorarlas todas aquí

Este efecto está pensado para verse en desktop usando un ratón. Si estás leyendo el artículo en un dispositivo móvil te recomiendo que lo veas desde un ordenador o portátil.
Magic
🥳
Borders

Las bases

Vamos a empezar por lo básico: Creamos el grid y las celdas y por ahora simplemente le vamos a añadir algo de border-radius y un poquito de backdrop-filter

MagicBorders.tsx
export default function MagicBorders() {
const { ref, mousePos } = useMousePos()

return (
<div className="container">
{Array.from(Array(3).keys()).map((box) => (
<MagicBox key={el} mousePos={mousePos} className={styles.box} />
))}
</div>
)
}

Para cada celda vamos a necesitar 3 elementos: El primero será el wrapper que contenga todo, el segundo será el que nos dará el efecto de los bordes, y el tercero es donde residirá el contenido.

MagicBox.tsx
export default function MagicBox() {
return (
<div className="box">
<div />
<div>{children}</div>
</div>
)
}
magic-borders.css
.container {
width: 100%;
border-radius: 24px;
display: grid;
gap: 0.5rem;
padding: 0.5rem;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr));
}

.box {
aspect-ratio: 1/1;
border-radius: 16px;
backdrop-filter: blur(16px);
}

No parece gran cosa por ahora, pero tranquilo que vamos a ir añadiéndole más elementos poco a poco.

¡Aunque no lo parezca este componente es bastante complejo!

No solo necesitamos implementar CSS de manera inteligente, sino que además tenemos que lidiar con varios hooks clásicos de React como useRef o useCallback.

Por ahora vamos a terminar de estilar las celdas:

magic-borders.css

.box {
position: relative;
aspect-ratio: 1/1;
border-radius: 16px;
backdrop-filter: blur(16px);
&:nth-child(2) {
font-size: 4rem;
}
& > div:first-child {
position: absolute;
inset: 0;
border-radius: 15px;
background: radial-gradient(
250px circle at 0 0,
rgba(255, 255, 255, 0.8) 40%,
rgba(255, 255, 255, 0.2)
)
padding-box;
padding: 2px;
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask-composite: exclude;
z-index: 1;
pointer-events: none;
}
& > div:nth-child(2) {
position: absolute;
inset: 0;
border-radius: 14px;
background: rgba(0, 0, 0, 0.5);
display: grid;
place-items: center;
}
}

Uf, cada vez tiene peor pinta 😅

No te preocupes que al final va a quedar increíble y todo tendrá sentido. Ya te he dicho que era un pelín complicado.

En el primer child, como ya comentamos antes, vamos a mostrar el brillo animado de los bordes. Para ello generamos un radial-gradient. El obstáculo al que nos enfrentamos ahora es que ese gradient debería ir siguiendo el ratón, pero ahora mismo está fijo en la esquina superior izquierda.

¡No hay problema! Sólo tenemos que calcular la posición del ratón y usarla para actualizar la posición del gradient ¿No?

Eehhh... no exactamente.

import useMousePos from "./useMousePos"
import MagicBox from "./MagicBox.jsx"
import styles from "./MagicBorders.module.css"

export default function MagicBorders() {
const elements = ["Magic", "🥳", "Borders"]
const { ref, mousePos } = useMousePos()

return (
  <div
    ref={ref}
    className={styles.container}
  >
    {Array.from(Array(2).keys()).map((box) => (
      <MagicBox key={box} mousePos={mousePos} className={styles.box}/>
    ))}
  </div>
)
}

Cómo verás en el sandbox de arriba, el problema ahora es que la posición del gradient es la misma en todos los elementos 😰.

Mira, la mejor manera de aprender no es ni viendo tutoriales en YouTube, ni preguntándole a ChatGPT ni leyéndome a mí. La mejor manera de aprender a programar es PROGRAMANDO.

Toca arremangarse. Curiosea el código del sandbox durante unos minutos a ver si identificas el problema y después ¡Sigue leyendo!

¿Pero esto no era un efectillo fácil?

¡Eso pensaba yo también! Pero en el Frontend pocas cosas son tan sencillas como parecen 🤣.

El problema es que estamos trackeando la posición del mouse y mapeamos esa posición a la posición del gradient. Así que cuando el mouse está en la posición 0,0, el gradient se mueve a 0,0. Pero cada celda tiene su propio gradient, así que el gradient de todas las celdas se mueve a 0,0, esquina superior izquierda.

Lo que necesitamos entonces es calcular la posición relativa del mouse con respecto a cada una de las celdas.

🤯

Tranqui, es más fácil de lo que parece.

Yo lo he hecho con este trocito de código de aquí que utiliza getBoundingClientRect.

MagicBox.jsx
import { useEffect, useRef } from "react"

export default function MagicBox({ className, mousePos, children, ...rest }) {
const rectRef = useRef({
x: 0,
y: 0,
})

const ref = useRef(null)

useEffect(() => {
if (ref.current && mousePos) {
const currentRect = ref.current?.getBoundingClientRect()
rectRef.current = {
x: mousePos.x - currentRect.left,
y: mousePos.y - currentRect.top,
}
}
}, [mousePos])

const boxStyles: CustomStyles = {
"--mouse-x": `${rectRef.current.x}px`,
"--mouse-y": `${rectRef.current.y}px`,
}

return (
<div className={`${className}`} ref={ref} style={boxStyles} {...rest}>
<div />
<div>{children}</div>
</div>
)
}

¿Por qué useRef en vez de useState?

useState es un hook que rerenderiza el componente cada vez que cambia su valor. ¡Pero nosotros no queremos volver a renderizar nada! Solo queremos inferir de ese valor para cambiar un estilo determinado. Para este tipo de usos, useRef es espectacular porque nos permite mantener un valor en memoria sin necesidad de volver a renderizar todas las celdas cada vez que movemos el ratón.

Fijáos que ya no estamos usando directamente mousePos, sino que calculamos la posición relativa del mouse respecto a la celda con el código que tenemos dentro de useEffect, y añadimos mousePos al array de dependencias ya que queremos recalcular cada vez que se mueva el mouse.

useEffect(() => {
if (ref.current && mousePos) {
const currentRect = ref.current?.getBoundingClientRect()
rectRef.current = {
x: mousePos.x - currentRect.left,
y: mousePos.y - currentRect.top,
}
}
}, [mousePos])
import useMousePos from "./useMousePos"
import MagicBox from "./MagicBox.jsx"
import styles from "./MagicBorders.module.css"

export default function MagicBorders() {
const elements = ["Magic", "🥳", "Borders"]
const { ref, mousePos } = useMousePos()

return (
  <div
    ref={ref}
    className={styles.container}
  >
    {Array.from(Array(2).keys()).map((box) => (
      <MagicBox key={box} mousePos={mousePos} className={styles.box}/>
    ))}
  </div>
)
}

¡Voilá! El gradient ahora se posiciona correctamente para cada celda. ¡Esto ya va cogiendo forma! La parte de Javascript ya la tenemos. Ahora solamente nos falta terminar de estilar, ya que el gradient debería mostrarse únicamente en el borde, mientras dejamos el centro de la celda transparente.

Esto tiene su dificultad y aquí es donde las implementaciones que me he ido encontrando por ahí difieren bastante. Muchos directamente no añaden transparencia, simplemente incluyen un fondo para ocultar el gradient y listo.

Quedaría algo así:

¡No está mal! Y si el fondo fuese también de un color uniforme te diría que es un resultado excelente. ¿Pero y si queremos que las celdas tengan transparencia? Pues aquí la cosa se vuelve a complicar (de nuevo) porque si le añadimos transparencia...

Se descubre el pastel.

Al hacerlo transparente se puede ver el gradient entero que hay por detrás. ¿Entonces ahora qué hacemos? Pues os presento la solución:

mask y mask-composite

Estas dos propiedades nos permiten enmascarar (es decir, ocultar) parte de un elemento. Lo que vamos a hacer en este caso es aprovecharnos de las diferentes capas del Box Model. Vamos a crear un mask que oculte el content pero no el padding. Esto nos permitirá usar padding para poder elegir cómo de ancho queremos que sea nuestro borde.

magic-borders.css
.box {
position: relative;
aspect-ratio: 1/1;
border-radius: 16px;
backdrop-filter: blur(16px);
&:nth-child(2) {
font-size: 4rem;
}
& > div:first-child {
position: absolute;
inset: 0;
border-radius: 15px;
background: radial-gradient(
250px circle at var(--mouse-x) var(--mouse-y),
rgba(255, 255, 255, 0.8) 40%,
rgba(255, 255, 255, 0.2)
)
padding-box; //Especificamos que el radial-gradient que forma el borde incluya el padding-box
padding: 2px; //Declaramos el padding, que en la práctica será el ancho de nuestro borde.
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); //El gradient que usamos para enmascarar solo incluye el content-box
mask-composite: exclude; //Excluimos todo lo que quede dentro del mask para ocultarlo
z-index: 1;
pointer-events: none;
}
& > div:nth-child(2) {
position: absolute;
inset: 0;
border-radius: 14px;
background: rgba(0, 0, 0, 0.5);
display: grid;
place-items: center;
}
}

¡Y ahora sí que sí! Ya tenéis todas las piezas necesarias. Podéis, por ejemplo, incluir otro gradient para crear un efecto 'reflejo' y que solo sea visible en la celda sobre la que hacemos hover.

Tenéis un ejemplo completo en el sandbox de abajo. Destripad el código y veréis que una vez le pillas el truco es más fácil de lo que parece.

import useMousePos from "./useMousePos"
import MagicBox from "./MagicBox.jsx"
import styles from "./MagicBorders.module.css"

export default function MagicBorders() {
const { ref, mousePos } = useMousePos()

return (
  <div
    ref={ref}
    className={styles.container}
  >
    {Array.from(Array(2).keys()).map((el) => (
      <MagicBox key={el} mousePos={mousePos} className={styles.box}/>
    ))}
  </div>
)
}

Puf, menuda odisea. Y aún quedarían muchos más detalles por ver, como por ejemplo:

  • Uso de la Intersection Observer API para que el gradient sólo se mueva cuando el componente es visible.
  • Activar/Desactivar el gradient cuando alejamos el mouse.

Pero eso, por ahora, ya os lo dejo a vosotros 😉