Dev.to · 10 min read

Building Fluentic Style: CSS Rule Order Should Not Feel Like Astrology

Building Fluentic Style: CSS Rule Order Should Not Feel Like Astrology

This is part of my Building Fluentic Style series, where I’m writing down the design decisions, tradeoffs, and small surprises from building Fluentic Style. Atomic CSS gives you a nice primitive: one CSS property/value one generated rule one reusable class That is great for dedupe. If two places produce the same atomic rule, the final CSS can emit that rule once and reuse it wherever needed. But dedupe is not the whole story. At some point, two generated rules are both valid, both target the same CSS property, and both match the element. Then the question is not: can we reuse this rule? The question is: which rule should win? And that is where CSS rule order can start feeling like astrology. Docs related to this topic: Priority Priority And Rule Order Design: Priority And Rule Order Selectors Custom Chain Methods The Simple Case Is Easy This is easy: const button = style({ backgroundColor: '#2563eb', color: '#ffffff', }); There is only one value for each property. No state. No media. No theme. No parent override. No component scope. Atomic CSS works nicely here. backgroundColor: #2563eb -> one generated background-color rule color: #ffffff -> one generated color rule Dedupe can help if the same values appear elsewhere. Nothing weird yet. Then States Start Fighting Now add hover and active: const button = style({ backgroundColor: '#2563eb', }).hover({ backgroundColor: '#1d4ed8', }).active({ backgroundColor: '#1e40af', }); When the button is only hovered, hover should win. When the button is active, active should usually win over hover. Both selectors can match at the same time. :hover :active Atomic dedupe does not answer this. The rules are different: :hover background-color :active background-color The question is order. Should active come after hover? Should disabled come after active? Should focus-visible beat hover? For common states, Fluentic’s default style builder has a priority order. Conceptually: link -> visited -> hover -> focusWithin -> focus -> focusVisible -> active -> disabled Later states win earlier states when they target the same property. So by default, active wins over hover, and disabled sits above the common interaction states. That is not magic. It is a convention encoded into the selector priority model. Semantic States Make This More Important Pseudo-classes are only the start. Design systems often have semantic states: pressed selected expanded open invalid current danger Those states usually map to selectors: [aria-pressed="true"] [aria-selected="true"] [data-state="open"] [data-tone="danger"] And those states need an order too. For example: const tab = ui({ color: '#475569', }).hover({ color: '#0f172a', }).selected({ color: '#2563eb', }); If a selected tab is also hovered, which color should win? A lot of CSS systems leave that answer to whatever rule was generated later. That can work for a while. But in a component library, source order becomes harder to trust: rules can come from different files variants can compose conditionally themes can add overrides scopes can target component parts runtime-known values can appear later extraction can reorder output for dedupe I do not want the answer to be: whichever one happened to be inserted last For design-system states, I want the priority to be explicit. Custom Selector Priority Fluentic lets a custom style builder define its own selector methods and priority order. import { createStyleFn, type CSSProperties, type } from '@fluentic/style'; import { selector, selectorPriority } from '@fluentic/style/selector'; const selectors = { hover: selector(':hover'), focusVisible: selector(':focus-visible'), active: selector(':active'), pressed: selector('[aria-pressed="true"]'), selected: selector('[aria-selected="true"]'), disabled: selector(':disabled'), }; const prioritySelectors = selectorPriority(selectors, [ 'hover', 'focusVisible', 'active', 'pressed', 'selected', 'disabled', ]); export const { style: ui } = createStyleFn({ style: type, selectors: prioritySelectors, }); The list is ordered from lower priority to higher priority. So in this builder: focusVisible beats hover active beats focusVisible pressed beats active selected beats pressed disabled beats selected Then component code can read like the design system: const trigger = ui({ color: '#475569', }).hover({ color: '#0f172a', }).pressed({ color: '#334155', }).selected({ color: '#2563eb', }).disabled({ color: '#94a3b8', }); The important part is not that Fluentic picked one perfect universal order. There is no universal order. The important part is that a design system can define the order once, then every component using that builder follows it. That gives more confidence than relying on accidental source order. Priority Is Not A Single Flat Number When I first thought about rule order, it was tempting to imagine a single score: base = 0 hover = 10 active = 20 disabled = 30 But real styling does not stay that simple. A rule can be inside a media query. A rule can come from a scope. A rule can target a child slot while a parent is hovered. A value can be intentionally weighted. A longhand property can refine a shorthand property. So Fluentic uses buckets instead of one flat score. The practical order is nested: value weight direct selector bucket parent selector bucket media bucket property bucket Lower buckets are emitted first. Higher buckets win later in the cascade. That sounds abstract, so let’s unpack it. Base, Media, And State Are Different Buckets Consider this: const button = style({ color: 'black', }).media('(max-width: 700px)', { color: 'blue', }).hover({ color: 'red', }); At a small width, the media rule can override the base color. So blue beats black. But when the button is hovered, should base media beat hover? Usually no. Hover is a state rule. So while hovered, red should still win. base color: black base media color: blue hover color: red At small width and hovered: red wins If you want hover to change at that breakpoint, write that case in the same state context: const button = style({ color: 'black', }).hover({ color: 'red', }).media( '(max-width: 700px)', style({ color: 'blue', }).hover({ color: 'purple', }), ); Now there is a hover rule inside the media context. At small width and hovered: purple wins That is more explicit. Media does not jump over every selector just because it appeared later. It wins inside its context. Parent State And Direct State Are Different Too Component scopes make this even more interesting. Imagine a card with a button slot: const card = { button: style.slot({ color: 'black', }).active({ color: 'red', }), }; Now a parent hover scope wants to change the button: const cardHover = style.scope().hover([ card.button({ color: 'blue', }), ]); This means: when the card scope is hovered, make the button blue But the button itself also has an active state: when the button is active, make it red If the card is hovered and the button is active, what should win? Fluentic treats these as different contexts: parent selector -> button color blue direct selector -> button color red The direct button active state should not be accidentally crossed by an unrelated parent hover override. So red can still win. If the design really wants the card-hover + button-active case to win, write that case directly: const cardHoverButtonActive = style.scope().hover([ card.button().active({ color: 'purple', }), ]); Now the authored code says exactly what is happening: when the card is hovered and the button is active make the button purple That is much easier to trust than hoping a generated CSS order happens to line up. Value Weight Is The Escape Hatch Sometimes a design-system rule needs to sit above normal selector and media behavior. For that, Fluentic has value weight: const button = style({ color: style.weight('#111827', 1), }).hover({ color: '#2563eb', }); Here the weighted base color can beat the unweighted hover color. That is not something I expect most app code to use often. It is an escape hatch for design-system authors. The rule is: use normal composition first use selector priority for state conventions use value weight only when a rule intentionally needs to sit above normal behavior I like having this escape hatch because it is explicit. If a value is unusually strong, the source code says so. No mystery specificity trick. No random !important. No hoping a file loads later. Property Order Still Matters CSS has shorthand and longhand relationships. This should work: const box = style({ margin: 4, marginTop: 8, }); marginTop should refine margin. So property order is part of the bucket model too. Longhands can be emitted after shorthands they refine. That is a small detail, but it matters if the generated CSS is atomic. Because margin and margin-top are separate generated rules. Atomic output makes this explicit: margin margin-top The rule order still has to preserve CSS expectations. Layers Or Sorted Output In Development During development, rule order is also something you may want to inspect. Fluentic dev utilities can switch priority output: StyleDevUtils.setPriorityMode.toLayer(); StyleDevUtils.setPriorityMode.toSort(); Layer mode makes priority buckets easier to see. Sort mode is closer to compact generated output. That is useful because debugging order problems is not only about the final winner. Sometimes I want to understand the buckets themselves. which bucket is this rule in? why did this one appear later? is this scope context or direct selector context? Making that visible helps rule order feel less mysterious. Dedupe Is Good, But Confidence Needs Order Atomic dedupe answers one kind of question: can identical rules share output? Priority answers a different question: when multiple rules match, which one wins? Both matter. If you only optimize for dedupe, rule order can become a side effect of the implementation. If you only rely on CSS source order, component composition can become fragile as the app grows. Fluentic tries to make rule order follow the structure of the authored style: value weight selector context scope context media context property relationship The generated CSS still uses the normal cascade. But the ordering model comes from the style data, not from vibes. That is the important part for me. Where This Leaves Fluentic I do not want developers to tune priority all day. Most app code should not think about this much. You should write: style({ color: 'black', }).hover({ color: 'blue', }).active({ color: 'red', }); and get the expected result. But when a design system grows, the edge cases start to matter: custom semantic states component scopes theme overrides parent-provided styles responsive variants shorthand/longhand conflicts escape hatches That is where rule order should be predictable. Not because Fluentic ignores CSS. Because Fluentic uses CSS with a stable generated order. That is the goal: atomic CSS for dedupe priority buckets for confidence source structure for debugging CSS rule order should not feel like astrology. It should feel like something you can explain from the code you wrote. Fluentic Style is still new and currently in beta. I am looking for early users to try it in real React, Next.js, Preact, Solid, and component-library codebases. Useful links: Docs Priority Priority And Rule Order Selectors GitHub npm: @fluentic/style Feedback would help a lot right now, especially around selector priority, component scopes, media behavior, generated CSS order, and whether this priority model matches the way you expect component styles to override each other.

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More Programming & Dev News