DEV Community

Eduard Krivanek
Eduard Krivanek

Posted on

Unhandled CSS Ruining Life

There is a moment in the life of almost every mature frontend when CSS stops feeling like a tool and starts feeling like archaeology. You open one screen and the primary button has one height. On another screen, it is almost the same height, but not quite. Inputs look consistent until you open a modal. Selects behave until a page level override wins. Somewhere in the codebase there is a global stylesheet that still matters, a component stylesheet that matters more, and one deep selector that matters most of all. Nothing is completely broken. That is what makes this kind of problem dangerous. The problem is usually not “bad CSS.” The problem is that the application no longer has a clear styling architecture.

When CSS stops being local

Early in a project, styling feels easy because the UI is still small enough to be held in one person’s head. A global stylesheet provides the visual baseline. Components get their own local styles. A UI library gives you buttons, selects, inputs, and checkboxes that are good enough to move quickly. If the design team needs something slightly different, you add an override and keep going. Then the application grows. New sections arrive. Feature teams solve similar problems in slightly different ways. One group wraps a checkbox into a reusable consent component because the label needs links, validation, and custom spacing. Another group uses the raw checkbox from the component library because it is quicker and the feature deadline is near. Somebody adds a global override to make all form controls match the brand more closely. Somebody else adds a local override because the global rule broke a special case. A page with unusual requirements bends the styling rules just enough to ship. No one is wrong in the moment.

Unlike a refactor that fails loudly, styling debt often survives because each local decision makes sense when viewed in isolation. Over time, however, the application develops an invisible split personality. It has “official” components and “practical” components. It has “shared styles” and “the real styles that actually win in production.” It has consistency in theory and variation in practice. At that point, the team usually starts asking the wrong question. They ask, “Should we clean up SCSS?” or “Should we move to Tailwind?” or “Should we get rid of global styles?”

The warning signs that matter

A broken styling architecture rarely announces itself with one catastrophic bug. It reveals itself through friction. The strongest warning sign is not visual inconsistency by itself. Mature products often have legitimate variation. The strongest warning sign is that developers need to know too much internal detail in order to style a basic control safely. If a developer has to remember the internal DOM structure of a third party checkbox component, or the exact selector required to override a library label, or whether a certain control should be changed in a global theme file instead of a wrapper component, the architecture is already leaking.

That kind of leak is expensive because it distributes responsibility downward. Instead of having one stable place where the checkbox lives as a product decision, the application spreads checkbox behavior across templates, wrappers, overrides, library classes, and exceptions. The same usually happens with inputs, selects, and buttons. Once those foundations drift, every feature built on top of them becomes harder to reason about.

What the destination should feel like

The most important outcome of a CSS recovery effort is not prettier code. It is restored trust. When trust exists, a developer can open the codebase and make a styling change with a reasonable expectation of what else it will affect. A product designer can request a form refresh without triggering a month of regressions. A new team member can infer where a style belongs because the architecture teaches them by example. Even exceptions become manageable, because they are clearly exceptions rather than secret rules.

The first layer is the foundation: design tokens, typography, spacing scale, color roles, border radii, elevation, motion. These are the values that give the product a consistent visual language. They should be stable, semantic, and boring. If color or spacing values are still scattered as raw numbers throughout the app, the system has not yet found its center.

The second layer is primitives: the actual controls the application relies on every day. This is where buttons, inputs, selects, checkboxes, labels, and validation patterns belong. Whether they are implemented with plain HTML, wrapped around a component library, or partially themed through one is less important than this fact: the rest of the application should not need to care. A checkbox should arrive as a reliable product primitive, not as a styling opportunity.

The third layer is composition: shared UI patterns and feature screens. This is where a consent block, settings row, profile form section, payment card, or alert panel becomes specific to product needs. Composition should use primitives, not redefine them. A feature page may choose arrangement, content, density, or emphasis, but it should not quietly invent a new checkbox standard because the page stylesheet made that convenient.

A small example of the real difference

Here is a tiny example that captures the deeper issue. In a drifting codebase, a checkbox often appears like this in one component:

<library-checkbox class="terms-checkbox">
  I agree to the terms
</library-checkbox>

// ----
// local styles
.terms-checkbox {
  margin-top: 12px;
}

:host ::ng-deep .library-checkbox-label {
  font-size: 14px;
  line-height: 20px;
}
Enter fullscreen mode Exit fullscreen mode

At first glance, this does not look terrible. It is short. It ships. It solves the screen. But it quietly pushes product decisions into the wrong place. The feature page now owns checkbox spacing, label typography, and knowledge of the third party component’s internal structure. If five pages do this differently, the application has five checkbox definitions whether anyone admits it or not. The healthier version is not healthier because it is prettier. It is healthier because responsibility moves back to the primitive:

<app-checkbox [invalid]="submitted && control.invalid">
  I agree to the terms
</app-checkbox>

// ---- 
// global styles
.ui-checkbox {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: var(--space-3);
  align-items: start;
  color: var(--color-text);
  font-size: var(--font-size-sm);
}
Enter fullscreen mode Exit fullscreen mode

Start where inconsistency hurts the most

If I were leading this effort, I would not start with the flashiest screen or the worst stylesheet. I would start with the controls that repeat so often that their inconsistency silently infects the whole product. That usually means the same short list: buttons, inputs, selects, checkboxes, field labels, and validation states.

These controls are deceptively small, but they shape almost every workflow. They appear in onboarding, settings, verification, payments, preferences, support flows, and edge case recovery screens. When they drift, the application feels unstable even if the layouts themselves are fine. When they are standardized, the product suddenly feels more polished even before the larger redesign work begins.

If the application uses raw library checkboxes in twenty places and one shared checkbox-like component in five other places, I would not try to normalize them all by editing twenty-five screens immediately. I would create one stable checkbox primitive with a product-level API and begin routing new work through it.

The migration succeeds or fails on API design

Teams often think of shared UI primitives as styling work, but they are really API design work. The reason so many “shared components” fail to gain adoption is not that their CSS is bad. It is that their API is awkward. If using the shared checkbox means fighting its inputs, working around its markup rules, or losing flexibility in rich-label cases, developers will bypass it the first time a deadline gets tight.

So the primitive must be designed around real use cases, not idealized demos. A checkbox, for example, is not just a square with a label. In a mature product, it often has to support long text, linked text, validation, hint content, disabled states, analytics hooks, form integration, and alignment that still works when the label wraps to multiple lines. If one of the existing flows already solved some of that well, I would use it as evidence. Not because that flow is perfect, but because it proves the product has already discovered what the primitive actually needs to do.

The hardest part is global CSS

Every large frontend has a file or folder that became more important than anyone intended. Sometimes it is a theme file for the component library. Sometimes it is a forms.scssmaterial-overrides.scss, or global.scss that began life as a legitimate foundation and gradually became the emergency department for the entire UI. The team knows it is risky, but it also knows that changing one selector there can solve three screens at once. That is exactly why it becomes dangerous. I would not try to delete global CSS early in the migration. That usually creates panic and replacement churn. Instead, I would change its job description.

Global CSS should define what is globally true. It should own tokens, resets, typography foundations, layout constraints that truly belong to the whole shell, and limited app-wide theming. What it should not own is the local survival strategy of individual controls. That means if a checkbox needs special label spacing, the fix should eventually live in the checkbox primitive. If one kind of input has special error presentation, that should eventually live in the field or input primitive. If a select needs a custom icon or density variant, that should be part of the primitive’s contract, not a side effect of a global selector targeting internal library classes.

What the first visible wins usually look like

A product designer asks for updated checkbox spacing and the team can implement it in one place. A validation redesign lands across several forms without every page needing local tweaks. A new settings section ships and feels consistent with older screens because it uses the shared field primitives by default. Developers start removing deep selectors not because a rule forbids them, but because they no longer need them as often.

That kind of momentum matters. CSS recovery is partly technical and partly psychological. The team needs proof that the new path is easier than the old one. If the shared components feel slower, people will drift back to direct styling. If they feel like relief, adoption becomes self-reinforcing.

This is why I would avoid introducing too many abstraction layers at once. The first generation of primitives should not try to encode every future design-system dream. They should solve the current product’s repeated pain points clearly enough that developers trust them. Once that trust exists, the system can evolve.

And only then does the Tailwind question become interesting

By this stage, the Tailwind conversation becomes much more productive. Before the migration, Tailwind would have been a debate about taste and tooling. During the migration, it would have risked becoming another parallel styling language. After the primitives and ownership model start to stabilize, it becomes a real architectural choice.

At that point the team can ask much sharper questions. Do we want utilities mainly for layout and composition while keeping primitives encapsulated? Do we want to express tokens through utility classes? Do we want Tailwind to reduce the volume of one-off page styles, but not replace shared components? Can we keep markup readable and component APIs clear? Do we have enough discipline to avoid mixing utilities, local SCSS, and deep third-party overrides in the same feature?

So if I were telling this story publicly, I would frame Tailwind not as the rescue plan, but as a potential late-stage accelerator. The rescue plan is still the same: define primitives, wrap unstable dependencies, shrink the global blast radius, and migrate by steady adoption rather than heroic cleanup. That is how large frontends usually recover. Not by one rewrite, but by a series of decisions that make the right thing easier and the wrong thing less necessary.

Top comments (0)