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.
combineStyle is one of those APIs that looks smaller than the problem behind it.
const css = combineStyle(
cardStyles,
bindScope(cardStyles.root, props.theme),
);
At first glance, it looks like a merge helper.
But I eventually stopped thinking about it that way.
To me, combineStyle became the place where a component’s own styles meet styles passed from outside.
The problem was not “how do I merge two things?”
The real problem was:
How do component styles stay extendable without making JSX turn into a pile of manual style merging?
That is the part I kept running into.
The Simple Version Is Always Fine
A component usually starts clean.
Maybe you have a Card.
import { style } from '@fluentic/style';
const cardStyles = {
root: style({
padding: 24,
borderRadius: 12,
backgroundColor: 'white',
}),
title: style({
fontSize: 20,
fontWeight: 700,
}),
body: style({
color: '#475569',
}),
};
type CardProps = {
title: string;
body: string;
};
export function Card(props: CardProps) {
return (
<article css={cardStyles.root}>
<h2 css={cardStyles.title}>{props.title}</h2>
<p css={cardStyles.body}>{props.body}</p>
</article>
);
}
This is the happy path: the markup stays readable, the styles are nearby, and for many components this is enough.
The JSX mostly tells me what the component renders.
That is the feeling I want to keep.
Then The Component Becomes Reusable
At some point, the component needs more styling inputs.
Maybe one page needs a featured card.
Maybe another page needs a compact card.
Maybe a design system wants to expose a few component parts so product code can style them.
With class names, I usually end up adding something like this:
type CardProps = {
title: string;
body: string;
classes?: {
root?: string;
title?: string;
body?: string;
};
};
Then the component starts stitching its own classes together with incoming classes:
export function Card(props: CardProps) {
return (
<article className={cx(styles.root, props.classes?.root)}>
<h2 className={cx(styles.title, props.classes?.title)}>
{props.title}
</h2>
<p className={cx(styles.body, props.classes?.body)}>
{props.body}
</p>
</article>
);
}
This works.
I have written this kind of code many times.
Then variants arrive:
export function Card(props: CardProps) {
return (
<article
className={cx(
styles.root,
props.compact && styles.compactRoot,
props.featured && styles.featuredRoot,
props.classes?.root,
)}
>
<h2
className={cx(
styles.title,
props.compact && styles.compactTitle,
props.featured && styles.featuredTitle,
props.classes?.title,
)}
>
{props.title}
</h2>
<p
className={cx(
styles.body,
props.featured && styles.featuredBody,
props.classes?.body,
)}
>
{props.body}
</p>
</article>
);
}
Still normal frontend code.
Still understandable.
But the JSX is no longer only showing the structure of the component.
It is also carrying the styling merge rules.
And the more parts the component has, the more that merge logic spreads through the markup.
That is the part I wanted Fluentic to avoid.
The JSX should mostly express the data, the elements, and the component structure.
Styling composition should have a clear place instead of leaking into every className or css prop.
The First Idea Was A Hook
At one point, I thought Fluentic might need something hook-like.
function Card(props: CardProps) {
const css = useStyles(cardStyles, props.theme);
return (
<article css={css.root}>
<h2 css={css.title}>{props.title}</h2>
<p css={css.body}>{props.body}</p>
</article>
);
}
For React, this feels familiar.
A hook gives you a place to prepare styles before render.
A hook can read props.
A hook can memoize.
So the idea is not strange.
But the more Fluentic grew, the less I wanted the style resolver to be a hook.
A hook could still call a shared cache internally, so this was not only a performance argument.
The bigger issue was that this operation does not really need React state, React lifecycle, or React component identity.
Style composition depends on the style objects and scopes you pass in.
Making the main API a hook would make React part of something that does not actually require React.
That felt like the wrong direction, especially once Fluentic started moving toward Next.js server/client support, SolidJS support, and compiler-transformed JSX.
So combineStyle became a plain function.
const css = combineStyle(cardStyles, ...inputs);
No hook.
No component instance required.
No React lifecycle required.
Just styles in, final styles out.
The Fluentic Version
In Fluentic, when a component wants to expose styleable parts, those parts can become slots.
import {
bindScope,
combineStyle,
style,
type StyleProp,
type StyleTheme,
} from '@fluentic/style';
const cardStyles = {
root: style.slot({
padding: 24,
borderRadius: 12,
backgroundColor: 'white',
}),
title: style.slot({
fontSize: 20,
fontWeight: 700,
}),
body: style.slot({
color: '#475569',
}),
};
type CardProps = {
title: string;
body: string;
compact?: boolean;
featured?: boolean;
css?: StyleProp;
theme?: StyleTheme;
};
A slot still feels like writing a normal style.
The extra part is that another style can point to it later.
Outside styles can provide changes for root, title, body, or any other public slot the component exposes.
Those grouped changes are scopes:
const compactCard = style.scope([
cardStyles.root({
padding: 16,
}),
cardStyles.title({
fontSize: 16,
}),
]);
const featuredCard = style.scope([
cardStyles.root({
backgroundColor: '#0f172a',
color: 'white',
}),
cardStyles.title({
color: '#e0f2fe',
}),
cardStyles.body({
color: '#cbd5e1',
}),
]);
Then the component prepares the final styles in one place:
export function Card(props: CardProps) {
const css = combineStyle(
cardStyles,
bindScope(
cardStyles.root,
props.compact && compactCard,
props.featured && featuredCard,
props.theme,
),
);
return (
<article css={[css.root, props.css]}>
<h2 css={css.title}>{props.title}</h2>
<p css={css.body}>{props.body}</p>
</article>
);
}
This is the pattern I wanted.
The component has one style resolution point.
The JSX receives the final styles.
The root still accepts props.css for direct root element styling.
The theme prop accepts styles for component slots.
The component still decides where those styles attach.
<Card
title="Revenue"
body="$42,300"
featured
theme={dashboardCardTheme}
/>
A small note on the names here:
StyleTheme is the type Fluentic uses for styles passed into component slots.
The prop does not have to be called theme, but that is the convention Fluentic docs use.
bindScope(cardStyles.root, props.theme) tells Fluentic where those incoming scopes should attach. For a card, that target is usually the root slot.
Why bindScope Matters Here
Scopes need a target slot.
You can call each scope directly:
const css = combineStyle(
cardStyles,
props.compact && compactCard(cardStyles.root),
props.featured && featuredCard(cardStyles.root),
props.theme?.(cardStyles.root),
);
That works for simple cases.
But once you have multiple scopes, repeating the same target gets noisy.
bindScope lets the component say the target once:
const css = combineStyle(
cardStyles,
bindScope(
cardStyles.root,
props.compact && compactCard,
props.featured && featuredCard,
props.theme,
),
);
This is also why props.theme usually stays last.
The component can apply its own state or variant scopes first, then let external component styling come after.
That keeps the override order predictable.
Base styles first.
Component variants next.
Styles passed from outside last.
And if the root element also accepts a direct css prop, that can stay at the root element:
<article css={[css.root, props.css]}>
So there are two related extension points:
theme -> styles provided to component slots
css -> styles attached to the root element
That distinction keeps the component API flexible without making every element carry a long merge expression.
The Difference In JSX
Without this kind of resolution point, the component often ends up with a style merge matrix:
<article
className={cx(
styles.root,
props.compact && styles.compactRoot,
props.featured && styles.featuredRoot,
props.classes?.root,
)}
>
<h2
className={cx(
styles.title,
props.compact && styles.compactTitle,
props.featured && styles.featuredTitle,
props.classes?.title,
)}
>
{props.title}
</h2>
<p
className={cx(
styles.body,
props.featured && styles.featuredBody,
props.classes?.body,
)}
>
{props.body}
</p>
</article>
With combineStyle, the composition moves up to one place:
const css = combineStyle(
cardStyles,
bindScope(
cardStyles.root,
props.compact && compactCard,
props.featured && featuredCard,
props.theme,
),
);
Then the JSX stays closer to the component structure:
return (
<article css={[css.root, props.css]}>
<h2 css={css.title}>{props.title}</h2>
<p css={css.body}>{props.body}</p>
</article>
);
This is a small difference in code, but it changes the maintenance feeling.
The style composition has a home.
The markup does not need to repeat the same merge pattern for every part.
The Cache Question
The hard part is that hooks make caching feel obvious.
With a hook, you can imagine useMemo.
You can imagine instance-local caching.
You can make the work feel tied to React render behavior.
A plain function does not automatically give you that comfort.
So combineStyle had to answer:
If this is just a function, how does it avoid doing too much work?
The answer in Fluentic is to reuse work based on the style objects and the path used to resolve them.
Most component styles are stable values:
const cardStyles = {
root: style.slot({ padding: 24 }),
title: style.slot({ fontWeight: 700 }),
};
Scopes are often stable too:
const compactCard = style.scope([
cardStyles.root({ padding: 16 }),
cardStyles.title({ fontSize: 16 }),
]);
So when many component instances use the same styles and scopes, Fluentic can reuse the work instead of treating each instance like a completely new styling case.
combineStyle(
cardStyles,
bindScope(cardStyles.root, compactCard),
);
The useful question is not only:
What happened in this component instance?
It is also:
Have these styles already been resolved this way?
That fits Fluentic better than making the API a React hook.
A hook can still be useful in some styling systems, but combineStyle does not need to be one.
Production Should Not Pay For Dev-Time Work
There is another important piece here.
combineStyle should not make every render feel like Fluentic is compiling styles again from scratch.
In development, Fluentic needs to understand your style code, keep debug names, generate sourcemaps, and make DevTools useful.
That work is valuable while building.
But it should not become the normal production cost.
In production, Fluentic’s compiler and bundler plugin can turn style chains into extracted CSS and prepared JavaScript output.
The browser still handles the parts that only become known while the app is rendering, like scopes chosen by props or state, theme values, and prop-driven style values.
But the heavier work of reading the style chain and generating CSS belongs to the build.
So the goal for combineStyle is practical:
Keep component style composition predictable and fast enough that component authors do not have to think about it.
It should be easy to prepare final styles in one place and keep the JSX readable.
Why This Became The Handoff Point
At first, combineStyle looked like a utility.
Now I see it as the handoff point inside a component.
It is where the component gathers:
- its default styles
- its slots
- variant styles
- state styles
- styles passed from outside
- token/theme overrides
- root-level
css
Then JSX receives the final styles.
That matters because Fluentic is trying to support more than one styling layer.
Quick element styling:
<div css={{ padding: 16 }} />
Component part styling:
<Card theme={featuredCardTheme} />
Root element styling:
<Card css={pageCardSpacing} />
App-level theming:
<main css={[appRoot, darkTheme]}>
<Card theme={dashboardCardTheme} />
</main>
And the component handoff point:
const css = combineStyle(
cardStyles,
bindScope(cardStyles.root, props.theme),
);
That is the reason combineStyle exists.
Not because merging styles is difficult by itself.
It exists so a component has one explicit place where its own styles meet styles passed from outside, while its JSX stays focused on structure.
Docs
Related docs:
- combineStyle
- style.slot
- style.scope
- bindScope
- Accept and resolve outside styles
- Runtime resolution and cache
- Fluentic Style docs
Fluentic Style is still new and currently in beta.
I am still looking for early users and feedback, especially from people building real component systems where styling needs to go beyond one element and one class name.
Top comments (0)