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 automated OOCSS
The current heavyweight is Tailwind, 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 and flex 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; } ...
Flex alignments? Same pattern:
$flex-alignments: (
"start": flex-start,
"end": flex-end,
"center": center,
"between": space-between
);
@each $name, $value in $flex-alignments {
.justify-#{$name} { justify-content: $value; }
.items-#{$name} { align-items: $value; }
}
Variants (responsive, hover, dark mode) followed the same shape: nested @media, :hover, parent selectors. SCSS could already produce the md:p-4 / hover:bg-blue-500 patterns by looping over breakpoints and pseudo-classes.
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. 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, 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.
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 design tokens as input and give you valid CSS as output. The contract would be the function signatures: pass these values, get back this typed object. Mismatched units would fail at compile time. Off-scale values would still be possible but visibly off-scale, not laundered into the same syntax as everything else.
It would also cover the CSS spec, not a subset of it. CSS is fluid: new properties land in browsers all the time, experimental features like @container, view-timeline, field-sizing arrive without waiting for any framework to type them. A framework that gates which features you can use restricts the work to whatever its authors had time to model. The CSS spec is the spec. The framework's job is to type the values you pass in and emit valid CSS, not to decide which properties are allowed.
The shape I keep coming back to is opinionated at the edges, loose in the middle. The framework is strict where strictness matters: typed input from the token layer, valid CSS at the moment of emission. The middle (how you compose, how you organize files, when you reach for a helper vs. write raw CSS) is yours. The framework doesn't try to own that.
That's 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. vanilla-extract types 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({ block: m(8), inline: 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.
And if you miss Tailwind's p-1 / p-4 / p-8 shorthand, it's a three-line scale function on top of the same primitives:
const space = (n: number) => m(4).multiply(n);
space(4).css(); // "16px" (Tailwind's p-4)
paddings(space(4)); // typed equivalent of p-4, drops into a style object
Tailwind-style ergonomics inside the typed framework. The scale base is up to you: switch to rem for font-size-respecting spacing, define separate scales per category (space, radius, fontSize), or compose ratios for a fluid-typography scale. The math is yours; the typing carries through.
And the media-queries module ties it together by taking typed breakpoints instead of string templates:
import { m } from "css-calipers";
import { makeMediaQueryStyle } from "css-calipers/mediaQueries";
const media = makeMediaQueryStyle({
mobile: { maxWidth: m(639) },
tablet: { minWidth: m(640), maxWidth: m(1023) },
desktop: { minWidth: m(1024) },
});
const cardWidth = m(320).clamp(m(260), m(360)); // typed fluid sizing
const cardStyles = {
width: cardWidth.css(),
...media({
mobile: { padding: m(12).css() },
tablet: { padding: m(16).css() },
desktop: { padding: m(24).css() },
}),
};
clamp is the comparison worth making explicit. Native CSS clamp(260px, 100vw, 360px) is pure string: a misordered argument, a typo in a unit name, a mismatched unit family, all ship without complaint. The typed version takes Measurement objects, validates their units, and returns a Measurement you can keep composing on. You can derive the bounds from tokens, add to the clamped result, feed it into another helper, then emit the CSS string only at the very end. Same idea as CSS's clamp(), with typing carried through every step instead of dropped at the call site.
The media-query factory takes typed breakpoint values the same way; a missing key in your query map is caught at the call site, not when the page renders wrong on tablet.
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. 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, vanilla-extract, 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.
What's missing
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)