PORTFOLIO_WEBSITE
A personal portfolio and blog built with Astro 6, a hand-rolled CSS design system, and MDX-powered content collections. Engineered for performance, typographic precision, and maintainability — no UI frameworks, no Tailwind.
Overview
Most portfolio sites are wrappers around a template. This one was built to be a design and engineering exercise in its own right — a chance to own every layout decision, every animation, and every token in the system rather than inheriting them from a component library.
The constraints were deliberate: no Tailwind, no UI framework, no pre-built component system. The design language had to come from a hand-written CSS token system, and every interactive behaviour had to be built from scratch with vanilla JS and the browser’s native APIs.
Architecture
Astro’s island architecture means zero framework JavaScript reaches the browser. Every page is pre-rendered HTML at build time. The only client-side JS is hand-written scripts for animations and interactivity — the logo scroll, theme toggle, contact form, filter chips, and scroll-reveal observer.
Design System
The entire visual language is encoded in CSS custom properties in global.css. There is no dependency on an external token library or preprocessor — just a :root block that defines every surface, text colour, spacing step, radius, easing curve, and duration used across the site.
Constraint: No colour, spacing, radius, or easing value is hardcoded anywhere in a component. Every value references a token. This keeps light/dark mode correct automatically — swapping the tokens in [data-theme=“light”] repaints the whole site.
Key token categories:
- Surfaces — six tonal steps from
--surfaceto--surface-container-highest, used to create depth without borders or shadows - Primary / Accent — a cyan family (
--primary,--primary-container,--primary-fixed-dim) used sparingly for CTAs, active states, and highlights - Type scale — 12 named steps from
--display-lgdown to--label-sm, covering every text use case - Spacing — a named scale (
--space-1through--space-20) rather than arbitrary pixel values - Motion — three named durations and a single
--ease-kineticcurve used for all transitions
Animations
A global IntersectionObserver in Layout.astro watches every element with a data-reveal attribute. When the element enters the viewport, it receives .is-visible, which transitions it from opacity: 0; translateY(20px) to its natural position. Stagger is controlled per-element via data-reveal-delay (milliseconds), applied as an inline transition-delay at the moment of intersection. Elements are unobserved after their first reveal.
A custom requestAnimationFrame loop advances the logo track by a configurable speed each frame. As a logo approaches the viewport centre it decelerates via a smoothstep function, pauses for ~0.9 s at the centre point, then re-accelerates. The centred logo also receives .logo—active, transitioning it from greyscale to full colour. The loop is cancelled on astro:before-swap and restarted on astro:page-load with fresh DOM references to avoid stale-closure bugs with View Transitions.
Project listing cards are desaturated by default using a mix-blend-mode: saturation overlay (background: white). On hover, the overlay fades out to reveal the image’s natural colour, while the underlying image scales up slightly via transform: scale(1.05). The image is clipped by its overflow: hidden container so the zoom stays contained.
Astro’s ClientRouter enables the View Transitions API across all navigations. Project and blog listing cards share a transition:name with the hero image and title on their respective detail pages, producing a morph animation on navigation rather than a full page flash.
Content Model
Content is managed through four Astro content collections, each with a Zod schema enforced at build time. Invalid frontmatter (wrong type, missing required field, out-of-range enum) fails the build rather than silently rendering broken pages.
The filter field on projects and blog posts is a strict enum — Simulation | Software | Modeling for projects, Simulation | Infrastructure | Software for posts. This field drives the client-side filter chips on each listing page. When a filter is applied, a three-phase animation runs: visible cards fade out, grid placement classes are recalculated based only on the remaining visible items (so a 1-item result gets a full-width layout, 2 items get equal halves, etc.), then cards stagger back in.
Key Technical Decisions
Why Astro over Next.js or SvelteKit? A portfolio is a content site, not an application. Astro’s output model — pre-rendered HTML, zero framework JS by default, islands for the bits that need interactivity — matches the problem exactly. Next.js and SvelteKit ship a client runtime on every page whether you need it or not.
Why no Tailwind? Utility classes solve repetition at the cost of owning the design system. A custom token system in CSS custom properties is more expressive for this use case: tokens cascade correctly through the DOM, work with calc(), compose with var(), and adapt to light/dark mode with a single selector override. There is no build step, no purge config, and no class-name vocabulary to memorise.
Why MDX? Project writeups need more than Markdown — stat grids, architecture diagrams, performance bars, and development timelines require structured layout that prose alone can’t express. MDX allows dropping into HTML blocks for those sections while keeping the rest of the content as plain Markdown. All layout-specific class names are styled globally by the detail page template, so content files stay clean.