Ir al contenido principal

Devlog · Fase 0 · 6/7

Catppuccin y cristal esmerilado: el design system de aubia.dev

Dos temas Catppuccin, superficies translúcidas y tres fuentes definen aubia.dev. Los ajustes compartidos dan coherencia al sitio, con límites en el desenfoque y el movimiento.

Publicado el 17 de agosto de 2026Actualizado el 27 de septiembre de 202610 min de lectura
  • design-system
  • catppuccin
  • tailwind
  • motion

El fondo oscuro de aubia.dev no es negro. Es un gris azulado, #1e1e2e, sobre el que cuatro degradados radiales forman manchas de color malva, lavanda, melocotón y azul. Estas manchas permanecen fijas al desplazarse por la página. Las tarjetas y la barra de navegación dejan ver su color a través de un fondo translúcido.

Elegí Catppuccin para los temas del sitio. El malva aparece en los enlaces de los artículos y en los contornos del foco de teclado; las superficies, los títulos y las animaciones tienen sus propios ajustes compartidos. Un componente puede reutilizar una tarjeta o una animación de entrada sin redefinir cada color y cada duración.

Mocha y Latte, los mismos nombres para dos temas

La paleta oficial de Catppuccin incluye cuatro temas. El sitio utiliza dos: Mocha, el más oscuro, y Latte, el claro. Cada uno proporciona veintiséis colores con los mismos nombres. text es el color del texto, base el fondo principal; mauve y peach son tonos cuyo uso depende de la interfaz.

Estos colores se convierten en variables CSS con el prefijo --color-ctp-. Al declararlas en el bloque @theme de Tailwind, quedan disponibles clases como text-ctp-mauve y bg-ctp-base. La clase del componente sigue siendo la misma cuando cambia el tema. Su variable pasa, por ejemplo, del malva claro de Mocha, #cba6f7, al violeta más intenso de Latte, #8839ef.

Los veintiséis colores no bastan para describir todo el sitio. El cristal necesita un color de fondo, un contorno y reflejos; los degradados de los títulos tienen sus propias paradas de color. Estos ajustes completan la paleta en el CSS. Los acentos conservan su tono Catppuccin original en ambos temas, incluidos los títulos y el resaltado Phiki del blog. Sobre fondos claros, algunos quedan por debajo del umbral de contraste WCAG AA; el sitio asume esta elección visual.

El tema claro requiere algo más que intercambiar el texto y el fondo. En modo oscuro, las tarjetas utilizan el gris intenso mantle, con contornos blancos de baja opacidad. En modo claro, utilizan base, casi blanco, con un contorno oscuro. Las sombras externas de las superficies de cristal, invisibles en Mocha, se vuelven grises azuladas en Latte. La luz separa las superficies en un tema y la sombra en el otro.

Elegir el tema antes de que aparezca la página

En una primera visita con JavaScript activado, el sitio muestra Mocha, aunque el sistema prefiera el modo claro. El selector ofrece «Claro», «Oscuro» y «Sistema». Esta última opción elimina el atributo data-theme de la raíz HTML, de modo que la media query prefers-color-scheme puede aplicar el tema del sistema.

Un pequeño script en el <head> lee la preferencia guardada antes de que se cargue React. Si no hay un valor válido o no se puede acceder al almacenamiento local, establece data-theme="dark". El CSS incluye los valores de Latte bajo [data-theme='light'] y bajo la media query del modo sistema. El navegador puede elegir los colores adecuados sin esperar a que se hidrate la página.

Cuando usted cambia de tema, el selector aplica la elección al documento e intenta guardarla. Si el almacenamiento está bloqueado, el cambio funciona durante esa visita, pero puede no conservarse cuando vuelva. Sin JavaScript, el script inicial no se ejecuta: la ausencia del atributo deja que el CSS siga la preferencia del sistema.

El efecto de cristal de las tarjetas

La transparencia deja ver el fondo; el desenfoque atenúa sus detalles. En las tarjetas, estos dos ajustes se combinan con un borde y un reflejo interior:

@utility glass-card {
    background: color-mix(in oklch, var(--glass-bg-card) 62%, transparent);
    backdrop-filter: blur(40px) saturate(1.6) brightness(1.02);
    border: 1px solid color-mix(in oklch, var(--glass-stroke) 12%, transparent);
    box-shadow: inset 0 1px 0 color-mix(in oklch, var(--glass-highlight) 8%, transparent);
}

La mezcla con transparent da aquí al fondo una opacidad del 62 %. Colorea lo que aparece detrás de la tarjeta sin ocultarlo por completo. La propiedad backdrop-filter desenfoca el fondo y después aumenta su saturación y, ligeramente, su brillo. El texto de la tarjeta sigue nítido. Con un fondo totalmente opaco, los píxeles de detrás siguen existiendo, pero el fondo oculta el resultado del filtro.

El reflejo interior ilumina el borde superior y sigue siendo blanco de baja opacidad en ambos temas. El borde pasa de blanco a gris azulado en modo claro, independientemente del desenfoque. Separa la tarjeta de su fondo sin darle un contorno luminoso.

No todas las superficies utilizan la misma mezcla. Los botones internos del menú utilizan un 58 % de color de fondo, la navegación un 70 % y la variante glass-strong un 80 %. El radio de desenfoque va de 40 píxeles en las tarjetas a 80 en esta variante densa, utilizada alrededor del formulario de inscripción. La superficie del formulario oculta más decoración que los botones del menú.

El menú de idiomas conserva un fondo opaco

Superponer dos superficies desenfocadas no aplica dos veces el mismo efecto al fondo de la página. Un ancestro que ya tiene un backdrop-filter establece una backdrop root, un límite más allá del cual el filtro de un descendiente ya no puede obtener píxeles. will-change: opacity puede crear el mismo límite. El hijo no hereda el filtro; lo que cambia es el área disponible para su propio filtro.

El selector de idioma se encuentra en esta situación dentro de la navegación. Su panel utiliza por ello un fondo opaco del color de las tarjetas. Las etiquetas no dependen de un segundo desenfoque para distinguirse del contenido que se desplaza por detrás.

El panel es también un elemento HTML <details>, cuyos enlaces se renderizan incluso cuando está cerrado. Están presentes en el HTML inicial producido por el renderizado en el servidor. Se abre sin JavaScript; React añade el cierre al hacer clic fuera o al pulsar Escape.

Un portal de React podría trasladar un panel a otra parte del documento para evitar las restricciones de sus ancestros. Ese traslado no elimina los enlaces del documento. En cambio, un panel montado solo en el cliente no proporciona esos enlaces en la respuesta HTML inicial. El <details> utilizado aquí conserva tanto los enlaces renderizados en el servidor como la apertura nativa, sin portal.

Chakra Petch para los títulos, Geist para la lectura

Las formas angulosas de Chakra Petch aparecen en los títulos. Geist se utiliza para el cuerpo del texto y la interfaz, Geist Mono para el código. En un artículo como este, esa distribución distingue los subtítulos, los párrafos y los fragmentos de código sin exigir un cambio de color en cada transición.

Las tres fuentes están autoalojadas. El navegador las descarga desde el sitio, sin peticiones a Google Fonts ni a otro proveedor de fuentes. Los archivos proceden de los paquetes Fontsource instalados; el plugin de fuentes de laravel-vite-plugin prepara las declaraciones @font-face durante el build y después la directiva Blade @fonts las incluye en la página.

Las tres fuentes utilizan el provider local(), directamente desde sus paquetes. Chakra Petch carga un único WOFF2 latino, con grosor 600 y estilo normal, sin una variante WOFF redundante. Geist y Geist Mono son fuentes variables: su archivo WOFF2 abarca grosores de 100 a 900.

Chakra Petch y Geist se precargan en las páginas públicas. Geist Mono también se precarga en la página de inicio y el blog, pero no en las páginas legales: tres precargas de fuentes en el primer caso y dos en el segundo. El fragmento de configuración de Chakra Petch especifica qué debe ocurrir durante su descarga:

local('Chakra Petch', {
    variants: [{
        src: 'node_modules/@fontsource/chakra-petch/files/chakra-petch-latin-600-normal.woff2',
        weight: 600,
        style: 'normal',
    }],
    variable: '--font-display',
    display: 'swap',
    preload: [{ weight: 600, style: 'normal' }],
    fallbacks: ['system-ui', 'sans-serif'],
}),

Con swap, el navegador puede mostrar el texto con una fuente alternativa antes de recibir la prevista. El plugin ajusta las métricas de las fuentes alternativas de Chakra Petch y Geist para aproximar sus dimensiones a las de las fuentes previstas. Estas aproximaciones reducen los posibles desplazamientos al cambiar de fuente, sin garantizar que no haya ningún cambio de posición en la página. Geist Mono conserva sus alternativas monoespaciadas: la generación de fuentes alternativas ajustadas está desactivada para esta familia.

Animaciones de entrada y secuencias animadas

Los títulos permanecen visibles en el HTML del servidor. El H1 es estático; el título de las preguntas frecuentes utiliza la variante text de HeadingReveal, con initial={false}. Cada palabra se desplaza 8 píxeles en 700 milisegundos, sin pérdida de opacidad ni desenfoque. Estos estados se definen en el componente:

const wordVariants = {
    hidden: { opacity: 1, y: 0 },
    visible: {
        opacity: 1,
        y: [8, 0],
        transition: { duration: 0.7, ease: EASE_LUMA },
    },
};

Cada palabra está dentro de un m.span con inline-block que recibe wordVariants, con un desfase predeterminado de 60 milisegundos entre palabras. La animación de entrada se ejecuta una sola vez, cuando la mitad del elemento observado es visible. La constante compartida EASE_LUMA vale [0.22, 0.61, 0.36, 1]: esta curva de Bézier controla la aceleración y la desaceleración. Los nodos de las ilustraciones utilizan animaciones de entrada con resortes, con rigidez y amortiguación.

Motion no lo controla todo. Los bucles CSS animan las ilustraciones y los temporizadores JavaScript hacen que el texto de la frase destacada cambie letra a letra. Las tarjetas de la sección Visión tienen una animación de entrada CSS vinculada al desplazamiento, que solo se activa si el navegador admite animation-timeline: view() y el visitante no ha solicitado movimiento reducido. En caso contrario, conservan su estado visible predeterminado.

Responder a la preferencia de movimiento reducido

El ajuste del sistema «Reducir movimiento» puede cambiar mientras una página está abierta. El hook local useSafeReducedMotion lee la media query correspondiente con useSyncExternalStore y se suscribe al evento change de matchMedia. React puede entonces volver a renderizar los componentes suscritos cuando cambia la preferencia.

Esta implementación local evita una limitación de Motion 13.2.0: su hook useReducedMotion inicializa un estado de React sin actualizarlo, aunque la documentación indica que responde a los cambios.

El servidor no conoce la preferencia del navegador. Por tanto, el hook local devuelve false para el renderizado en el servidor y la hidratación inicial, y después lee el valor real en el cliente. Este uso del snapshot del servidor en ambos casos es el comportamiento que React describe para useSyncExternalStore.

El CSS actúa sin esperar esa lectura: las reglas de movimiento reducido detienen el cursor parpadeante, la animación de entrada fade-up y los bucles de las ilustraciones a los que se aplican. Los confetis tienen su propia protección. Estas reglas no desactivan todas las transiciones del sitio.

El texto animado muestra su última frase en el renderizado del servidor. Mientras el movimiento reducido está activo, conserva esa frase y no programa temporizadores. Cambiar la preferencia durante la visita cancela el temporizador en curso; desactivar el movimiento reducido permite reanudar la secuencia. Las frases superpuestas, invisibles y excluidas de los lectores de pantalla, reservan la altura necesaria para la más larga. El contador diferido también dispone de un espacio reservado bajo el formulario.

Esta obra se cuenta aquí, artículo a artículo. Lo que viene después depende de lo que usted diga de ella.

Únase a la lista de espera