Fluentic is my attempt to make React styling feel explicit, composable, and predictable again, without giving up the ergonomics developers expect.
Years ago, I worked on web apps with Emotion, and I loved it.
It was flexible, productive, and convenient. But over time, some of the codebases I worked on became harder and harder to change. I do not blame Emotion for that. The problem was how I used the convenience it gave me.
I wrote class names that depended on DOM structure. I used nested selectors because they were quick. I reached into child elements from parent styles. It worked, until months later I had to change something and no longer felt confident about what else would break.
That feeling is what I remember most.
Not that the styling was impossible. Not that the tool was bad. Just that I slowly lost confidence when changing UI.
The Problem Was Confidence
A lot of frontend styling problems are not about whether something is technically possible.
CSS is powerful. CSS-in-JS is powerful. Utility classes are powerful. CSS Modules are useful. Static extraction is useful.
The hard part is usually confidence.
Can I change this component without accidentally changing another component?
Can I understand where this style comes from?
Can I tell which style wins?
Can I move this element without breaking some selector far away?
Can I delete this class name?
Can I add a variant without making the component more fragile?
When styles depend on class names, DOM structure, nested selectors, and implicit relationships between elements, every change becomes a little less obvious.
You can still build good systems this way. Many teams do.
But that way of styling makes it very easy to create hidden coupling.
And hidden coupling is where confidence starts to fade.
Then I Spent Years in React Native
After that, I spent years mostly working on React Native projects.
React Native styling changed how I think about UI.
The styling model is much smaller:
<View style={[styles.root, disabled && styles.disabled, style]} />
Enter fullscreen mode Exit fullscreen mode
Styles are objects.
Styles are passed as values.
Arrays compose styles.
The later style wins.
Variants are usually explicit.
The style belongs close to the element that uses it.
There is no class name pretending to be both identity and styling hook. There is no global selector reaching through some DOM structure. There is much less “where did this come from?”
Of course React Native styling is not perfect. It has its own limitations. But the mental model is very simple.
And after using that model for years, I came back to React web.
That was when web styling felt more complicated to me than it used to.
Not because CSS got worse.
Actually, CSS got much better.
We have better layout. Better selectors. Better browser support. Better build tools. Better frameworks.
But for component-heavy React work, I started to miss the React Native mental model a lot.
In React Native, this kind of style composition is completely normal:
<View style={[base, variant, state, props.style]} />
Enter fullscreen mode Exit fullscreen mode
And that normality matters.
Base first. Then the variant. Then the current state. Then the style passed from outside.
It makes styling feel like data. Like values. Like composition.
Not like a string contract with the DOM.
React web already has pieces of this too. Emotion’s css prop can feel especially close, and other tools have their own ways to compose styles.
However the thing I wanted was not just style composition for one element.
React Native does not solve every part of this either. When a component has multiple internal elements, you still need to decide which parts can be styled and how those styles are passed through.
I wanted the same explicit composition to work not only for one root element, but across a whole styleable component: root, icon, label, header, content, footer, or whatever parts the component chooses to expose.
Not just root element composition:
<Component css={[base, variant, props.css]} />
Enter fullscreen mode Exit fullscreen mode
But a component style theme where each public part can be composed explicitly, without the caller reaching into DOM structure or relying on class names as hidden hooks, with something as simple as this:
<Component theme={[baseTheme, variantTheme, props.theme]} />
Enter fullscreen mode Exit fullscreen mode
That is where Fluentic started to feel different.
React Web Has Something React Native Misses
But I do not want to pretend React Native has the perfect model.
It does not.
The web has something extremely useful: selectors.
Sometimes selectors are exactly what you want.
You want hover states:
.button:hover {
background: var(--hover);
}
Enter fullscreen mode Exit fullscreen mode
You want focus states.
You want child coordination.
You want media queries.
You want pseudo elements.
You want to style based on parent state.
You want to express relationships without manually passing a style prop into every single element.
That is real power.
React Native often makes this more manual. You wire state. You pass style props. You conditionally apply styles element by element.
That explicitness is nice until it becomes repetitive.
So the question I kept thinking about was:
Can React styling keep the simple React Native mental model, but still keep the useful parts of CSS?
Can styles be values, while selectors still exist?
Can composition be explicit, while output is still real CSS?
Can override order be obvious, while runtime work stays small?
That question became the beginning of Fluentic.
Extraction Is Not the New Idea
Modern React styling has changed a lot.
Runtime CSS-in-JS used to feel like a reasonable default for many React apps. Today, the tradeoffs are more visible.
Performance matters.
Server rendering matters.
Streaming matters.
React Server Components changed what can happen on the server and what should happen on the client.
Build-time extraction is no longer a niche idea. In many modern stacks, it feels close to mandatory.
But extraction itself is not the interesting part anymore.
There are already many libraries that extract CSS.
That is not the main reason I started working on Fluentic.
The thing I felt missing was not “another extracted CSS-in-JS library”.
The thing I wanted was a different way to write and compose styles.
A model closer to what I had in my head after years of React Native:
- styles as values
- explicit composition
- predictable override order
- local ownership
- variants without magic class names
- selectors when useful, but not as the default way to connect everything
- build-time output that fits modern React apps
That is the shape of Fluentic.
What I Want From a Styling System
When I started thinking about Fluentic, I wrote down the requirements more like a frontend developer who has been hurt by his own past decisions than like someone trying to invent a styling library.
The requirements were not only technical.
They were emotional too.
I wanted to feel confident changing UI again.
1. Styles Should Be Values
In React Native, a style is something you can pass around.
<View style={styles.card} />
Enter fullscreen mode Exit fullscreen mode
Or compose:
<View style={[styles.card, elevated && styles.elevated]} />
Enter fullscreen mode Exit fullscreen mode
That feels very different from:
<div className={`${styles.card} ${elevated ? styles.elevated : ""}`} />
Enter fullscreen mode Exit fullscreen mode
The class name version works, of course. But the mental model is different.
One feels like composing values.
The other feels like constructing a string that points to styling somewhere else.
In Fluentic, the equivalent idea uses style(...) and the JSX css prop:
import { style } from '@fluentic/style';
const styles = {
card: style({
padding: 16,
borderRadius: 8,
backgroundColor: 'white',
}),
elevated: style({
boxShadow: '0 12px 30px rgb(15 23 42 / 0.16)',
}),
};
function Card({ elevated }: { elevated?: boolean }) {
return (
<section css={[styles.card, elevated && styles.elevated]}>
Ready to style.
</section>
);
}
Enter fullscreen mode Exit fullscreen mode
The syntax is different from React Native, because the target is still the browser.
But the feeling is familiar:
Base style first.
Conditional style after.
The style value is passed directly to the element.
2. Override Order Should Be Obvious
One of the nicest things about React Native style arrays is that the later item wins.
<View style={[styles.base, styles.large, props.style]} />
Enter fullscreen mode Exit fullscreen mode
You can read that line and understand the override order.
Base first.
Then large.
Then external style.
There is very little mystery.
On the web, override order can come from many places:
- stylesheet order
- selector specificity
- class order
- nested selectors
- cascade layers
- inline styles
- generated CSS order
Some of these are powerful. Some are necessary. But when building components, I often want the boring rule:
later style wins
Fluentic is designed around that kind of predictability.
<button css={[styles.button, primary && styles.primary, props.css]}>
Save
</button>
Enter fullscreen mode Exit fullscreen mode
That is the kind of composition I want to keep visible in React code.
3. Variants Should Be Explicit
I have written many components where styles were controlled by a mix of class names and nested selectors.
Something like:
.card.compact .title {
font-size: 14px;
}
.card.highlighted .title {
color: blue;
}
Enter fullscreen mode Exit fullscreen mode
This is convenient.
It also couples the parent state, child structure, and class names together.
Later, when the component changes, the style assumptions are still sitting there.
For a local component, explicit composition can be enough:
const styles = {
card: style({
padding: 16,
borderRadius: 8,
backgroundColor: 'white',
}),
compactCard: style({
padding: 12,
}),
title: style({
fontSize: 20,
fontWeight: 700,
}),
compactTitle: style({
fontSize: 16,
}),
};
function Card({ compact }: { compact?: boolean }) {
return (
<div css={[styles.card, compact && styles.compactCard]}>
<h2 css={[styles.title, compact && styles.compactTitle]}>
Title
</h2>
</div>
);
}
Enter fullscreen mode Exit fullscreen mode
This may look less magical.
That is the point.
A little less magic often means a lot more confidence later.
4. Reusable Components Need More Than Root Styling
Root-level styling is only the small case.
Reusable React components often have internal parts.
A button may have a root, an icon, and a label.
A card may have a root, header, title, content, and footer.
A caller might need to customize those parts without depending on DOM structure.
With normal class names, the public styling API often becomes accidental:
<button className="button">
<span className="button__icon" />
<span className="button__label">Save</span>
</button>
Enter fullscreen mode Exit fullscreen mode
That works.
But now the caller knows your class names.
And if they use selectors, they may also start to know your markup.
Fluentic uses slots for this.
A slot is a named styling target that a component chooses to expose:
import { style } from '@fluentic/style';
const buttonStyles = {
root: style.slot({
display: 'inline-flex',
alignItems: 'center',
gap: 8,
border: 0,
borderRadius: 8,
padding: '8px 12px',
}),
icon: style.slot({
display: 'inline-flex',
width: 16,
height: 16,
}),
label: style.slot({
fontWeight: 650,
}),
};
Enter fullscreen mode Exit fullscreen mode
This says something important:
root, icon, and label are allowed styling targets.
The DOM can stay private.
The component decides what it exposes.
5. Selectors Should Exist, But They Should Not Become the Whole Model
I still want hover and focus styles to feel natural.
In Fluentic, a simple element style can use fluent selector/state chains:
const button = style({
backgroundColor: 'white',
border: '1px solid var(--color-border)',
}).hover({
backgroundColor: 'var(--color-surface-hover)',
}).focusVisible({
outline: '2px solid var(--color-focus)',
outlineOffset: 2,
});
Enter fullscreen mode Exit fullscreen mode
Hover and focus states belong naturally in CSS.
I do not want to manually wire every interaction state in JavaScript just to avoid selectors.
But I also do not want selectors to become the main way components talk to each other.
Selectors are useful.
Selectors are also where hidden relationships often begin.
So Fluentic should support selectors, but still encourage local, explicit style ownership.
6. Runtime Work Should Be Small
Modern React apps already have enough work to do at runtime.
Styling should not require unnecessary client-side computation when the result can be known at build time.
This matters even more with SSR and React Server Components.
So Fluentic needs extraction.
But again, extraction is the implementation requirement, not the whole product idea.
The product idea is the way styles are written and composed.
7. Debugging Should Lead Back to the Code
This does not happen every day, but when it happens, it is painful.
I have had simple style issues take much longer than they should because tracing the final rule back to the code was not clear enough. Sometimes the source map was not precise. Sometimes the class name pointed me to generated output, but not to the exact style code that produced it.
That kind of debugging makes me lose confidence quickly.
Atomic CSS has a real benefit: it can keep the final CSS output small. But it also means the browser often shows many generated class names in the HTML. That tradeoff is acceptable only if those class names can still be traced back to the style code that created them.
If the browser shows an atomic class name, I still need a way to answer:
- which code created this rule?
- which component owns it?
- did this come from the base style, a variant, a scope, or an incoming override?
- why did this style win?
Because styling bugs are often not about one declaration. They are about the path that declaration took into the UI.
So Fluentic is not only about producing CSS. It is also about making generated styles easier to trace back to the React code that created them.
8. App Themes Should Not Be an Afterthought
There are two things many apps do not need on day one, but often wish they had prepared for from the beginning: i18n and theming.
At first, it is easy to assume the app only needs one language, one brand, one density, and one visual mode.
Then later the app needs dark mode. Or a different brand theme. Or a denser admin layout. Or product-specific styling.
That is when theming becomes painful.
Not because dark mode is hard by itself, but because the codebase was not prepared for styles to change at the app level.
So Fluentic has to support theming without forcing every value to become a carefully named token from day one.
Named tokens are useful when the name matters, which is usually when a component exposes values that other code is expected to override intentionally:
const card = createTokens({
surface: '#ffffff',
text: '#111827',
border: '#d8e3df',
});
const cardStyles = {
root: style.slot({
backgroundColor: card.surface,
color: card.text,
border: '1px solid',
borderColor: card.border,
}),
};
Enter fullscreen mode Exit fullscreen mode
Now the component has a small theme surface.
Other code does not need to know its DOM structure. It only needs to know the values the component chose to expose.
But at the app level, naming every color or spacing value can become more effort than help.
Sometimes the value itself is the clearest thing to read, search, and maintain.
That is why Fluentic also has createValues(...):
const color = createValues([
'#ffffff | Surface',
'#111827 | Text',
'#d8e3df | Border',
]);
Enter fullscreen mode Exit fullscreen mode
The app can use those values directly:
const page = style({
backgroundColor: color('#ffffff | Surface'),
color: color('#111827 | Text'),
});
Enter fullscreen mode Exit fullscreen mode
And createTheme(...) can provide different values at the app or subtree level:
const darkTheme = createTheme([
color('#ffffff | Surface', '#111827'),
color('#111827 | Text', '#ffffff'),
color('#d8e3df | Border', '#374151'),
]);
function App() {
return (
<main css={[page, darkTheme]}>
<Dashboard />
</main>
);
}
Enter fullscreen mode Exit fullscreen mode
At the component level, those app values can be mapped into the component’s exposed tokens through style.scope(...) and passed to the component:
const dashboardCardTheme = style.scope([
card.surface(color('#ffffff | Surface')),
card.text(color('#111827 | Text')),
card.border(color('#d8e3df | Border')),
]);
<Card theme={dashboardCardTheme} />
Enter fullscreen mode Exit fullscreen mode
The app theme still controls what those app values become. The component theme only says how this component should connect to those values.
The goal is not to force every app to define many themes from day one.
The goal is to make themeability available from the beginning, so dark mode and app-level styling do not become a retrofit later.
What Fluentic Is Trying To Be
Fluentic is my attempt to bring a React Native-inspired styling mental model to React web apps.
The goal is not to hide CSS.
The web is still the web.
You still need to understand layout, cascade, inheritance, media queries, pseudo states, and browser behavior.
Fluentic is not trying to make CSS disappear.
It is trying to make component styling feel more explicit, composable, and predictable.
The smallest version looks like this:
import { style, type StyleProp } from '@fluentic/style';
const styles = {
button: style({
height: 36,
paddingInline: 12,
borderRadius: 6,
border: '1px solid #d1d5db',
backgroundColor: '#ffffff',
}).hover({
backgroundColor: '#f3f4f6',
}),
primary: style({
borderColor: '#2563eb',
backgroundColor: '#2563eb',
color: '#ffffff',
}),
disabled: style({
opacity: 0.5,
pointerEvents: 'none',
}),
};
type ToolbarButtonProps = React.ComponentProps<'button'> & {
primary?: boolean;
css?: StyleProp;
};
function ToolbarButton({
primary,
disabled,
css,
...props
}: ToolbarButtonProps) {
return (
<button
{...props}
disabled={disabled}
css={[
styles.button,
primary && styles.primary,
disabled && styles.disabled,
css,
]}
/>
);
}
Enter fullscreen mode Exit fullscreen mode
For reusable components, Fluentic can go further with tokens, slots, scopes, and combineStyle(...).
import {
style,
createTokens,
combineStyle,
bindScope,
type StyleProp,
type StyleTheme,
} from '@fluentic/style';
const button = createTokens({
color: {
surface: '#2563eb',
surfaceHover: '#1d4ed8',
text: '#ffffff',
dangerSurface: '#dc2626',
},
space: {
gap: 8,
paddingInline: 12,
},
});
const buttonStyles = {
root: style.slot({
display: 'inline-flex',
alignItems: 'center',
gap: button.space.gap,
border: 0,
borderRadius: 8,
paddingBlock: 8,
paddingInline: button.space.paddingInline,
backgroundColor: button.color.surface,
color: button.color.text,
}).hover({
backgroundColor: button.color.surfaceHover,
}),
icon: style.slot({
display: 'inline-flex',
width: 16,
height: 16,
}),
label: style.slot({
fontWeight: 650,
lineHeight: 1,
}),
};
type ButtonProps = {
children: React.ReactNode;
css?: StyleProp;
icon?: React.ReactNode;
theme?: StyleTheme;
};
function BaseButton(props: ButtonProps) {
const css = combineStyle(
buttonStyles,
bindScope(buttonStyles.root, props.theme),
);
return (
<button css={[css.root, props.css]}>
{props.icon ? <span css={css.icon}>{props.icon}</span> : null}
<span css={css.label}>{props.children}</span>
</button>
);
}
Enter fullscreen mode Exit fullscreen mode
Then variants can target public slots and component tokens instead of reaching through DOM structure:
const dangerButton = style.scope([
button.color.surface(button.color.dangerSurface),
buttonStyles.label({
fontWeight: 700,
}),
]);
const compactButton = style.scope([
buttonStyles.root({
gap: 6,
paddingBlock: 6,
paddingInline: 10,
}),
buttonStyles.label({
fontSize: 13,
}),
]);
function Button({
danger,
compact,
theme,
...props
}: ButtonProps & {
danger?: boolean;
compact?: boolean;
}) {
return (
<BaseButton
{...props}
theme={[
danger && dangerButton,
compact && compactButton,
theme,
]}
/>
);
}
Enter fullscreen mode Exit fullscreen mode
At the app level, values can stay readable while still being themeable.
import { createTheme, createValues, style } from '@fluentic/style';
const color = createValues([
'#2563eb | Accent',
'#1d4ed8 | AccentHover',
'#ffffff | AccentText',
]);
const darkTheme = createTheme([
color('#2563eb | Accent', '#60a5fa'),
color('#1d4ed8 | AccentHover', '#93c5fd'),
color('#ffffff | AccentText', '#0f172a'),
]);
Enter fullscreen mode Exit fullscreen mode
Then the app can map those values into the component’s exposed tokens through a component theme:
const appButtonTheme = style.scope([
button.color.surface(color('#2563eb | Accent')),
button.color.surfaceHover(color('#1d4ed8 | AccentHover')),
button.color.text(color('#ffffff | AccentText')),
]);
function App() {
return (
<main css={darkTheme}>
<Button theme={appButtonTheme}>
Save
</Button>
</main>
);
}
Enter fullscreen mode Exit fullscreen mode
The app theme decides what the app values become.
The component theme decides how this button connects to those values.
The component itself stays explicit about the parts and tokens it exposes.
The important part is not only the exact syntax.
The important part is the styling approach:
- styles are defined as values
- styles are composed explicitly
- conditional styles are visible
- component parts can be named with slots
- variants can target slots without depending on DOM structure
- app and component themes can be prepared early without becoming a separate styling system
- selectors are available, but they are not the whole styling model
- CSS is extracted for production builds
- debugging should lead back to the React code, not stop at a generated class name
That is the balance I want.
React Native simplicity.
CSS power.
Modern React output.
A styling system where the everyday path stays simple, explicit when the component grows and prepared for the app-level concerns that usually arrive later.
Why Not Just Use Existing Tools?
This is a fair question.
There are many good styling tools now.
Some are utility-first.
Some are CSS-in-TypeScript.
Some are zero-runtime.
Some are compiler-first.
Some are design-system focused.
Some are close to CSS Modules with better ergonomics.
I am not building Fluentic because I think all of them are bad.
I am building it because I want a very specific feel.
The feel of React Native style composition, adapted to React web.
The thing I miss is not only extraction, type safety, or colocated styles.
It is the simple confidence of this line:
style={[base, variant, state, props.style]}
Enter fullscreen mode Exit fullscreen mode
That mental model has been living rent-free in my head for years.
When I came back to React web, I wanted that feeling again.
But I also wanted hover, focus, media queries, selectors, SSR, extraction, component slots, and CSS output.
So Fluentic is my attempt to connect those worlds.
What I Am Trying To Avoid
Fluentic is also shaped by the things I want to avoid from my own past web code.
I want to avoid class names becoming a private styling API between parent and child components.
I want to avoid parent styles reaching too deeply into child structure.
I want to avoid selectors that make markup hard to change.
I want to avoid styling systems where I cannot easily answer “which style wins?”
I want to avoid too much runtime styling work.
I want to avoid cleverness that feels good today and suspicious six months later.
That last one is important.
A lot of styling decisions feel great when writing them.
The real test is how they feel when changing them later.
Fluentic Is Still Early
Fluentic is still early.
I am still exploring the API, constraints, compiler behavior, and the right balance between CSS power and React Native-like explicitness.
But the direction is clear:
React styling should feel more like composing values, and less like maintaining invisible relationships between class names and DOM structure.
That is the idea I want to test.
Not whether CSS can do everything. It can.
Not whether extraction is useful. It is.
The question is:
Can styling for component-heavy React apps feel predictable again?
Can we keep the parts of CSS that make the web powerful, while making everyday component styling feel closer to the React Native model?
That is what I am building with Fluentic.
You can check out Fluentic here:
If this problem feels familiar, especially if you have worked on both React web and React Native apps, I’d love to hear what you think.
Which parts of React styling become harder to change over time?
Which parts of React Native styling do you wish existed on React web?
And what component-styling cases should Fluentic cover next?
That feedback would be much more useful than stars alone, though I will not pretend stars hurt.
답글 남기기