If you have ever built a CSS utility library, a design system, or even a small set of helper classes, you have hit two classic CSS cascade walls:

  1. Multi-value properties overwrite each other. Applying both .transition-colors and .transition-transform to the same element means whichever rule comes later in the stylesheet wins—and silently deletes the other.
  2. Default values fight overrides. If .transition sets a default transition-duration: 200ms, then writing class="duration-500 transition" can break depending on the order those classes were defined in your CSS file, not the order you wrote them in HTML.

Most frameworks work around these issues with JavaScript class-merging utilities (tailwind-merge), bloated combinatorial classes, or !important.

Modern CSS gives us a much cleaner, zero-runtime solution. By combining CSS @property with zero-specificity :where() selectors, you can build composable, completely order-independent CSS utilities that never leak into child elements.

Here is how the architecture works in practice.

Problem 1: transition-property Is All-or-Nothing

In CSS, transition-property accepts a comma-separated list, not additive rules. Suppose you write two standard utility classes:

.transition-colors { transition-property: color, background-color, border-color; transition-duration: 200ms; transition-timing-function: ease-in-out; } .transition-transform { transition-property: transform; transition-duration: 200ms; transition-timing-function: ease-in-out; } 

When you put both on a button:

<button class="transition-colors transition-transform">Save</button> 

Because .transition-transform appears later in the stylesheet, the browser computes transition-property: transform and completely drops your color transitions. Swapping the class order in your HTML does nothing—CSS cascade order is determined by the stylesheet, not the class attribute.

The Fix: Custom Property Slots + @property

Instead of having each utility write a different transition-property list, give every transition group its own custom property slot, and have all of them write the exact same combined transition-property list:

@property --transition-colors { syntax: "*"; inherits: false; } @property --transition-opacity { syntax: "*"; inherits: false; } @property --transition-shadow { syntax: "*"; inherits: false; } @property --transition-transform { syntax: "*"; inherits: false; } 

Each utility class now only toggles its own slot:

.transition-colors { --transition-colors: color, background-color, border-color, text-decoration-color, fill, stroke; } .transition-transform { --transition-transform: transform; } .transition-colors, .transition-opacity, .transition-shadow, .transition-transform { transition-property: var(--transition-colors, --none), var(--transition-opacity, --none), var(--transition-shadow, --none), var(--transition-transform, --none); } 

Look closely at what happens when an element has class="transition-colors transition-transform":

  1. --transition-colors resolves to color, background-color, ...
  2. --transition-transform resolves to transform
  3. Unset slots fall back to --none—an unanimateable custom property name that browsers safely ignore.
  4. Both properties transition simultaneously, regardless of which class came first in CSS or HTML.

Why @property Is Mandatory Here

You might wonder why we registered those variables with @property instead of just using plain --transition-colors variables.

Inheritance leaks. Normal CSS custom properties inherit down the DOM tree. Without inherits: false, putting .transition-colors on a parent card would set --transition-colors for every child inside that card. The moment a child element used .transition-transform, it would accidentally inherit its parent's --transition-colors value too.

Registering each variable with inherits: false scopes the slot strictly to the element itself. Zero leakage into descendants.

(Bonus: This exact same pattern solves composable transform utilities—--translate-x, --scale-x, --rotate—and gap-aware flex layouts without inheritance bugs.)

Problem 2: Defaults Overriding Explicit Modifiers

Now look at the second half of those rules: default duration and easing.

If .transition-colors sets transition-duration: 200ms directly at normal class specificity (0, 1, 0), you immediately hit a source-order trap:

/* Defined early in your stylesheet */ .duration-500 { transition-duration: 500ms; } /* Defined later in your stylesheet */ .transition-colors { transition-duration: 200ms; } 

Even if a developer writes class="transition-colors duration-500", the duration stays stuck at 200ms because .transition-colors was declared lower in the CSS file.

The Fix: Zero-Specificity :where()

The :where() pseudo-class always has 0 specificity (0, 0, 0). By separating the property declaration from its default timing declarations, any explicit modifier wins automatically:

/* Specificity: (0, 0, 0) */ :where(.transition, .transition-colors, .transition-opacity, .transition-shadow, .transition-transform) { transition-duration: var(--duration-200); transition-timing-function: var(--ease-in-out); } /* Specificity: (0, 1, 0) — always beats :where() */ .duration-500 { transition-duration: var(--duration-500); } 

Now:

  • class="transition-colors" gets 200ms and ease-in-out out of the box.
  • class="transition-colors duration-500" gets 500ms.
  • class="duration-500 transition-colors" also gets 500ms.
  • No !important, no duplicate classes, and no JavaScript class-merging runtime required.

Scaling This Into a Sass Mixin Engine

When you generate this from Sass, you can expose the exact same architecture both as HTML utility classes and as composable mixins inside an apply() dispatcher:

.card { @include apply( transition-colors, transition-shadow, duration(300), ease(out), shadow(md) hover ); } 

Which compiles down to clean, deterministic CSS:

.card { --wu-transition-colors: color, background-color, border-color, text-decoration-color, fill, stroke; --wu-transition-shadow: box-shadow; transition-property: var(--wu-transition-colors, --wu-none), var(--wu-transition-opacity, --wu-none), var(--wu-transition-shadow, --wu-none), var(--wu-transition-transform, --wu-none); transition-duration: var(--wu-duration-300); transition-timing-function: var(--wu-ease-out); } .card:hover { box-shadow: var(--wu-shadow-md); } 

Wrapping Up

Modern CSS primitives like @property, :where(), @layer, and native <dialog> / popover APIs eliminate a huge amount of the complexity we used to solve with build-time JS scanners and runtime class mergers.

We built this entire pattern into WovenUI—an open-source, CSS-first design system and 54-component UI kit powered by Dart Sass with compile-time token validation and zero-JS native HTML components.

If you want to inspect the full Sass source or try the live compiler playground, check out the repos on GitHub:

(If you find the architecture useful, dropping a star on GitHub helps a ton as we push toward our first community milestones!)