frontend-design
Technology Stack
Technology Stack
Layer: Framework Choice: Astro 5.x (static output) Why it matters: Zero JS shipped by default; pages are pure HTML/CSS until you opt in ──────────────────────────────────────── Layer: Styling Choice: Tailwind CSS v4 via @tailwindcss/vite Why it matters: Not the older @astrojs/tailwind integration. Tokens live in src/styles/global.css inside @theme {} ──────────────────────────────────────── Layer: Type safety Choice: TypeScript (strict) Why it matters: Every component has a typed Props interface ──────────────────────────────────────── Layer: Blog/content Choice: Astro Content Collections with glob() loader Why it matters: Markdown/MDX posts in src/content/blog/, schema in src/content.config.ts ──────────────────────────────────────── Layer: SEO Choice: @astrojs/sitemap, custom SEOHead.astro, src/utils/seo.ts Why it matters: JSON-LD schemas (Organization, BlogPosting, FAQ, Breadcrumb), OG tags, RSS ──────────────────────────────────────── Layer: Utility Choice: clsx + tailwind-merge wrapped as cn() in src/utils/merge.ts Why it matters: Every component that accepts a class prop uses this for conflict-free merging ──────────────────────────────────────── Layer: Deps that are NOT used Choice: No React, no anime.js, no astro-navbar Why it matters: Despite what DESIGN.md says (it describes the Zen Browser site this was adapted from). All interactivity is vanilla
Color System
All colors are CSS custom properties that flip between light and dark mode. Defined in global.css:
:root [data-theme=“dark”] —zen-paper: #f2f0e3 —zen-paper: #1f1f1f —zen-dark: #2e2e2e —zen-dark: #d1cfc0 —zen-muted: rgba(0,0,0,0.04) rgba(255,255,255,0.04) —zen-subtle: rgba(0,0,0,0.06) rgba(255,255,255,0.07)
These are mapped to Tailwind via @theme {}:
- paper / dark — the two “ink on paper” roles that invert in dark mode
- coral (#F76F53) — primary accent for CTAs, links, highlights
- zen-blue (#6287f5) — secondary accent
- zen-green (#63f78b) — tertiary accent / success
The overall feel is warm, not clinical. The light mode background is a parchment off-white, not pure white. Dark mode is a warm charcoal, not pure black.
Opacity pattern: Instead of defining dozens of gray shades, the site uses text-dark/50, text-dark/70, border-dark/[0.06], etc. — Tailwind’s opacity modifier on the base dark color. This is used everywhere for secondary text, borders, and muted surfaces.
Typography
Two fonts, each with a clear role:
- Bricolage Grotesque (body, everything) — variable sans-serif loaded via @fontsource at weights 400-700. Applied to body with font-weight: 500 as default, font-variation-settings: ‘width’ 100.
- Junicode (display headings only) — variable serif, self-hosted woff2 in public/fonts/. Two
variants: Roman and Italic. Applied via font-junicode class. The italic variant is used on accent
words inside
with the italic class, but importantly font-style: normal because the italic is baked into the separate font file. Swash features (swsh 1 on Roman, swsh 0 on Italic).
Heading sizes scale responsively: text-3xl sm:text-4xl lg:text-5xl is the most common pattern for section headings. The hero goes up to text-7xl on desktop.
Layout Architecture
Layout chain
Page -> PageLayout (Navbar + skip-to-content +
Blog posts use BlogLayout -> BaseLayout directly, skipping the Navbar/Footer.
Container pattern
No Tailwind container class. Instead, every section uses: mx-auto max-w-6xl px-4 sm:px-6 lg:px-8 The Navbar uses max-w-7xl for slightly wider reach.
Section pattern
Every landing page section follows this structure:
...
...
Key conventions:
- section-divider adds a subtle horizontal gradient line at the top (::before pseudo-element, 1px, fades from transparent to —zen-dark and back, 8% opacity)
- Section padding: py-12 lg:py-24 to py-16 lg:py-36 depending on importance
- Section headings: centered, tracking-tight, leading-[0.95]
- Subtext under headings: text-dark/60 (60% opacity of the base text color)
- Some sections have a colored pill/badge above the heading (e.g. Pricing uses rounded-full bg-coral/10 text-coral with text-[11px] uppercase tracking-[0.2em])
Dark Mode
Detection: A blocking inline
- Check localStorage.getItem(‘theme’)
- Fall back to prefers-color-scheme
- Default to ‘light’
- Set data-theme attribute on
Toggle: Second inline script attaches click handlers to #theme-toggle (desktop) and .theme-toggle-mobile buttons. Swaps the attribute, toggles a dark class, persists to localStorage, and updates sun/moon icon visibility.
CSS strategy: All theme-aware colors use the CSS custom properties. Borders and backgrounds use opacity modifiers. Some components have manual dark overrides like [data-theme=“dark”] .shadow-lg { —tw-shadow-color: rgba(0,0,0,0.4) }.
Animation System
Entirely CSS-based, triggered by a small IntersectionObserver in BaseLayout:
Scroll-triggered animations
Elements get a data-animate attribute. The observer adds is-visible class when they enter viewport (threshold 5%, rootMargin -40px bottom), then unobserves. Three animation types:
┌────────────────────────┬───────────┬───────────────────────────────────────────────┐ │ Attribute │ Keyframes │ Effect │ ├────────────────────────┼───────────┼───────────────────────────────────────────────┤ │ data-animate (default) │ zenReveal │ blur(4px) + translateY(24px) + opacity 0 -> 1 │ ├────────────────────────┼───────────┼───────────────────────────────────────────────┤ │ data-animate=“fade” │ zenFade │ blur(4px) + opacity 0 -> 1 │ ├────────────────────────┼───────────┼───────────────────────────────────────────────┤ │ data-animate=“scale” │ zenScale │ blur(4px) + scale(0.95) + opacity 0 -> 1 │ └────────────────────────┴───────────┴───────────────────────────────────────────────┘
Stagger via data-delay=“1” through data-delay=“6” (increments of 0.15s). All use cubic-bezier(0.25, 0.1, 0.25, 1) easing, 0.5s duration.
Hero entrance
Uses .hero-child class with a CSS custom property —hero-delay. Plays on page load (not scroll-triggered). Same blur+translate+opacity pattern with heroEntrance keyframes, 0.6s duration, delay = var(—hero-delay) * 0.15s + 0.1s.
Reduced motion
@media (prefers-reduced-motion: reduce) disables all animations, resets opacity/transform/filter to final values.
Component Patterns
Button (ui/Button.astro)
Polymorphic — renders if href is given,