Moving Away from Tailwind CSS: Building Maintainable CSS Architecture with Vanilla CSS
Programmer Julia Evans recently shared her experience migrating a project from Tailwind CSS to vanilla CSS on Hacker News. The post earned 379 points and 244 comments, sparking a heated discussion in the frontend community. She used Tailwind for 8 years before making the switch — not because Tailwind is bad, but because she had learned enough from it to build a better system with vanilla CSS.
This guide breaks down her methodology with practical code examples. If you're considering moving away from Tailwind CSS, or just want to understand better CSS architecture patterns, this article is for you.
Table of Contents
Why Move Away from Tailwind CSS
Tailwind CSS has been one of the most popular CSS frameworks in recent years. Its atomic design philosophy lets developers assemble interfaces quickly using utility classes without writing custom CSS. But as projects grow, several pain points emerge:
- HTML readability suffers: A button with 20 utility classes becomes hard to read at a glance
- Style reuse is awkward: The same combinations of classes appear in multiple places without a true "component" abstraction
- Bundle size concerns: Even with purging, generated CSS files tend to be larger than hand-crafted ones
- Debugging is unintuitive: DevTools shows a wall of utility classes instead of semantic style rules
Julia's core argument: Tailwind is excellent for learning CSS and building quick prototypes, but once you truly understand CSS design principles, vanilla CSS can produce cleaner, more maintainable code.
What Tailwind Taught Us
Before leaving Tailwind behind, Julia makes an important point: Tailwind is an excellent CSS teaching tool. It taught many developers these critical concepts:
- CSS Reset: Modern projects need a reliable reset to normalize browser default styles
- Color systems: Don't use arbitrary hex values — build a structured color palette
- Type scale: Don't use 13px, 14px, 15px arbitrarily — use a consistent scale
- Spacing conventions: Use consistent values (4px, 8px, 16px, 32px) instead of random numbers
These design principles, learned from Tailwind, can be directly carried over to vanilla CSS. The key is to codify them using CSS custom properties.
Building a Design System with CSS Variables
One of vanilla CSS's greatest strengths is custom properties (variables). Julia's approach is to rebuild Tailwind's design system using CSS variables:
Color Variables
Defined on :root for global access:
:root {
/* Primary colors */
--pink: #fea0c2;
--blue: #a0d0fe;
--green: #a0fea0;
--yellow: #fefe80;
--purple: #d0a0fe;
/* Neutral colors */
--gray-50: #f9fafb;
--gray-100: #f3f4f6;
--gray-200: #e5e7eb;
--gray-500: #6b7280;
--gray-700: #374151;
--gray-900: #111827;
/* Semantic colors */
--text: var(--gray-900);
--text-muted: var(--gray-500);
--bg: #ffffff;
--bg-subtle: var(--gray-50);
--border: var(--gray-200);
}
The benefit: change once, apply everywhere. Want dark mode support? Just redefine these variables inside @media (prefers-color-scheme: dark).
Type Scale
Directly ported from Tailwind's font size system:
:root {
--size-xs: 0.75rem; /* 12px */
--size-sm: 0.875rem; /* 14px */
--size-base: 1rem; /* 16px */
--size-lg: 1.125rem; /* 18px */
--size-xl: 1.25rem; /* 20px */
--size-2xl: 1.5rem; /* 24px */
--size-3xl: 1.875rem; /* 30px */
--size-4xl: 2.25rem; /* 36px */
}
--size-xs instead of --font-size-xs for brevity. Your team only needs to remember one naming prefix.
Spacing System
:root {
--space-1: 0.25rem; /* 4px */
--space-2: 0.5rem; /* 8px */
--space-3: 0.75rem; /* 12px */
--space-4: 1rem; /* 16px */
--space-6: 1.5rem; /* 24px */
--space-8: 2rem; /* 32px */
--space-12: 3rem; /* 48px */
--space-16: 4rem; /* 64px */
}
CSS Component Architecture
This is the most important part of Julia's methodology. She borrows the component idea from Vue/React but implements it in pure CSS: each component has a unique class name and its own CSS file.
Design Principles
- Each component uses a unique class name (e.g.,
.site-header,.blog-card,.search-box) - Each component has its own CSS file
- Components never override each other's styles
- Components can use CSS variables internally but never modify global ones
File Structure
css/
reset.css /* CSS Reset */
variables.css /* Global CSS variables */
base.css /* Base styles */
components/
site-header.css /* Header component */
blog-card.css /* Blog card */
search-box.css /* Search input */
footer.css /* Footer */
utilities.css /* Utility classes */
Component Example: Blog Card
HTML:
<article class="blog-card">
<img class="blog-card__image" src="cover.jpg" alt="Cover image">
<div class="blog-card__body">
<h3 class="blog-card__title">Article Title</h3>
<p class="blog-card__excerpt">Article summary...</p>
<span class="blog-card__date">2026-05-17</span>
</div>
</article>
CSS (components/blog-card.css):
.blog-card {
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
background: var(--bg);
transition: box-shadow 0.2s;
}
.blog-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.blog-card__image {
width: 100%;
height: 200px;
object-fit: cover;
}
.blog-card__body {
padding: var(--space-4);
}
.blog-card__title {
font-size: var(--size-xl);
font-weight: 600;
margin: 0 0 var(--space-2);
}
.blog-card__excerpt {
font-size: var(--size-sm);
color: var(--text-muted);
margin: 0 0 var(--space-3);
}
.blog-card__date {
font-size: var(--size-xs);
color: var(--text-muted);
}
Notice: .blog-card__title and .blog-card__excerpt can never conflict because they're scoped under the .blog-card namespace. This is far clearer than Tailwind's class="text-xl font-semibold mb-2".
Minimal Base Styles
Julia keeps base styles extremely lean — only the defaults that are truly needed globally:
/* reset.css - Simplified CSS Reset */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
}
body {
font-family: 'Inter', system-ui, sans-serif;
font-size: var(--size-base);
line-height: 1.6;
color: var(--text);
background: var(--bg);
}
img {
max-width: 100%;
display: block;
}
a {
color: var(--blue);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
section {
padding: var(--space-8) 0;
}
No excess. No global font-size overrides. No complex reset rules. Just enough.
Managing Spacing with the Owl Selector
This is the most elegant part of Julia's methodology. The owl selector (* + *), proposed by Heydon Pickering, solves vertical spacing inside components with a single line of CSS:
section > * + * {
margin-top: 1rem;
}
What it means: any element inside a section that isn't the first child gets a margin-top. No need to add spacing to each child element individually, no extra wrappers, no gap properties.
Compared to Tailwind
Tailwind version:
<section class="flex flex-col gap-4">
<h2>Title</h2>
<p>Paragraph</p>
<div>Content</div>
</section>
Owl selector version:
<section>
<h2>Title</h2>
<p>Paragraph</p>
<div>Content</div>
</section>
Cleaner HTML, and spacing logic lives entirely in CSS. You can even define different spacing per section:
.hero-section > * + * {
margin-top: var(--space-6);
}
.blog-list > * + * {
margin-top: var(--space-4);
}
.footer > * + * {
margin-top: var(--space-2);
}
margin-top: 0.
Container Queries: Truly Responsive Design
Tailwind's responsive design relies on media queries, which are based on viewport width — not the actual available space of a component. CSS container queries solve this:
.blog-card {
container-type: inline-size;
container-name: card;
}
/* Switch to horizontal layout when card container >= 400px */
@container card (min-width: 400px) {
.blog-card {
display: grid;
grid-template-columns: 200px 1fr;
}
.blog-card__image {
height: 100%;
}
}
This means the same blog card component automatically switches to a compact horizontal layout in a sidebar, and a spacious vertical layout in the main content area — no JavaScript needed, no props from parent components.
Container Queries vs Media Queries
/* Media query: based on viewport width, imprecise */
@media (min-width: 768px) {
.blog-card { /* Active when viewport >= 768px */ }
}
/* Container query: based on component's actual width, precise */
@container card (min-width: 400px) {
.blog-card { /* Active when card >= 400px wide */ }
}
All modern browsers support container queries (Chrome 105+, Firefox 110+, Safari 16+). Safe for production use today.
Keeping Essential Utilities
Julia doesn't completely abandon utility classes. She keeps a small set of truly useful global utilities:
/* Accessibility: visually hidden but readable by screen readers */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
/* Text truncation */
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Container */
.container {
max-width: 1200px;
margin-inline: auto;
padding-inline: var(--space-4);
}
These are genuinely cross-component utilities — few in number, clear in purpose. Completely different from Tailwind's hundreds of utility classes.
Migration Guide
If you're considering moving away from Tailwind CSS, here's a step-by-step approach:
Step 1: Establish Your Variable System
Extract colors, fonts, spacing, and other design tokens from your existing tailwind.config.js and convert them to CSS variables. This can be semi-automated with a script that parses the Tailwind config and generates variables.css.
Step 2: Start Small
Don't replace everything at once. Pick one or two small components (a button, a card) and rewrite them in vanilla CSS while keeping the rest on Tailwind.
Step 3: Set Up Your File Structure
Organize your CSS files using the component architecture described above. One file per component for easy maintenance and discovery.
Step 4: Incremental Replacement
Each time you modify a page or component, replace its Tailwind classes with vanilla CSS. This makes migration gradual and limits the risk of introducing bugs.
Step 5: Remove Tailwind
Once all components are migrated, remove Tailwind from your project dependencies. Check your build config to ensure CSS files are properly bundled.
Conclusion
Julia Evans' experience teaches an important lesson: good tools don't have to be permanent companions. Tailwind CSS is an excellent CSS teaching tool and rapid prototyping framework, but once you truly understand CSS design philosophy, vanilla CSS gives you greater flexibility and better long-term maintainability.
Key takeaways:
- CSS variables are the foundation of a design system — more flexible than Tailwind config
- Component-based CSS (one component, one file, one class) solves style isolation and reuse
- The owl selector manages component spacing with a single CSS rule
- Container queries provide more precise responsive design than media queries
- A small set of utilities handles truly cross-component styles
This isn't an argument that Tailwind CSS is bad — it remains an excellent choice for quick prototypes and small projects. But for medium-to-large projects that need long-term maintenance, mastering vanilla CSS architecture is a skill worth investing in.
Further reading: Julia Evans' original post New CSS things I learned, and Heydon Pickering's Lobotomized Owl selector article.