Back to Blog

Building a Modern Portfolio with Astro and Framer Motion

Astro Tailwind CSS Framer Motion Web Development

Why Astro?

When I set out to rebuild my portfolio, I had a clear goal: fast, content-first, and minimal JavaScript. Astro’s island architecture was the perfect fit. By shipping zero JavaScript by default, every page loads instantly — and you only hydrate the interactive bits that actually need to be interactive.

The result? A portfolio that scores 100 on Lighthouse Performance while still having smooth animations, a dark mode toggle, and even a seasonal color palette system.

The Stack

Here’s what powers this site:

  • Astro 5 — Static site generation with island architecture
  • React 19 — Interactive components (navbar, project cards, contact form)
  • Tailwind CSS v4 — Utility-first styling with the new @theme directive
  • Framer Motion — Scroll-triggered animations and micro-interactions
  • Netlify — Hosting, form processing, and CI/CD via GitHub Actions

The key insight: not everything needs to be a React component. I split components into two categories:

TypeExtensionWhen to Use
Static.astroNo client-side interactivity needed
Interactive.tsxNeeds state, event handlers, or animations

This means the Footer and SectionHeading ship zero JavaScript, while the Navbar and ProjectCard hydrate only when needed.

Try it: Hydration Comparison

Hover over the cards below to see how Astro island architecture works — static components render instantly with no JS, while interactive islands hydrate on demand:

Static (.astro)

Footer

Social links, copyright, tech credits. No client-side interactivity needed.

0 KB JS
Island (.tsx)

Navbar

Mobile menu, dark mode toggle, scroll detection. Hydrates via client:load.

~7 KB JS

Content Collections

One of Astro’s best features is Content Collections — type-safe markdown content with Zod schema validation. No more runtime surprises when a field is missing or malformed.

Here’s the actual schema powering my projects:

const projects = defineCollection({
  loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/projects' }),
  schema: z.object({
    title: z.string(),
    description: z.string(),
    tags: z.array(z.string()),
    image: z.string().optional(),
    liveUrl: z.string().url().optional(),
    githubUrl: z.string().url().optional(),
    featured: z.boolean().default(false),
    order: z.number().default(0),
    achievements: z.array(z.string()).default([]),
    company: z.string().optional(),
  }),
});

I have four collections: projects, experience, blogs, and tech-stack. Each lives in its own directory under src/content/ and follows strict naming conventions (kebab-case file names matching the company or project name).

The beauty of this approach is that adding a new project is just creating a markdown file — no database, no CMS, no API calls.

Seasonal Color Palettes 🎨

This is probably my favorite feature. The site’s color palette changes automatically based on the time of year:

SeasonMonthsPrimary ColorsAccent
❄️ WinterDec–FebIndigoEmerald
🌸 SpringMar–MayTealRose
☀️ SummerJun–AugAmberCyan
🍂 AutumnSep–NovOrangeGold

Try it: Season Preview

Click any season below to see its color palette. The gradient text and accent dot update live — this is the same CSS variable override the real site uses:

A portfolio built with 'Astro'

Winter — Indigo + Emerald

How It Works

The implementation is surprisingly simple. Tailwind CSS v4’s @theme directive defines CSS custom properties:

@theme {
  --color-primary-500: #6366f1; /* Winter indigo */
  --color-accent-500: #10b981;  /* Winter emerald */
}

Then each season overrides these tokens via a data-season attribute on <html>:

html[data-season="spring"] {
  --color-primary-500: #14b8a6; /* Teal */
  --color-accent-500: #f43f5e;  /* Rose */
}

An inline script in <head> detects the current month before the page paints, so there’s no flash of wrong colors:

var seasons = ['winter','winter','spring','spring','spring',
               'summer','summer','summer','autumn','autumn',
               'autumn','winter'];
var season = localStorage.getItem('season') || seasons[new Date().getMonth()];
document.documentElement.setAttribute('data-season', season);

Visitors can also manually cycle through seasons using the emoji toggle (🌸 ☀️ 🍂 ❄️) in the navbar, and their preference persists in localStorage.

The Design System

Every visual element is built on three utility classes:

  • .text-gradient — The signature gradient text that blends primary into accent colors
  • .glass-card — Frosted glass cards with backdrop blur and subtle borders
  • .section-padding — Responsive section spacing that scales with screen size

These, combined with a consistent color token system (primary-*, surface-*, accent-*), mean I never write raw hex values. Everything adapts automatically when the season or theme changes.

Animations Done Right

Framer Motion powers all the animations, but I follow a strict rule: use client:visible hydration whenever possible. This means animation components only load when scrolled into view.

// FadeIn wraps any content with a scroll-triggered entrance
<FadeIn client:visible delay={0.1}>
  <ProjectCard ... />
</FadeIn>

The ProjectCard itself uses whileHover for the achievement overlay reveal — when you hover a project card, the achievements slide in from the bottom with a spring animation. Here’s what that looks like in action:

Try it: Project Card Hover

Hover over the card below to reveal the achievement overlay, just like the real project cards on the portfolio:

🛒

E-Commerce Platform

Full-stack e-commerce solution with real-time inventory, payment processing, and an admin dashboard.

React .NET Core Azure SQL Server
Key Achievements
Reduced page load time by 40% with lazy loading
Handled 10K+ concurrent users at peak
99.9% uptime SLA with Azure deployment

Contact Form with Netlify Forms

The contact form is a React component (ContactForm.tsx) that submits via fetch to Netlify Forms — no page redirect, no janky full-page POST:

Try it: Form States

Click the buttons below to cycle through the contact form’s four states:

The form includes a honeypot field for spam prevention and the hidden form-name input that Netlify requires to detect forms during the build step.

Deployment Pipeline

Every push to main triggers a GitHub Action that:

  1. Checks out the code
  2. Installs dependencies with npm ci
  3. Builds the production bundle with astro build
  4. Deploys to Netlify via the nwtgck/actions-netlify@v3 action

The whole pipeline runs in under 30 seconds. The site is live at awalakaushik.dev.

What I Learned

Building with Astro taught me to think critically about what actually needs JavaScript. The answer? Far less than you’d think. A navbar with a mobile menu and dark mode toggle — yes. A footer with social links — no. A project card with hover animations — yes. A section heading — absolutely not.

This mindset shift — defaulting to static and opting into interactivity — is what makes Astro so powerful. And with Tailwind v4’s CSS-first approach and seasonal theming, the site feels alive and personal without sacrificing performance.

If you’re thinking about building your own portfolio, here’s my advice: start with content, not code. Define your data structures first (schemas, markdown files), then build the UI around them. It’s faster, it’s more maintainable, and it forces you to think about what actually matters.