My Problem đ€
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. When you stop to analyze whatâs under the hood, you sometimes discover youâre carrying a layer you donât need.
Donât get me wrong: Tailwind CSS is an amazing tool. Iâve used it in professional projects and will keep 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
My Solution đ§©
The idea was simple: remove Tailwind and replace it with vanilla CSS using a design tokens system built on 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, I get consistency across the entire project without depending on any framework. Just pure CSS that the browser understands directly.
The process meant 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.
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 reach for 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);
}
My Result đŻ
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 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.
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 đââïž