We have utility libraries, class-naming conventions, and methodologies dressed up in framework branding. What we don't have is a CSS layer that behaves like a framework does in any other ecosystem: typed input, defined output, a contract a compiler can actually enforce.
This is the long version of a complaint I've been making in private for years. It also names what I think a real CSS framework would do, and shows the working sketch I've been building in that direction.
Tailwind is useful, but it's not really a framework
The current heavyweight is Tailwind, with over 400 million npm downloads a month as of May 2026, so let's start with what it actually is. Strip the build step away and the utility-first methodology isn't new. Object-Oriented CSS, popularized by Nicole Sullivan in 2008, was already making the same case: small, single-purpose classes you compose at the markup layer.
Before Tailwind existed, plenty of teams generated their own utility scales using SCSS loops. You can rebuild a meaningful slice of Tailwind's spacing utilities in a few lines of SCSS that would've worked in 2012:
// Define the design system tokens
$spacing-map: (
"1": 0.25rem, // 4px
"2": 0.5rem, // 8px
"4": 1rem, // 16px
"8": 2rem // 32px
);
// Loop to generate padding and margin utilities
@each $key, $value in $spacing-map {
.p-#{$key} { padding: $value; }
.pt-#{$key} { padding-top: $value; }
.m-#{$key} { margin: $value; }
.mt-#{$key} { margin-top: $value; }
}
// Compiles to:
// .p-1 { padding: 0.25rem; }
// .pt-1 { padding-top: 0.25rem; } ...
What Tailwind genuinely adds is the build step. The JIT compiler scans your codebase and emits only the utility classes you actually use, with arbitrary-value support (p-[17px]) for one-offs. That's a real ergonomic win. Bundle sizes shrank and authoring speed went up.
The downstream cost is harder to talk about, because it shows up in maintenance, not authoring. In a Tailwind codebase, when something looks off on a page and you go searching for what styled it, you often can't. The class you'd search for (flex, text-center, p-4) appears a thousand times across the project. Layout decisions become string fragments scattered across every component file, not declarations you can grep, jump-to, or rename in one place. The tools that normally help you navigate a codebase mostly don't.
What we have, then: utility classes that were doable in SCSS in 2012, a build step that makes them ergonomic, and a search-and-rename problem that gets worse the bigger the project gets. Is that really what we want from a framework?
Where this breaks
AI didn't break Tailwind, even though January's layoffs at Tailwind Labs (the "AI killed Tailwind" framing) read that way in headlines. It exposed a contract Tailwind never had.
For most of the Tailwind era, humans wrote the class names. There was an implicit social contract: you stayed mostly on the spacing scale, you didn't use arbitrary values without a reason, you noticed when a string was getting absurd and broke it into a component. The framework didn't enforce any of that. It just trusted you.
The trust assumption is breaking. AI now writes a meaningful share of production CSS, and AI has no implicit social contract. It generates plausible-looking utility strings that pass a glance review and then break across a viewport, or duplicate themselves, or quietly drift off the scale. The class attribute runs to thirty utilities, half of them redundant (p-4 pt-4 pb-4 px-4 mt-2 mb-2 for what should have been p-4 my-2), and nothing in the stack catches the drift. Reviewing the output by hand becomes the bottleneck.
The arbitrary-value escape hatch. p-[17px] was tolerable when a senior dev would push back in review. Now it's a default behavior of the code generator, and there's no contract for anyone to point at. "Use the scale" is a vibe, not a rule.
The systematic-looking string. p-[var(--spacing-3)] looks systematic. There's a token name in there, the convention seems followed, reviewers wave it through. But the framework's scanning step doesn't actually understand it. The var lives inside a string fragment, not in the helper API. You can typo the variable name and ship it. Nothing catches that. Hardcoding is honest dishonesty. Token-wrapped strings are dishonest dishonesty.
Tailwind users will point to the fixes: @apply and component extraction for the grep problem, ESLint plugins and a theme-only config for the off-scale problem. These help, but they are external constraints layered on top, not a contract the type system enforces. A linter you can disable per line is a convention with tooling, not a compiler that refuses to emit invalid output. That distinction is the whole argument.
The fix isn't to ban AI from CSS, or to ban arbitrary values. The fix is to give the codebase a contract the AI can actually run inside: typed inputs that the build can verify, helpers that emit valid CSS, off-scale values visible rather than indistinguishable from on-scale ones.
What a framework should mean for CSS
In any other ecosystem, a framework gives you a contract. You hand it inputs in a defined shape, it does the work inside, you get a defined output. Rails takes route definitions and gives you a request lifecycle. React takes components and props and gives you a reconciled tree. The framework owns the implementation; you own the configuration and composition.
A CSS framework, by that standard, would:
- Take typed design tokens in and emit valid CSS out. Every value (length, color, string) becomes data the build can see, not strings to scan.
- Fail at compile time on mismatched units. Off-scale values stay possible, but visibly off-scale, not laundered into the same syntax as everything else.
-
Cover the full CSS spec, not a subset. New properties land in browsers all the time (
@container,view-timeline,field-sizing); a framework that gates which features you can use restricts the work to whatever its authors had time to model. - Stay opinionated at the edges, loose in the middle. Strict on typed input and valid emission. Composition, file organization, helper depth: all yours.
-
Offer opt-in helpers, never mandatory ones. A team could write a
bordershelper that groups width, color, and radius the way designers think about them; another team could skip that layer entirely.
That shape (strict edges, loose middle, full spec) is the inverse of how most CSS frameworks work. Tailwind owns the middle (every class is theirs); the edges stay loose (any string can land in a class attribute). CSS-in-JS libraries own a template syntax in the middle, with values inside the template still untyped. Typed CSS-in-JS libraries like vanilla-extract type the output but not the input. A framework with strict edges in both directions, and a loose middle, is rare in CSS, but it's how typed systems work everywhere else.
A working sketch: CSS-Calipers
This is the principle in code. CSS-Calipers is a small TypeScript library I wrote last year. The ideas behind it go back to my Vanilla Forums days. It covers the measurement-and-math piece of the framework I keep wanting. Tokens in, typed CSS out. The helpers are the contract. Best to use at build time, but occasional runtime possible.
The measurements stay opaque through composition. Nothing emits a string until you call .css() at the boundary. The math is checked at every step, not just at the end.
Mismatched units fail fast. As the snippet above shows, paddingBase.add(rotation) throws a clear error with px vs. deg named in the message. You don't find out in production that you added pixels to degrees.
The measurement core is the foundation. On top of it I've built a helpers layer in my portfolio: borders, paddings, margins, shadows. Each helper consumes measurements and emits typed style objects. Here's the borders helper in actual use:
// Use defaults from the token layer
export const cardBase = style(borders());
// Override specific values inline
export const cardEmphasis = style({
...borders({
width: m(2),
radius: { south: m(8) }, // compass-style: south = bottom
}),
});
// Or pass a full token config
export const cardThemed = style({
...borders(theme.cardBorders),
});
Three calling shapes, all valid: defaults, inline overrides, full token configs. The third one is where this starts to feel like a framework. Imagine you import the token from a tokens file:
// tokens/cardBorders.ts — today
export const cardBorders = {
width: m(1),
color: theme.colors.surface,
};
// components/Card.styles.ts
import { cardBorders } from "@/tokens/cardBorders";
export const cardStyles = style({
...borders(cardBorders),
});
Now design wants a thicker accent top and rounded bottom corners. You edit only the tokens file:
// tokens/cardBorders.ts — after the design tweak
export const cardBorders = {
width: m(1),
color: theme.colors.surface,
top: { width: m(3), color: theme.colors.accent },
radius: { south: m(8) },
};
The component file is unchanged. The helper accepts the new token shape and emits more CSS; if you remove keys later, it emits less. Adding a borderTopWidth to the design means a new key in the token object, not a new class in your markup. The call site is invariant; the design tokens are where the change happens.
Spacing helpers work the same way: paddings(m(16)) for uniform, paddings({ vertical: m(8), horizontal: m(16) }) for axis-intent, paddings(theme.cardPadding) to delegate the shape entirely to tokens. Same calling pattern across the layer.
Helper names are deliberately the plural of the CSS property they emit: paddings, borders, margins, shadows. Grep-able, visually distinct from a raw padding property in a style object, consistent across the layer.
The same pattern extends through the rest of the helpers layer: @supports fallbacks as typed objects, accessibility variants typed against the actual CSS feature set, color manipulation through typed methods, typed media-query factories, typed clamp and other math primitives. CSS values get type-checking in places they almost never do in normal CSS-in-JS work.
You can opt in or out at any boundary. Use measurements for high-stakes spacing math; write raw Tailwind classes for layout primitives where utility-first wins; drop into a CSS Module or styled-components for component-scoped work. CSS-Calipers itself is compiler-agnostic: it produces typed CSS strings that work with any of those, or with bare style objects, or wherever else you want valid CSS to land. The library doesn't try to own the middle. The contract is the function signatures, not the entire styling story.
The Missing Framework Layer
We still don't have proper CSS frameworks. The pieces exist in different places: typed CSS-in-TS, design tokens, scanning compilers, agent-aware tooling. None of them are wired together into a single thing that takes typed input from your token layer, emits valid CSS to the spec, and stays out of the way in between.
Getting there isn't a single project. It's a shift in how the category is defined: what we call "frameworks" today are vocabularies and methodologies. What a real one would be is a contract.
It's hard. The CSS spec is complex: edge cases, shorthand properties, overrides, the cascade itself. A real framework can't simplify those away by giving you a subset; it has to absorb the mess and stay faithful to it. CSS is the spec, and the spec is messy.
A real CSS framework is opinionated at the edges where types matter, loose in the middle where you compose, and covers the whole CSS spec rather than a subset. We don't have one yet. We could.

Top comments (0)