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.
In the previous post, I wrote about how I did not want a Tailwind preset, then ended up adding two.
One is object-based.
One is class-name-based.
That sounded like a Tailwind feature from the outside, but internally it became something bigger for Fluentic.
The interesting part was not only Tailwind.
The interesting part was the lower-level system underneath it:
object style chains
class-name style chains
custom transforms
custom selector methods
typed validation
runtime resolution
CSS extraction
debug metadata
The Tailwind presets are just one use of that system.
The more general idea is that Fluentic can host different styling dialects.
The Tailwind Presets Are Built On Public Pieces
The Tailwind docs are here:
But the important thing is that these presets are not a separate secret mode.
They are built from the same lower-level APIs that users can reach for:
- createStyleFn
- Transforms
- Custom style fields
- Custom chain methods
- Custom style builders
- Class-name style chains
- Selectors
That matters because Tailwind is not the end of the story.
A team might want Tailwind-like names.
Another team might want design-system words like row, center, tone, surface, pressed, or below.
Another team might want class names because that is how their UI code already reads.
Fluentic should not require all of those teams to write styles with one exact vocabulary.
Two Inputs, Same Fluentic Path
Fluentic now has two main custom authoring paths.
Object-based chains:
ui({
row: true,
center: true,
gapX: 8,
}).selected({
backgroundColor: '#eef2ff',
});
Class-name-based chains:
cx('row', 'center', 'gap-x-8')
.selected('bg-selected');
Those look different.
But they can both become Fluentic style data:
object fields -> transform -> Fluentic style data
class names -> transform -> Fluentic style data
chain methods -> selectors, media, states
slots/scopes -> component styling boundaries
compiler -> CSS extraction for production output
debug tools -> source-aware generated CSS
That is the whole point.
The syntax can change without replacing the rest of the styling system.
Object Chains: Custom Fields With Pure TypeScript
The object-based path uses createStyleFn(...).
You define the style shape you want, then write a transform that turns your custom fields into normal CSS properties.
For example, a small layout dialect:
import {
createStyleFn,
styleTransform,
type CSSProperties,
type,
} from '@fluentic/style';
import { selector } from '@fluentic/style/selector';
type LayoutStyle = CSSProperties & {
row?: boolean;
column?: boolean;
center?: boolean;
gapX?: CSSProperties['columnGap'];
gapY?: CSSProperties['rowGap'];
};
const layoutTransform = styleTransform<LayoutStyle>({
transform(style) {
const { row, column, center, gapX, gapY, ...css } = style;
if (row || column) {
css.display = 'flex';
css.flexDirection = row ? 'row' : 'column';
}
if (center) {
css.alignItems = 'center';
css.justifyContent = 'center';
}
if (gapX !== undefined) css.columnGap = gapX;
if (gapY !== undefined) css.rowGap = gapY;
return css;
},
});
const selectors = {
hover: selector(':hover'),
selected: selector('[aria-selected="true"]'),
};
export const { style: ui } = createStyleFn({
style: type<LayoutStyle>,
selectors,
transform: layoutTransform,
});
Then component code can use those custom fields:
const tab = ui({
row: true,
center: true,
gapX: 8,
paddingInline: 12,
paddingBlock: 8,
}).selected({
backgroundColor: '#eef2ff',
});
The transform is just TypeScript.
No plugin DSL.
No compiler macro language.
No special config syntax just to say “when row is true, write flex row CSS.”
You destructure the custom fields, write normal CSS properties, and return the remaining object.
That is why I like this part of Fluentic a lot. Custom transforms stay boring in the best possible way.
Class-Name Chains: Custom Names With createClassNameFn
The class-name path uses createClassNameFn(...).
Instead of transforming an object, you transform each class name.
import {
classNameTransform,
createClassNameFn,
type CSSProperties,
} from '@fluentic/style';
import { selector } from '@fluentic/style/selector';
type UiClassName =
| 'row'
| 'center'
| 'gap-x-8'
| 'bg-selected'
| 'opacity-muted';
const transform = classNameTransform<UiClassName>({
transform(className) {
const css: CSSProperties = {};
if (className === 'row') {
css.display = 'flex';
css.flexDirection = 'row';
}
if (className === 'center') {
css.alignItems = 'center';
css.justifyContent = 'center';
}
if (className === 'gap-x-8') {
css.columnGap = 8;
}
if (className === 'bg-selected') {
css.backgroundColor = '#eef2ff';
}
if (className === 'opacity-muted') {
css.opacity = 0.64;
}
return css;
},
});
const selectors = {
hover: selector(':hover'),
selected: selector('[aria-selected="true"]'),
};
export const { className: cx } = createClassNameFn({
selectors,
transform,
});
Now the authoring style is class-name based:
const tab = cx('row', 'center', 'gap-x-8')
.selected('bg-selected')
.hover('opacity-muted');
And because the class-name type is a TypeScript union, invalid names can be caught:
cx('row', 'gap-x-8'); // ok
cx('gap-x-999');
// ^ TypeScript error
For a tiny design-system dialect, a union is enough.
For a larger one, you can use template literal types, lookup tables, generated maps, helper functions, whatever TypeScript already gives you.
Again, the transform is just regular TypeScript logic.
Custom Chain Methods Are Where It Gets Interesting
Custom fields and class names decide what CSS declarations exist.
Custom chain methods decide when those declarations apply.
This is where Fluentic’s selector system becomes important.
Instead of repeating raw selectors everywhere:
ui().select('&[data-state="open"]', {
opacity: 1,
});
You can turn shared selector conventions into named methods:
ui().open({
opacity: 1,
});
The lower-level selector syntax is small, but it carries a lot:
import {
assertEnumSelector,
selector,
selectorPriority,
} from '@fluentic/style/selector';
const selectors = {
hover: selector(':hover'),
focusVisible: selector(':focus-visible'),
open: selector('[data-state="open"]'),
tone: selector(
'[data-tone="$"]',
assertEnumSelector('neutral', 'danger'),
),
state: selector(
'[data-state="$$"]',
assertEnumSelector('open', 'closed'),
),
below: selector('@media (max-width: $$)', 'media'),
};
const prioritySelectors = selectorPriority(selectors, [
'hover',
'focusVisible',
'open',
]);
Then those selectors become typed chain methods:
const menuButton = ui({
border: 0,
padding: '8px 12px',
}).open({
backgroundColor: '#eef2ff',
}).tone('danger', {
color: '#b91c1c',
}).below('700px', {
padding: '6px 10px',
});
The method signatures come from the selector definitions.
A selector with no argument:
open: selector('[data-state="open"]')
becomes:
.open(style)
A selector with one $ argument:
tone: selector('[data-tone="$"]', assertEnumSelector('neutral', 'danger'))
becomes:
.tone('neutral' | 'danger', style)
A selector with $$ can accept one value or an array:
state: selector('[data-state="$$"]', assertEnumSelector('open', 'closed'))
becomes:
.state('open', style)
.state(['open', 'closed'], style)
An at-rule selector:
below: selector('@media (max-width: $$)', 'media')
becomes a media-style chain method:
.below('700px', style)
And the validation is not only runtime convention. The validator can narrow the TypeScript signature:
ui().tone('danger', {
color: '#b91c1c',
});
ui().tone('warning', {
color: '#92400e',
});
// ^ TypeScript error if "warning" is not in the enum selector
That is the part I care about.
A design system can move from selector strings by memory to typed methods with a central definition.
[data-state="open"] -> .open(...)
[data-tone="danger"] -> .tone('danger', ...)
@media (max-width: 700px) -> .below('700px', ...)
The selector stays CSS-shaped.
The authoring API becomes TypeScript-shaped.
The Same Selector Methods Work For Class Names
The selector system is not only for object styles.
The class-name chain gets the same methods:
const trigger = cx('row', 'center', 'gap-x-8')
.open('bg-selected')
.tone('danger', 'text-danger')
.below('700px', 'gap-x-4');
That is why the Tailwind class-name preset can keep Tailwind-like names while still using Fluentic chains for conditions.
Class names are the vocabulary.
Chain methods are the structure.
cx('bg-blue-600')
.hover('bg-blue-700')
.md('px-6')
Instead of making hover: and md: part of the string itself, Fluentic keeps those as methods.
That makes the condition explicit in the style chain, and it keeps the same extension path open for app-specific states:
cx('bg-surface')
.open('shadow-lg')
.tone('danger', 'border-danger')
.below('640px', 'px-3');
Why This Reduces Limits
This is what the Tailwind preset work taught me.
If Fluentic only supported CSS object fields, it would be nice for people who like object styles.
If Fluentic only supported class names, it would be familiar for utility-first users.
But supporting both makes the lower-level idea much more useful.
A team can choose the authoring shape that fits the code:
direct CSS object
good for precise component styles
custom object dialect
good for design-system fields and typed shortcuts
custom class-name dialect
good for utility naming and migration from existing code
custom selector methods
good for shared states, breakpoints, data attributes, and component conventions
And these are not separate systems.
They all still flow into Fluentic’s component styling pieces:
-
cssprop -
style.slot(...)/cx.slot(...) -
style.scope(...)/cx.scope(...) combineStyle(...)- tokens and themes
- runtime resolution
- production CSS extraction
- debug source metadata
That means the user gets more freedom at the top without losing the shared behavior underneath.
You can build a Tailwind-like dialect.
You can build a design-system dialect.
You can build a small team-local vocabulary.
You can keep raw CSS properties where that is clearer.
The point is not to hide CSS forever.
The point is to let teams choose the right words while still keeping styles composable inside a component system.
Extraction And Debugging Still Matter
There is one more upside that I think is important.
Custom fields, custom class names, and custom transform functions can sound like they would push you outside the normal styling pipeline.
In Fluentic, they do not.
You are still writing Fluentic style chains. The custom pieces only decide the vocabulary you write at the top. The result still has the same structure underneath: style parts, selectors, slots, scopes, themes, and values.
That is intentional.
Fluentic emits atomic CSS in both development and production. Because the custom vocabulary still goes through the style-chain structure, the compiler and bundler plugins can recognize it and apply the same production path: code optimization, extracted atomic CSS, and prepared JavaScript output.
This is not only for the boring case where a style object sits at module scope and never changes.
Real component code has conditions, props, themes, scopes, and values that only exist at runtime. Fluentic is designed for that. The compiler extracts CSS from the style-chain structure, while the runtime uses the prepared style data to pick up runtime-known values, resolve selected scopes and themes, and attach the right generated classes.
The important part is that you do not have to switch APIs when a style depends on component state or props.
It is still regular TypeScript. You can branch, pass values around, compose scopes, and let the rendered app choose the final styling inputs.
That was important to me.
I did not want custom style dialects to feel like a cute dev-only layer that breaks once the app becomes dynamic.
In development, the same structure gives Fluentic enough information to attach debug metadata and sourcemaps to generated atomic CSS. I will cover that more in the next post.
This also changes how I think about transform performance.
A custom transform can look a little heavier than a normal style object:
const transform = styleTransform<LayoutStyle>({
transform(style) {
const { row, center, gapX, ...css } = style;
if (row) {
css.display = 'flex';
css.flexDirection = 'row';
}
if (center) {
css.alignItems = 'center';
css.justifyContent = 'center';
}
if (gapX !== undefined) {
css.columnGap = gapX;
}
return css;
},
});
At first glance, it is fair to wonder:
“Am I shipping this function to the browser and running it for every style?”
In production builds with the Fluentic plugin, the answer is usually no.
The transform runs during the build for extracted style chains. The plugin turns the authored style chain into prepared output and generated CSS. By the time the production JavaScript reaches the browser, the transform function is no longer part of the hot path for those extracted styles.
That means you do not need to obsess over every byte or every branch in a transform function the same way you would for code running on every render.
Write the transform clearly first.
Of course, development mode still runs transforms so the authoring experience can stay flexible and immediate. So I still try not to make transforms absurdly slow.
But the tradeoff is much nicer than it looks at first:
development -> transform runs for flexibility and fast iteration
production -> plugin runs transform during build and emits extracted CSS
browser -> consumes prepared style output instead of rerunning the transform
That made custom transforms feel much more practical to me.
They can stay plain TypeScript instead of becoming a tiny hand-optimized compiler language.
The same applies to debugging.
In development, Fluentic still emits atomic CSS, but the generated CSS can carry source information. Each generated CSS item can trace back to the style, field, chain call, slot, scope, or JSX css prop that produced it.
So when you inspect generated CSS in browser DevTools, the path back to source should not feel like guessing.
You should not have to wonder:
Which helper produced this class?
Which style chain added this hover rule?
Which slot override changed this property?
Which JSX element received this css prop?
The generated CSS should lead you back to the original code.
No special browser extension should be required for the basic workflow. It should work with normal browser DevTools and sourcemaps.
I will cover the debugging details in the next post, because this deserves its own space.
But this is one of the reasons I liked the custom style system more after adding the Tailwind presets.
The custom syntax is not outside Fluentic.
It becomes Fluentic.
Where This Leaves The Tailwind Presets
So yes, Fluentic has two Tailwind presets:
But the part I am more excited about is what they proved.
Fluentic can support:
- object-based style dialects
- class-name-based style dialects
- custom transforms written as pure TypeScript
- custom selector methods with typed arguments
- centralized selector validation
- component slots and scopes on top
- production CSS extraction
- source-aware development debugging
The Tailwind presets are useful by themselves.
But they also showed me the bigger shape.
Fluentic Style is not just one styling vocabulary.
It is a way to make styling vocabularies work inside a typed, composable JSX styling system.
Fluentic Style is still new and currently in beta. I am looking for early users to try it in real React, Preact, Solid, and component-library codebases.
Useful links:
Feedback would help a lot right now, especially around custom style builders, Tailwind preset coverage, selector methods, build integrations, and whether this styling-language approach feels useful in real projects.
Top comments (0)