🎨 Why Touch What Already Works?
My portfolio had been running perfectly with Tailwind CSS for months. Everything was fine. Styles were in place, components looked great, and the site loaded fast.
So why change?
Because working well and working the best way possible are two different things. And when you stop to analyze what’s under the hood, you sometimes discover you’re carrying a layer you don’t need 🧅.
🤔 The Problem with Tailwind (In My Case)
Don’t get me wrong: Tailwind CSS is an amazing tool. I’ve used it in professional projects and will continue using it where it makes sense. But in a personal portfolio built with Astro, I started noticing a few things:
- Unnecessary dependency: 3 extra packages (tailwindcss, @tailwindcss/vite, @tailwindcss/typography) for a project that didn’t really need them 📦
- Abstraction layer: Tailwind generates CSS from utility classes. It’s a layer between what you write and what the browser interprets. In a small project, that layer adds overhead without adding value 🧱
- Extra weight: The generated CSS included utilities I wasn’t always taking full advantage of. ~20KB extra that the user downloaded unnecessarily 📊
- Less control: When you want something very specific, you end up fighting the framework instead of writing exactly what you need ⚔️
💡 The Decision: Vanilla CSS with Design Tokens
The idea was simple: remove Tailwind and replace it with vanilla CSS using a design tokens system with CSS custom properties.
What are design tokens? They’re CSS variables that define your design system:
:root {
--color-primary: oklch(0.55 0.2 260);
--color-gray-100: oklch(0.97 0 0);
--color-gray-900: oklch(0.21 0.006 285.75);
--text-sm: 0.875rem;
--text-base: 1rem;
--text-xl: 1.25rem;
--spacing: 0.25rem;
--radius-lg: 0.5rem;
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
--transition-colors: color 0.15s, background-color 0.15s, border-color 0.15s;
}
With this, you get consistency across the entire project without depending on any framework. Just pure CSS that the browser understands directly 🎯.
🔧 The Migration
The process involved migrating 38 files in a single commit. Each Astro component went from using Tailwind classes to having its own scoped <style> block:
Before (Tailwind):
<header class="flex items-center justify-between px-6 py-4 bg-white dark:bg-gray-900">
<nav class="flex gap-4">
<a class="text-sm font-medium text-gray-700 hover:text-blue-500">Blog</a>
</nav>
</header>
After (Vanilla CSS):
<header class="header">
<nav class="nav">
<a class="nav-link">Blog</a>
</nav>
</header>
<style>
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: calc(var(--spacing) * 4) calc(var(--spacing) * 6);
background-color: var(--color-white);
}
:global(.dark) .header {
background-color: var(--color-gray-900);
}
.nav {
display: flex;
gap: calc(var(--spacing) * 4);
}
.nav-link {
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-gray-700);
transition: var(--transition-colors);
}
.nav-link:hover {
color: var(--color-blue-500);
}
</style>
More lines? Yes. Clearer and more maintainable? Absolutely ✅.
📚 What I Learned Along the Way
The migration wasn’t just “remove Tailwind and add CSS.” There were interesting pitfalls worth sharing:
Scoped styles and <slot>
In Astro, styles inside <style> are scoped by default. This means each component receives a unique attribute (data-astro-cid-*) and styles only affect that component.
The catch: content passed via <slot> does not receive that attribute. If a parent component tries to style slotted content, the styles won’t apply 😱.
The solution: use :global() for selectors targeting slotted content:
.container :global(a) {
color: var(--color-primary);
text-decoration: underline;
}
opacity vs background transparency
With Tailwind, I used classes like bg-opacity-70. When migrating, my first instinct was to use the opacity property. Mistake: opacity affects the entire element, including its children 👶.
The correct solution: color-mix() for background-only transparency:
.modal-overlay {
/* BAD: affects everything */
opacity: 0.7;
/* GOOD: only the background is transparent */
background-color: color-mix(in oklab, var(--color-gray-900) 70%, transparent);
}
📊 The Results
The numbers speak for themselves:
- ~20KB less CSS delivered to the browser 📉
- 3 dependencies removed from
package.json🗑️ - 0 abstraction layers between your code and the browser 🎯
- Faster builds by eliminating Tailwind’s processing step ⚡
- Full control over every line of CSS generated 🎛️
The package.json went from having tailwindcss, @tailwindcss/vite, and @tailwindcss/typography to no styling dependencies at all. Just pure CSS.
And the best part: the tailwind.config.mjs file was completely removed. One less configuration to maintain 🧹.
🏗️ The Final Architecture
The styling system ended up organized into 4 CSS files:
| File | Responsibility |
|---|---|
| design-tokens.css | Colors, typography, spacing, shadows, transitions |
| base-reset.css | Minimal CSS reset and utilities like .sr-only |
| prose.css | Blog content typography with dark mode support |
| layout.css | Global layout classes (.bodyLayout, .mainLayout) |
All imported from a single global.css. Clean, predictable, and no black magic 🧙♂️.
💭 Final Thoughts
Migrating from Tailwind CSS to vanilla CSS isn’t for everyone or every project. In large teams or projects with many developers, Tailwind remains a fantastic choice for its consistency and development speed.
But in a personal project like a portfolio built with Astro, where performance matters and full control is a luxury you can afford, removing that abstraction layer is liberating 🕊️. In fact, I ended up taking this philosophy even further and migrated the entire portfolio from Astro to Swift — I tell the full story in Astro to Saga.
It’s like painting a picture: you can use templates and tools to guide you, or you can pick up the brush and create exactly what you have in mind. Both options are valid. But when the canvas is yours, painting by hand has its charm 🎨.
Keep coding, keep running 🏃♂️