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:
- Multi-value properties overwrite each other. Applying both
.transition-colorsand.transition-transformto the same element means whichever rule comes later in the stylesheet wins—and silently deletes the other. - Default values fight overrides. If
.transitionsets a defaulttransition-duration: 200ms, then writingclass="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":
-
--transition-colorsresolves tocolor, background-color, ... -
--transition-transformresolves totransform - Unset slots fall back to
--none—an unanimateable custom property name that browsers safely ignore. - 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"gets200msandease-in-outout of the box. -
class="transition-colors duration-500"gets500ms. -
class="duration-500 transition-colors"also gets500ms. - 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:
- Core Engine: github.com/WovenUI/core
- UI Components: github.com/WovenUI/components
- Wovenui Playground wovenui.com/playground
(If you find the architecture useful, dropping a star on GitHub helps a ton as we push toward our first community milestones!)
