My Portfolio: Built with Astro, Tailwind, and a Whole Lot of Motion
How I built a fast, animated portfolio site with Astro Content Collections, View Transitions, and zero-framework-islands.
My Portfolio
I’ve been meaning to rebuild my portfolio site for a while. The old one was stuck in a holding pattern — functional, but not something that represented the kind of work I was doing. So when I decided to finally do it, I set three goals: make it fast, make it fun to interact with, and keep the content manageable without a CMS.
This write-up covers the tech stack, the decisions I made along the way, and a few patterns that turned out more interesting than I expected.
The Stack
The site is built with Astro as the core framework, Tailwind CSS v4 with DaisyUI for styling, and TypeScript everywhere. Content is managed through Astro’s Content Collections API backed by Zod schemas. For client-side animations, I leaned on Motion.js — it’s small, doesn’t require a framework, and fits well with Astro’s islands model.
No React, no Vue, no build-time framework rendering. Just static HTML with targeted JavaScript islands where they’re actually needed.
Content Collections as the Source of Truth
One of the best decisions was using Astro’s Content Collections for both blog posts and project entries. Instead of wrestling with a headless CMS or a database, everything lives as .mdx files in src/content/blog/ and src/content/projects/. At build time, each entry is validated against a Zod schema so broken content fails the build before it ships.
The schema for projects looks like this:
export const projectSchema = z.object({
title: z.string(),
description: z.string(),
coverImage: z.string().url().or(z.string().regex(/^\/.*$/)),
technologies: z.array(z.string()).default([]),
link: z.string().url().optional(),
github: z.string().url().optional(),
date: z.coerce.date().optional(),
tags: z.array(z.string()).optional().default([]),
});
Nothing fancy, but it catches typos, missing required fields, and malformed URLs at build time. I’ve been bitten by those kinds of issues in production before — a bad URL in a link or a missing cover image doesn’t get noticed until someone actually clicks through.
The collection loader uses Astro’s glob API, which means adding a new project is literally just dropping an .mdx file in the right folder and running the dev server.
View Transitions Between Cards and Detail Pages
When you click a project card on the homepage or the projects listing, the cover image animates into the detail page using the native View Transitions API. Astro handles the boilerplate with <ClientRouter /> in the base layout — the trick was just matching view-transition-name between the card thumbnail and the page header image.
<!-- Card thumbnail -->
<img
style="view-transition-name: project-cover-{project.id};"
src={project.coverImage}
alt={project.title}
/>
<!-- Detail page header -->
<img
style="view-transition-name: project-cover-{project.id};"
src={entry.data.coverImage}
alt={entry.data.title}
/>
Because both elements share the same view-transition-name, the browser smoothly morphs the image between the two layouts. The rest of the page transitions in the background. It’s a subtle effect, but it makes the site feel cohesive rather than like a series of disconnected page loads.
Animated Tag Filtering
Filtering projects and blog posts by tag should feel smooth — no flashing or jarring layout shifts. The approach was to animate cards on and off rather than just toggling display: none:
export function hideCard(card: Element): void {
if (!card.classList.contains('filter-hidden')) {
card.style.transition = 'opacity 0.2s, transform 0.2s';
card.style.opacity = '0';
card.style.transform = 'translateY(10px)';
card.addEventListener('transitionend', function handler() {
card.classList.add('filter-hidden');
card.style.display = 'none';
// Clean up styles
card.style.transition = '';
card.style.opacity = '';
card.style.transform = '';
card.removeEventListener('transitionend', handler);
});
}
}
The transitionend handler is key — we don’t set display: none until the fade-out animation is fully complete. Otherwise the browser skips the animation entirely. The reverse (showCard) does the same thing in the opposite direction.
Filter state itself lives in localStorage, keyed by row name (e.g. activeTag:Tech). This means if you filter to a specific technology and navigate to another page, the filter persists. The search component reads the same localStorage key so results respect the active filter context.
The Sliding Pill Tab Switcher
The homepage has a segmented control for switching between Projects and Blog views. The active tab indicator is a pill that slides between positions using Motion.js:
function updatePill(tab: HTMLElement) {
const rect = tab.getBoundingClientRect();
const parentRect = tab.parentElement?.getBoundingClientRect();
if (parentRect && pill) {
animate(
pill,
{ x: left, width: rect.width },
{ duration: 0.3, easing: [0.4, 0, 0.2, 1] }
);
}
}
The easing: [0.4, 0, 0.2, 1] is a spring-like curve borrowed from macOS — it gives the pill a subtle overshoot feel without being distracting. The content panels fade between each other with CSS transitions on opacity.
URL parameters are respected too — appending ?tab=blog from a blog detail page’s back-link switches to the blog tab automatically.
Client-Side Search with Flexsearch
Full-text search runs entirely in the browser using Flexsearch. The index is built at build time and served as a static JSON file. At runtime, the search input queries that index and displays results in a dropdown.
The search respects the active tag filter — if you’ve filtered projects to “Go” and then search for “CLI”, you only get Go projects that match, not every project on the site. This is achieved by reading the visibleSlugs key from localStorage and cross-referencing search results against it.
Theme Switching with Astro View Transitions
The site ships with five DaisyUI themes (nord, dim, dark, light, cupcake), with nord as the default and dim as the preferred-dark override. Toggling themes works by setting the data-theme attribute on the <html> element and persisting to localStorage.
The tricky part was handling View Transitions. When navigating between pages, the theme icon (sun/moon) needs to reflect the actual data-theme value on the target page — which might differ from the previous page if the user switched themes mid-session:
document.addEventListener("astro:page-load", syncThemeUI);
The astro:page-load event fires after every View Transition navigation, so the icon is always in sync. Without this, navigating from a page where you set “dim” to one that defaulted to “nord” would show the wrong icon until you manually clicked the toggle.
Hero Section: Typewriter + Parallax
The hero section does two animations:
- Typewriter — the title types out character by character at 70ms intervals
- Parallax fade — as you scroll past the hero, the content fades out and scales down from 1.0 to 0.85 over a 50px scroll range
The parallax uses requestAnimationFrame with a ticking guard to throttle scroll handlers to 60fps:
function onScroll() {
if (!ticking) {
requestAnimationFrame(() => {
applyParallax();
ticking = false;
});
ticking = true;
}
}
Both animations re-run on astro:page-load so they reset correctly after View Transition navigation (otherwise the typewriter would be mid-animation from the previous page).
Mobile Navigation
Mobile uses a checkbox-based drawer pattern — no JavaScript required for the open/close mechanics. The hidden <input type="checkbox"> toggles the overlay through CSS sibling selectors:
#nav-drawer-toggle:checked ~ #mobile-nav-overlay {
display: block;
}
JavaScript is only used for the nav links’ onclick to close the drawer after navigation. Search on mobile gets its own overlay drawer with the same pattern.
Lessons Learned
Astro’s islands model is genuinely the right default. I spent years thinking “I need React for interactivity” but the reality is that most of the interactivity on a portfolio site (filters, tabs, search, theme toggle) can be handled by small, framework-free scripts attached to specific elements. The result is a site with zero client-side framework overhead on the initial load.
View Transitions are worth the gotchas. The view-transition-name approach for the cover image morph worked beautifully, but only after I realized both elements needed identical names and the browser won’t animate if one element doesn’t exist yet. The <ClientRouter /> component in Astro handles the plumbing, but you still need to make sure your CSS transitions aren’t fighting the animation.
localStorage for state is simple but has edge cases. The filter state and theme persistence work well for a single device, but cross-device sync isn’t a thing (and it doesn’t need to be for this use case). The bigger issue was keeping the localStorage state in sync with component state during View Transitions — a navigation could change which filters were available, but the stored filter might reference a tag that no longer exists. The TagFilter component handles this gracefully by checking if the stored value is still valid.
The site is live at theovisagie.com if you want to see it in action.