Devlog · Phase 0 · 6/7
Catppuccin and Frosted Glass: aubia.dev's Design System
Two Catppuccin themes, translucent surfaces and three fonts define aubia.dev. Shared settings keep the site consistent, with limits to how blur and motion behave.
- design-system
- catppuccin
- tailwind
- motion

The dark background on aubia.dev isn't black. It's a blue-tinted gray, #1e1e2e, overlaid with four radial gradients in pools of mauve, lavender, peach and blue. These pools stay fixed as you scroll. The cards and navigation bar let their color show through a translucent background.
I chose Catppuccin for the site's themes. Mauve appears in article links and keyboard focus outlines; surfaces, headings and animations have their own shared settings. A component can reuse a card or an entrance animation without redefining every color and duration.
Mocha and Latte, the Same Names for Two Themes
The official Catppuccin palette includes four themes. The site uses two: Mocha, the darkest, and Latte, the light one. Each provides twenty-six colors with the same names. text is the text color, base the main background; mauve and peach are hues whose use depends on the interface.
These colors become CSS variables prefixed with --color-ctp-. Declaring them in Tailwind's @theme block makes classes such as text-ctp-mauve and bg-ctp-base available. The class in the component stays the same when the theme changes. Its variable switches, for example, from Mocha's light mauve, #cba6f7, to Latte's deeper purple, #8839ef.
Those twenty-six colors don't describe the whole site. Glass needs a background color, an outline and highlights; heading gradients have their own color stops. These settings extend the palette in the CSS. Accents keep their original Catppuccin hue in both themes, including headings and the blog's Phiki highlighting. On light backgrounds, a few fall below the WCAG AA contrast threshold; the site accepts that visual choice.
The light theme needs more than a swap between text and background. In dark mode, cards use the deep gray mantle, with low-opacity white outlines. In light mode, they use the almost-white base, with a dark outline. The outer shadows on glass surfaces, invisible in Mocha, become blue-gray in Latte. Light separates the surfaces in one theme, shadow in the other.
Choosing the Theme Before the Page Appears
On a first visit with JavaScript enabled, the site displays Mocha, even if the system prefers light mode. The switcher offers "Light", "Dark" and "System". That last option removes the data-theme attribute from the HTML root, allowing the prefers-color-scheme media query to apply the system theme.
A small script in the <head> reads the saved preference before React loads. If there is no valid value, or local storage is inaccessible, it sets data-theme="dark". The CSS includes Latte's values under [data-theme='light'] and under the media query for system mode. The browser can choose the right colors without waiting for the page to hydrate.
When you change themes, the switcher applies the choice to the document and tries to save it. If storage is blocked, the change works for that visit, but may not persist when you return. Without JavaScript, the initial script doesn't run: the missing attribute lets the CSS follow the system.
The Cards' Glass Effect
Transparency lets the background show through; blur softens its detail. On the cards, these two settings are combined with a border and an inner highlight:
@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);
}
Mixing with transparent gives the background 62% opacity here. It tints what appears behind the card without hiding it completely. The backdrop-filter property blurs the backdrop, then increases its saturation and slightly raises its brightness. The card's text stays sharp. With a fully opaque background, the backdrop pixels still exist, but the background hides the filter's result.
The inner highlight brightens the top edge and remains low-opacity white in both themes. The border changes from white to blue-gray in light mode, independently of the blur. It separates the card from its background without giving it a glowing outline.
Not every surface uses the same mix. The menu's inner buttons use 58% background color, the navigation 70% and the glass-strong variant 80%. The blur radius ranges from 40 pixels for cards to 80 for this dense variant, used around the signup form. The form's surface hides more of the decoration than the menu buttons do.
The Language Menu Keeps a Solid Background
Stacking two blurred surfaces doesn't apply the same effect to the page background twice. An ancestor that already has a backdrop-filter establishes a backdrop root, a boundary beyond which a descendant's filter can no longer retrieve pixels. will-change: opacity can create the same boundary. The child doesn't inherit the filter; what changes is the area available to its own filter.
The language switcher encounters this situation inside the navigation. Its panel therefore uses an opaque background in the cards' color. The labels don't rely on a second blur to stay distinct from the content scrolling behind them.
The panel is also an HTML <details> element, whose links are rendered even when it is closed. They are present in the initial HTML produced by server-side rendering. It opens without JavaScript; React adds closing on an outside click or with Escape.
A React portal could move a panel elsewhere in the document to escape its ancestors. Moving it doesn't remove the links from the document. A panel mounted only on the client, however, doesn't provide those links in the initial HTML response. The <details> used here preserves both server-rendered links and native opening behavior, without a portal.
Chakra Petch for Headings, Geist for Reading
Chakra Petch's angular shapes appear in headings. Geist is used for body text and the interface, Geist Mono for code. In an article like this one, that division distinguishes subheadings, paragraphs and snippets without requiring a color change at every transition.
All three fonts are self-hosted. The browser downloads them from the site, without a request to Google Fonts or another font provider. The files come from the installed Fontsource packages; the fonts plugin in laravel-vite-plugin prepares the @font-face declarations at build time, then the Blade @fonts directive includes them in the page.
All three fonts use the local() provider, directly from their packages. Chakra Petch loads a single Latin WOFF2 at weight 600 in the normal style, without a redundant WOFF variant. Geist and Geist Mono are variable fonts: their WOFF2 file covers weights from 100 to 900.
Chakra Petch and Geist are preloaded on public pages. Geist Mono is also preloaded on the home page and blog, but not on legal pages: three font preloads in the first case, two in the second. The Chakra Petch configuration excerpt also specifies what should happen while it downloads:
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'],
}),
With swap, the browser can display text in a fallback font before receiving the intended font. The plugin adjusts the metrics of Chakra Petch's and Geist's fallbacks to bring their dimensions closer to the intended fonts. These approximations reduce possible movement when the font changes, without guaranteeing zero layout shift. Geist Mono keeps its monospace fallbacks: adjusted fallback generation is disabled for this family.
Entrance Animations and Animated Sequences
Headings remain visible in the server-rendered HTML. The H1 is static; the FAQ heading uses the text variant of HeadingReveal, with initial={false}. Each word moves 8 pixels over 700 milliseconds, without fading or blurring. These states are defined in the component:
const wordVariants = {
hidden: { opacity: 1, y: 0 },
visible: {
opacity: 1,
y: [8, 0],
transition: { duration: 0.7, ease: EASE_LUMA },
},
};
Each word is wrapped in an inline-block m.span that receives wordVariants, with a default stagger of 60 milliseconds between words. The entrance animation runs once, when half of the observed element is visible. The shared EASE_LUMA constant is [0.22, 0.61, 0.36, 1]: this Bézier curve controls acceleration and deceleration. Nodes in the illustrations use springs for their entrance animations, with stiffness and damping.
Motion doesn't control everything. CSS loops animate the illustrations, and JavaScript timers cycle through the tagline text letter by letter. The Vision section's cards have a CSS scroll-driven entrance animation, enabled only if the browser supports animation-timeline: view() and the visitor hasn't requested reduced motion. Otherwise, they retain their default visible state.
Responding to the Reduced-Motion Preference
The system's "Reduce motion" setting can change while a page is open. The local useSafeReducedMotion hook reads the corresponding media query with useSyncExternalStore and subscribes to matchMedia's change event. React can then re-render subscribed components when the preference changes.
This local implementation avoids a limitation in Motion 13.2.0: its useReducedMotion hook initializes React state without updating it, although the documentation says it responds to changes.
The server doesn't know the browser's preference. The local hook therefore returns false for server rendering and initial hydration, then reads the actual value on the client. This use of the server snapshot in both places is the behavior React describes for useSyncExternalStore.
CSS takes effect without waiting for that read: reduced-motion rules stop the blinking cursor, the fade-up entrance animation and the illustration loops they target. Confetti has its own safeguard. These rules don't disable every transition on the site.
The animated tagline displays its final phrase in the server render. While reduced motion is active, it keeps that phrase and schedules no timer. Changing the preference during a visit cancels the current timer; turning reduced motion off allows the sequence to resume. Overlapping invisible phrases, hidden from screen readers, reserve the height needed by the longest one. The deferred counter also has reserved space below the form.
This build is chronicled here, article after article. What comes next depends on what you have to say about it.
Join the waitlist