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.
The feeling I keep having is that styling in component frameworks often asks components to fit back into the old HTML + CSS model, instead of asking what CSS composition should look like when components are the main unit.
That is not meant as a takedown of CSS.
I like CSS.
And the HTML + CSS model makes a lot of sense in its own world.
In that model, you write HTML, give elements class names, and use selectors when a nested part needs styling.
<div class="card">
<h2 class="card-title">Revenue</h2>
<p class="card-body">$42,300</p>
</div>
.card {
padding: 16px;
border-radius: 12px;
}
.card-title {
font-size: 18px;
font-weight: 700;
}
.card .card-body {
color: #475569;
}
That model has problems.
Global CSS can leak. Naming is hard. Specificity can become painful. Large stylesheets can become difficult to maintain.
But the basic mental model is easy to understand:
Give the part a name, then style that named part.
Even when the ecosystem adds SCSS, BEM, naming conventions, CSS Modules, and other tools, a lot of the core idea stays familiar.
There is markup.
There are names.
There are selectors.
Styles reach elements through those names.
That world feels coherent because HTML and CSS are built around that relationship.
Then components change the shape of UI.
Components Change The Unit
In React and other component frameworks, we usually stop thinking of UI as one big HTML document.
We think in components:
<Card title="Revenue">$42,300</Card>
That is a huge improvement.
A component owns its internal markup.
It receives props.
It composes with children.
It hides implementation details.
It can be typed.
It can be transformed by tooling.
It can become part of a design system.
But styling still has to answer a familiar question:
How do I style the thing inside?
In HTML + CSS, if I want to style the title inside a card, I can write:
.compact-card .card-title {
font-size: 14px;
}
In a component world, that assumes a lot:
- the component exposes a
.card-titleclass - the DOM structure stays the same
- outside CSS is allowed to reach inside
- the styling system is not isolating class names
- the component author intends that part to be customized
Sometimes that is fine.
I still use selector-based styling in many places.
But the component boundary changes the feeling.
The component owns the inside.
Outside code still wants to customize it.
That is the tension.
The Usual Ways We Handle This
Over time, frontend code has found many practical ways to handle this.
One common approach is more class props:
<Card
className="compact-card"
titleClassName="compact-title"
bodyClassName="compact-body"
actionClassName="compact-action"
/>
This is explicit and easy to understand.
But the component API starts growing around styling needs.
Every public part needs a prop.
Every prop needs a name.
Every name becomes a contract.
A slightly more organized version is a per-part override object:
<Card
classes={{
root: 'compact-card',
title: 'compact-title',
body: 'compact-body',
action: 'compact-action',
}}
styles={{
root: { padding: 12 },
title: { fontSize: 14 },
body: { color: '#475569' },
}}
/>
This gives the consumer two common escape hatches:
-
classeswhen they already have CSS somewhere -
styleswhen they want to pass a small direct override
That is convenient.
Inside the component, though, every public part still becomes a merge point:
type CardProps = {
title: string;
children: React.ReactNode;
actionLabel: string;
classes?: {
root?: string;
title?: string;
body?: string;
action?: string;
};
styles?: {
root?: React.CSSProperties;
title?: React.CSSProperties;
body?: React.CSSProperties;
action?: React.CSSProperties;
};
};
function Card(props: CardProps) {
return (
<article
className={cx('card', props.classes?.root)}
style={{ ...rootStyle, ...props.styles?.root }}
>
<h2
className={cx('card-title', props.classes?.title)}
style={{ ...titleStyle, ...props.styles?.title }}
>
{props.title}
</h2>
<p
className={cx('card-body', props.classes?.body)}
style={{ ...bodyStyle, ...props.styles?.body }}
>
{props.children}
</p>
<button
className={cx('card-action', props.classes?.action)}
style={{ ...actionStyle, ...props.styles?.action }}
>
{props.actionLabel}
</button>
</article>
);
}
This is not strange code.
I have written this kind of code a lot.
It is practical.
But it shows the repeated shape: every styleable part needs a name, a prop shape, and a merge point.
Variants are another common path:
<Card size="sm" tone="danger" density="compact" />
Variants are great when the design cases are known.
But when a variant affects many inner parts, the component starts carrying a small style matrix:
function Card(props: CardProps) {
const rootClass = cx(
'card',
props.size === 'sm' && 'card-sm',
props.density === 'compact' && 'card-compact',
props.tone === 'danger' && 'card-danger',
);
const titleClass = cx(
'card-title',
props.size === 'sm' && 'card-title-sm',
props.tone === 'danger' && 'card-title-danger',
);
const bodyClass = cx(
'card-body',
props.density === 'compact' && 'card-body-compact',
);
const actionClass = cx(
'card-action',
props.tone === 'danger' && 'card-action-danger',
);
return (
<article className={rootClass}>
<h2 className={titleClass}>{props.title}</h2>
<p className={bodyClass}>{props.children}</p>
<button className={actionClass}>{props.actionLabel}</button>
</article>
);
}
Again, not wrong.
Sometimes this is exactly the right choice.
CSS variables are also useful:
<Card
style={{
'--card-title-size': '14px',
'--card-body-color': '#475569',
}}
/>
They are native, flexible, and they compose well with CSS.
But the component still needs to intentionally expose those variables:
.card-title {
font-size: var(--card-title-size, 18px);
}
.card-body {
color: var(--card-body-color, #475569);
}
That is a good tool, but it is still a public styling surface the component author has to design.
So I do not see these approaches as wrong.
They are practical, and they exist because the problem is real.
The part that stays interesting to me is the repeated shape:
Outside styles want to reach meaningful component parts, and component authors need a clean way to expose those parts without leaking the whole DOM.
That is the problem I want Fluentic to explore.
Co-Location Helps, But It Solves A Different Layer
Tailwind and utility-first styling change the day-to-day feeling of writing CSS for many developers.
Instead of jumping between JSX and a CSS file, you can write the style directly where the element is rendered:
<button className="inline-flex items-center rounded-md px-3 py-2 text-sm font-medium">
Save
</button>
That is a big deal.
The style is close to the element.
You can read the JSX and understand a lot of the visual output immediately.
Fluentic can support writing styles right where the element is rendered too.
For example, you can write Tailwind-like classes through Fluentic:
<button css={cx('inline-flex', 'items-center', 'rounded-md', 'px-3', 'py-2')}>
Save
</button>
or write a style object directly on the element:
<button
css={style({
display: 'inline-flex',
alignItems: 'center',
borderRadius: 8,
paddingInline: 12,
paddingBlock: 8,
fontSize: 14,
fontWeight: 600,
})}
>
Save
</button>
But co-location mostly answers this question:
How do I style the element I am writing right now?
That is useful.
It just is not the same as the reusable component problem.
Once the UI becomes this:
<Card title="Revenue">$42,300</Card>
the outside code is no longer writing the h2, the body, or the action button directly.
Those elements live inside Card.
So the harder question becomes:
How does outside code style the parts inside a component it does not render directly?
Co-location does not remove that boundary.
It makes styling local elements nicer.
But a reusable component still needs a way to expose the internal parts that are intentionally styleable.
A Card Component Starts Simple
Imagine you start with a card component used in one place.
At this point, it does not need a public styling API.
It just owns its styles.
import { style } from '@fluentic/style';
const cardStyles = {
root: style({
display: 'grid',
gap: 12,
padding: 16,
borderRadius: 12,
backgroundColor: '#ffffff',
border: '1px solid #e2e8f0',
}),
title: style({
margin: 0,
fontSize: 18,
fontWeight: 700,
color: '#0f172a',
}),
body: style({
margin: 0,
color: '#475569',
lineHeight: 1.6,
}),
action: style({
justifySelf: 'start',
border: 0,
borderRadius: 8,
paddingInline: 12,
paddingBlock: 8,
backgroundColor: '#2563eb',
color: '#ffffff',
}),
};
type CardProps = {
title: string;
children: React.ReactNode;
actionLabel: string;
};
export function Card(props: CardProps) {
return (
<article css={cardStyles.root}>
<h2 css={cardStyles.title}>{props.title}</h2>
<p css={cardStyles.body}>{props.children}</p>
<button css={cardStyles.action}>{props.actionLabel}</button>
</article>
);
}
This is enough when the card only has one job.
The styles are owned by the component, and no outside code needs to customize the inner parts.
But apps rarely stay frozen.
Maybe the same card structure starts showing up in more places.
Maybe one page needs a compact version.
Maybe another page needs a danger version.
Maybe a dashboard wants the same base card, but with a different title color and action button.
At that point, the component starts becoming a common base component, and the styling question changes.
That is when the component needs to expose some styling surface.
The question is how.
Change Public Parts To Slots
In Fluentic, the small shift is to change the parts that should be customizable from style(...) to style.slot(...).
import { style } from '@fluentic/style';
export const cardStyles = {
root: style.slot({
display: 'grid',
gap: 12,
padding: 16,
borderRadius: 12,
backgroundColor: '#ffffff',
border: '1px solid #e2e8f0',
}),
title: style.slot({
margin: 0,
fontSize: 18,
fontWeight: 700,
color: '#0f172a',
}),
body: style.slot({
margin: 0,
color: '#475569',
lineHeight: 1.6,
}),
action: style.slot({
justifySelf: 'start',
border: 0,
borderRadius: 8,
paddingInline: 12,
paddingBlock: 8,
backgroundColor: '#2563eb',
color: '#ffffff',
}),
};
A slot is still very close to a normal Fluentic style.
The component can render it as its own base style.
The extra thing Fluentic keeps is slot identity.
That means this value can later be used as a target inside a scope:
style.scope([
cardStyles.title({
fontSize: 14,
}),
]);
That is the new capability.
The component author is not only writing styles.
They are also naming the parts that outside code may style on purpose.
The component is saying:
These are the public styling targets:
root,title,body, andaction.
Not every internal element needs to be public.
Only the parts that should be customizable become slots.
The public target is not a string class name buried in markup.
It is a TypeScript value exported by the component.
Add A Theme Prop
Once a component has slots, outside code needs a way to pass styles for those slots.
In Fluentic, that is usually a theme prop.
import type { StyleTheme } from '@fluentic/style';
type CardProps = {
title: string;
children: React.ReactNode;
actionLabel: string;
theme?: StyleTheme;
};
The theme prop is not the same thing as css.
css attaches style to one rendered DOM element.
A component theme can contain styles for multiple internal parts.
So the component receives a theme, resolves it against its slots, and then renders the resolved styles.
That is where combineStyle(...) and bindScope(...) come in.
import {
bindScope,
combineStyle,
type StyleTheme,
} from '@fluentic/style';
import { cardStyles } from './cardStyles';
type CardProps = {
title: string;
children: React.ReactNode;
actionLabel: string;
theme?: StyleTheme;
};
export function Card(props: CardProps) {
const css = combineStyle(
cardStyles,
bindScope(cardStyles.root, props.theme),
);
return (
<article css={css.root}>
<h2 css={css.title}>{props.title}</h2>
<p css={css.body}>{props.children}</p>
<button css={css.action}>{props.actionLabel}</button>
</article>
);
}
The component still owns the DOM.
The outside theme does not directly reach into the markup.
The component chooses the root slot, binds incoming themes to that root, and resolves the styles.
Then the component renders:
<article css={css.root}>
<h2 css={css.title}>{props.title}</h2>
<p css={css.body}>{props.children}</p>
<button css={css.action}>{props.actionLabel}</button>
</article>
That is the boundary I want.
Outside code provides styling intent.
The component decides where that intent attaches.
Scopes Are Outside Styles For Slots
Now outside code can create a scope.
A scope is a group of styles provided to slots.
import { style } from '@fluentic/style';
import { cardStyles } from './cardStyles';
export const compactCard = style.scope([
cardStyles.root({
gap: 8,
padding: 12,
}),
cardStyles.title({
fontSize: 14,
}),
cardStyles.body({
lineHeight: 1.45,
}),
]);
Usage:
<Card title="Revenue" actionLabel="Open" theme={compactCard}>
$42,300
</Card>
This is where the old CSS idea comes back, but in a component-aware shape.
Old CSS:
.compact-card .card-title {
font-size: 14px;
}
Fluentic:
const compactCard = style.scope([
cardStyles.title({
fontSize: 14,
}),
]);
The intent is similar:
When this outside styling applies, change the title.
But the target is different.
It is not .card-title.
It is cardStyles.title.
That means the target can be typed, composed, transformed, extracted, and debugged as Fluentic style data.
Scopes Can Change Multiple Parts Together
Most useful component themes affect more than one part.
A danger card might change the root border, title color, and action button.
export const dangerCard = style.scope([
cardStyles.root({
borderColor: '#dc2626',
backgroundColor: '#fef2f2',
}),
cardStyles.title({
color: '#991b1b',
}),
cardStyles.action({
backgroundColor: '#dc2626',
}),
]);
Usage:
<Card title="Delete project" actionLabel="Delete" theme={dangerCard}>
This action cannot be undone.
</Card>
Without something like scopes, this often turns into many separate props:
<Card
rootClassName="danger-root"
titleClassName="danger-title"
actionClassName="danger-action"
/>
or one large manual object:
<Card
classes={{
root: 'danger-root',
title: 'danger-title',
action: 'danger-action',
}}
/>
With Fluentic, the outside styling idea is one value:
theme={dangerCard}
That value can affect multiple public parts.
Scopes Compose
Because scopes are values, they can compose.
<Card
title="Revenue"
actionLabel="Open"
theme={[
compactCard,
highlightedCard,
props.danger && dangerCard,
]}
>
$42,300
</Card>
This is one of the parts I care about most.
The composition still feels close to the style-array idea that inspired Fluentic in the first place:
theme={[baseTheme, compact && compactCard, danger && dangerCard]}
But now the composition can reach multiple component parts.
That is hard to express with plain class props without manually merging many fields.
With scopes, each theme can carry its own slot overrides, and the component resolves them at the boundary.
Scopes Can Have State And Media Conditions
A scope can also use chain methods.
For example, a theme can change several parts when the component root is hovered:
export const interactiveCard = style.scope()
.hover([
cardStyles.root({
borderColor: '#2563eb',
}),
cardStyles.title({
color: '#2563eb',
}),
cardStyles.action({
backgroundColor: '#1d4ed8',
}),
]);
This means:
When the scoped root is hovered, apply these slot overrides.
That is very close to the old selector idea:
.card:hover .card-title {
color: #2563eb;
}
But Fluentic keeps the target as a slot reference.
The component still controls which DOM element is the root.
The component still controls where the title is rendered.
The outside style only targets the public slot.
Why This Is Not Just Class Names Again
At a glance, slots might look like class names with extra steps.
But they are not the same thing.
A class name contract says:
Here is a string. You may target it.
A slot says:
Here is a component part. You may provide style data for it.
That difference matters.
With class names, outside code often depends on the DOM:
.compact-card .card-title {
font-size: 14px;
}
With slots, outside code depends on the component’s public styling contract:
style.scope([
cardStyles.title({
fontSize: 14,
}),
]);
The component can change its internal markup and still keep the title slot.
The outside theme still targets cardStyles.title.
So the styling contract is not:
There is an
h2with this class under this parent.
The contract is:
This component exposes a
titlepart.
That feels like a better match for component-based UI.
The Public API Gets Smaller
Without slots and scopes, a component can easily grow this shape:
type CardProps = {
className?: string;
titleClassName?: string;
bodyClassName?: string;
actionClassName?: string;
compact?: boolean;
danger?: boolean;
highlighted?: boolean;
};
With slots and scopes, the component API can stay smaller:
type CardProps = {
title: string;
children: React.ReactNode;
actionLabel: string;
theme?: StyleTheme;
};
The styling surface still exists.
It just lives in the exported style contract:
export const cardStyles = {
root: style.slot({ ... }),
title: style.slot({ ... }),
body: style.slot({ ... }),
action: style.slot({ ... }),
};
This separation feels important.
The component props describe what the component does.
The slots describe which parts can be styled.
The themes describe how outside code wants to style those parts.
Debugging Still Has To Work
This would be much less useful if scopes disappeared into generated CSS and became impossible to trace.
If this wins:
const dangerCard = style.scope([
cardStyles.title({
color: '#991b1b',
}),
]);
I want the generated color rule to point back toward:
cardStyles.title({
color: '#991b1b',
})
Not just:
Some generated class somewhere.
That is why slots and scopes are part of Fluentic’s style data model.
They carry identity.
A generated rule can know:
This came from a scope.
This targeted thetitleslot.
This declaredcolor.
This source location produced the rule.
That means the debugging story can still work with component themes.
Slots and scopes are not only runtime helpers.
They are information Fluentic can preserve through generated atomic CSS, development metadata, sourcemaps, and production extraction.
I wrote more about the debugging side here:
Building Fluentic Style: Making Generated CSS Debuggable Again
Production Still Gets CSS
The authoring model can be high-level, but the browser still needs CSS.
Fluentic’s compiler can recognize style(...), style.slot(...), style.scope(...) patterns.
That means component slots and outside scopes can still participate in production CSS extraction.
The component can be written with style data:
const css = combineStyle(
cardStyles,
bindScope(cardStyles.root, props.theme),
);
But the production build can still emit extracted CSS and prepared JavaScript output.
That was important to me.
I did not want component-part styling to be a runtime-only trick.
If this pattern is going to be useful in real component libraries, it needs to fit production builds too.
The Part That Feels New
For me, the interesting idea is not the method names.
It is not:
Fluentic has
style.slot(...)andstyle.scope(...).
The interesting idea is:
Fluentic tries to bring back the easy mental model of “style this named part,” but without going back to fragile DOM selectors and string class contracts.
That is what makes this feel different.
A component can expose public styling targets without exposing its whole DOM.
A parent can customize multiple inner parts without receiving a pile of class props.
A theme can be composed as a value.
A scope can carry state and media conditions.
A generated rule can still trace back to the exact slot override that produced it.
That is the component styling API I kept wanting.
The Result
I think this is one of the more important parts of Fluentic.
Not because every component needs slots.
Many elements only need a normal style(...).
But when a component becomes reusable, and outside code needs to style its internal parts, the usual options get awkward fast.
Fluentic’s answer is:
style.slot(...)names a public component part.
style.scope(...)groups outside styles for those parts.
combineStyle(...)resolves the component styles at the component boundary.
bindScope(...)binds incoming themes to the component-owned root slot.
That gives component authors a way to say:
These are the parts you can style, and here is the safe way to style them.
No prop soup.
No guessing DOM selectors.
No turning every class name into a public contract.
Just component parts as style targets.
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 from people building component libraries or design-system components. I would love to know whether this slot/scope model matches the styling problems you hit in real UI code.
Top comments (0)