{"slug": "we-still-don-t-have-proper-css-frameworks", "title": "We still don't have proper CSS frameworks", "summary": "The article argues that current CSS tools like Tailwind are not true frameworks because they lack a compiler-enforceable contract with typed inputs and defined outputs, unlike frameworks in other ecosystems. It contends that Tailwind's core utility methodology existed in SCSS before 2012, and its main innovation is a JIT build step that reduces bundle sizes. The author warns that AI-generated code is breaking the implicit social contract of utility classes, leading to maintenance issues like redundant strings and unenforceable design constraints.", "body_md": "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.\n\nThis 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.\n\n## Tailwind is useful, but it's not really a framework\n\nThe current heavyweight is Tailwind, [with 75M+ npm downloads a month](https://workspace.hr/blog/tailwind-css-crisis-2026-what-developers-need-to-know), 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](https://github.com/stubbornella/oocss) in 2008, was already making the same case: small, single-purpose classes you compose at the markup layer.\n\nBefore 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:\n\n```\n// Define the design system tokens\n$spacing-map: (\n  \"1\": 0.25rem, // 4px\n  \"2\": 0.5rem,  // 8px\n  \"4\": 1rem,    // 16px\n  \"8\": 2rem     // 32px\n);\n\n// Loop to generate padding and margin utilities\n@each $key, $value in $spacing-map {\n  .p-#{$key}  { padding: $value; }\n  .pt-#{$key} { padding-top: $value; }\n  .m-#{$key}  { margin: $value; }\n  .mt-#{$key} { margin-top: $value; }\n}\n\n// Compiles to:\n// .p-1  { padding: 0.25rem; }\n// .pt-1 { padding-top: 0.25rem; } ...\n```\n\nWhat 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]`\n\n) for one-offs. That's a real ergonomic win. Bundle sizes shrank and authoring speed went up.\n\nThe 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`\n\n, `text-center`\n\n, `p-4`\n\n) 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.\n\nWhat 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?\n\n## Where this breaks\n\nAI didn't break Tailwind, even though [January's layoffs at Tailwind Labs](https://www.leanware.co/insights/tailwind-ai-crisis) read that way in headlines. It exposed a contract Tailwind never had.\n\nFor 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.\n\nThe 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`\n\nfor what should have been `p-4 my-2`\n\n), and nothing in the stack catches the drift. Reviewing the output by hand becomes the bottleneck.\n\n**The arbitrary-value escape hatch.** `p-[17px]`\n\nwas 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.\n\n**The systematic-looking string.** `p-[var(--spacing-3)]`\n\nlooks 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.\n\nTailwind users will point to the fixes: `@apply`\n\nand 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.\n\nThe 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.\n\n## What a framework should mean for CSS\n\nIn 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.\n\nA CSS framework, by that standard, would:\n\n-\n**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. -\n**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. -\n**Cover the full CSS spec, not a subset.** New properties land in browsers all the time (`@container`\n\n,`view-timeline`\n\n,`field-sizing`\n\n); a framework that gates which features you can use restricts the work to whatever its authors had time to model. -\n**Stay opinionated at the edges, loose in the middle.** Strict on typed input and valid emission. Composition, file organization, helper depth: all yours. -\n**Offer opt-in helpers, never mandatory ones.** A team could write a`borders`\n\nhelper that groups width, color, and radius the way designers think about them; another team could skip that layer entirely.\n\nThat 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.\n\n## A working sketch: CSS-Calipers\n\nThis is the principle in code. [CSS-Calipers](https://www.npmjs.com/package/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.\n\nThe measurements stay opaque through composition. Nothing emits a string until you call `.css()`\n\nat the boundary. The math is checked at every step, not just at the end.\n\nMismatched units fail fast. As the snippet above shows, `paddingBase.add(rotation)`\n\nthrows a clear error with px vs. deg named in the message. You don't find out in production that you added pixels to degrees.\n\nThe measurement core is the foundation. On top of it I've built a helpers layer in my [portfolio](https://lafleche.dev/): borders, paddings, margins, shadows. Each helper consumes measurements and emits typed style objects. Here's the borders helper in actual use:\n\n``` js\n// Use defaults from the token layer\nexport const cardBase = style(borders());\n\n// Override specific values inline\nexport const cardEmphasis = style({\n  ...borders({\n    width: m(2),\n    radius: { south: m(8) },  // compass-style: south = bottom\n  }),\n});\n\n// Or pass a full token config\nexport const cardThemed = style({\n  ...borders(theme.cardBorders),\n});\n```\n\nThree 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:\n\n``` js\n// tokens/cardBorders.ts — today\nexport const cardBorders = {\n  width: m(1),\n  color: theme.colors.surface,\n};\njs\n// components/Card.styles.ts\nimport { cardBorders } from \"@/tokens/cardBorders\";\n\nexport const cardStyles = style({\n  ...borders(cardBorders),\n});\n```\n\nNow design wants a thicker accent top and rounded bottom corners. You edit only the tokens file:\n\n``` js\n// tokens/cardBorders.ts — after the design tweak\nexport const cardBorders = {\n  width: m(1),\n  color: theme.colors.surface,\n  top:    { width: m(3), color: theme.colors.accent },\n  radius: { south: m(8) },\n};\n```\n\nThe 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`\n\nto 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.\n\nSpacing helpers work the same way: `paddings(m(16))`\n\nfor uniform, `paddings({ block: m(8), inline: m(16) })`\n\nfor axis-intent, `paddings(theme.cardPadding)`\n\nto delegate the shape entirely to tokens. Same calling pattern across the layer.\n\nHelper names are deliberately the plural of the CSS property they emit: `paddings`\n\n, `borders`\n\n, `margins`\n\n, `shadows`\n\n. Grep-able, visually distinct from a raw `padding`\n\nproperty in a style object, consistent across the layer.\n\nThe same pattern extends through the rest of the helpers layer: `@supports`\n\nfallbacks as typed objects, accessibility variants typed against the actual CSS feature set, color manipulation through typed methods, typed media-query factories, typed `clamp`\n\nand other math primitives. CSS values get type-checking in places they almost never do in normal CSS-in-JS work.\n\nYou 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.\n\n## What's missing\n\nWe 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.\n\nGetting 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.\n\nIt'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.\n\nA 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.", "url": "https://wpnews.pro/news/we-still-don-t-have-proper-css-frameworks", "canonical_source": "https://dev.to/slafleche/we-still-dont-have-proper-css-frameworks-18dk", "published_at": "2026-05-22 12:58:55+00:00", "updated_at": "2026-05-22 13:04:10.632151+00:00", "lang": "en", "topics": ["developer-tools", "open-source", "web3"], "entities": ["Tailwind", "Nicole Sullivan", "SCSS"], "alternates": {"html": "https://wpnews.pro/news/we-still-don-t-have-proper-css-frameworks", "markdown": "https://wpnews.pro/news/we-still-don-t-have-proper-css-frameworks.md", "text": "https://wpnews.pro/news/we-still-don-t-have-proper-css-frameworks.txt", "jsonld": "https://wpnews.pro/news/we-still-don-t-have-proper-css-frameworks.jsonld"}}