DEV Community

OmniDev
OmniDev

Posted on

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
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

The question is:

which rule should win?
Enter fullscreen mode Exit fullscreen mode

And that is where CSS rule order can start feeling like astrology.

Docs related to this topic:

The Simple Case Is Easy

This is easy:

const button = style({
  backgroundColor: '#2563eb',
  color: '#ffffff',
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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',
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Atomic dedupe does not answer this.

The rules are different:

:hover background-color
:active background-color
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Those states usually map to selectors:

[aria-pressed="true"]
[aria-selected="true"]
[data-state="open"]
[data-tone="danger"]
Enter fullscreen mode Exit fullscreen mode

And those states need an order too.

For example:

const tab = ui({
  color: '#475569',
}).hover({
  color: '#0f172a',
}).selected({
  color: '#2563eb',
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

I do not want the answer to be:

whichever one happened to be inserted last
Enter fullscreen mode Exit fullscreen mode

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<CSSProperties>,
  selectors: prioritySelectors,
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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',
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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',
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

At small width and hovered:

red wins
Enter fullscreen mode Exit fullscreen mode

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',
  }),
);
Enter fullscreen mode Exit fullscreen mode

Now there is a hover rule inside the media context.

At small width and hovered:

purple wins
Enter fullscreen mode Exit fullscreen mode

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',
  }),
};
Enter fullscreen mode Exit fullscreen mode

Now a parent hover scope wants to change the button:

const cardHover = style.scope().hover([
  card.button({
    color: 'blue',
  }),
]);
Enter fullscreen mode Exit fullscreen mode

This means:

when the card scope is hovered, make the button blue
Enter fullscreen mode Exit fullscreen mode

But the button itself also has an active state:

when the button is active, make it red
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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',
  }),
]);
Enter fullscreen mode Exit fullscreen mode

Now the authored code says exactly what is happening:

when the card is hovered
and the button is active
make the button purple
Enter fullscreen mode Exit fullscreen mode

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',
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

Priority answers a different question:

when multiple rules match, which one wins?
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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',
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

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.

Top comments (0)