styling
Per-instance styling with fx — token-aware objects that follow the theme.
Components carry their look in CSS slices and the skins re-dress them. fx is the layer
above that: the styling you apply to one instance, without writing a class or reaching
for a stylesheet.
<Card fx={{ p: 4, radius: 'lg', bg: 'bgSubtle' }}>…</Card>
Every component takes it. Values resolve against the same tokens the skins use, so an
override still moves with data-theme and data-accent instead of freezing one theme's
colours into your app.
Values
A number means different things per property, decided by that property's scale:
| Property kind | 3 means |
Result |
|---|---|---|
Spacing — p, m, gap, inset, … |
a step on the space scale | var(--flx-ui-space-3) |
Sizing — width, maxHeight, fontSize, borderRadius, … |
pixels | 3px |
Unitless — zIndex, flexGrow, opacity, … |
itself | 3 |
Negative space steps work — m: -2 becomes calc(var(--flx-ui-space-2) * -1) — and a
step that isn't on the scale degrades to pixels rather than emitting a var() that
resolves to nothing. Durations take milliseconds: transitionDuration: 200.
Strings look up a token first and fall through to raw CSS:
fx={{ bg: 'primary' }} // semantic token → var(--flx-ui-color-primary)
fx={{ bg: 'accent-9' }} // palette step → var(--flx-ui-accent-9)
fx={{ bg: 'bg-subtle' }} // kebab spelling of a semantic name, as it reads in CSS
fx={{ bg: '#0af' }} // no token by that name — passed through verbatim
Token-first, never token-only. Anything CSS understands still goes through.
Short names
Nine, and no more — everything else is the real CSS property name, which autocompletes and typo-checks:
| Alias | Property |
|---|---|
p · px · py |
padding · padding-inline · padding-block |
m · mx · my |
margin · margin-inline · margin-block |
bg |
background-color |
radius |
border-radius |
shadow |
box-shadow |
px/py and mx/my map to the logical properties, so they follow writing
direction — they are not just shorter than padding-left/padding-right, they are more
correct.
Responsive
An object per property, mobile-first. base carries no media query; every other key is a
min-width:
<Box fx={{ p: { base: 2, md: 4 }, flexDirection: { base: 'column', md: 'row' } }} />
sm |
md |
lg |
xl |
2xl |
|---|---|---|---|---|
| 640px | 768px | 1024px | 1280px | 1536px |
There is no array form: a positional list can't skip a step, and reading one tells you nothing about which breakpoint it meant.
States and selectors
State keys start with _, and they expand to the library's real conventions rather than
the bare pseudo-class. Headless parts signal state through data-* and ARIA, so a
:disabled-only mapping would miss most of them — _disabled covers :disabled,
[disabled] and [data-disabled]:
<Button fx={{ bg: 'primary', _hover: { bg: 'primaryHover' }, _disabled: { opacity: 0.5 } }} />
| Interaction | _hover _active _focus _focusVisible _focusWithin _disabled |
| State | _checked _selected _expanded _open _invalid |
| Position | _first _last _odd _even _notFirst _notLast _children |
| Pseudo-elements | _before _after _placeholder _selection |
| Scrollbar | _scrollbar _scrollbarThumb _scrollbarTrack |
| Theme | _dark _light _hc _rtl |
| Motion | _motionReduce _motionSafe |
The set is closed on purpose: you can't reach an app's own class names from fx, so
a component can never couple itself to consumer CSS. Nesting composes:
fx={{ _dark: { _hover: { bg: 'bgMuted' } } }}
_dark and _light match the data-theme attribute — not prefers-color-scheme — and
they match the element itself as well as its descendants, so a scoped theme region
behaves like a page-level one. _scrollbar* exists because scrollbar styling has no
standard selector, and the closed set is the only way in.
Container queries
_container asks about the nearest ancestor that declares containment, which is often
what you actually want instead of the page width:
<div style="container-type: inline-size">
<Card fx={{ _container: { md: { p: 6 }, '(min-width: 30rem)': { flexDirection: 'row' } } }} />
</div>
Keys are a breakpoint name or a raw condition. Raw conditions are first-class here
because an element-relative size rarely lines up with the page scale. A name entry
scopes the query to a named container instead of the nearest one:
fx={{ _container: { name: 'sidebar', sm: { display: 'none' } } }}
An element cannot query itself — the container has to be an ancestor. Declaring one
needs no new API: containerType and containerName are ordinary CSS properties.
Colours
alpha mix lighten darken emit CSS, they never compute a colour:
import { alpha, darken } from '@fluixi-ui/fx';
fx={{ bg: alpha('primary', 12), borderColor: darken('primary', 10) }}
Tokens are CSS variables that re-resolve against data-theme, so JavaScript colour maths
would bake in a value that was only correct for the theme active at render — and break
theming silently. alpha and mix produce color-mix(); lighten and darken produce
relative colour (oklch(from …)), which holds hue and chroma where mixing toward white or
black drifts grey. Amounts are percentages, 0–100.
Utilities
An fx value is a plain object, so a utility is just a named one. Import and use, or
merge with anything else:
import { flexBetween, muted, bordered, clickable, mergeFx } from '@fluixi-ui/fx';
<Box fx={flexBetween} />
<Text fx={muted} />
<Box fx={mergeFx(bordered, clickable, { p: 3, radius: 'md' })} />
<Box fx={{ ...flexBetween, p: 3 }} />
| Display | flex inlineFlex grid block inlineBlock hidden invisible |
| Flex | flexRow flexColumn flexWrap flexCenter flexBetween flexEnd flexStart alignCenter flexFill flexNone minW0 |
| Size | fullWidth fullHeight fullSize fillViewport |
| Position | relative absolute fixed stickyTop |
| Text | textCenter textStart textEnd nowrap medium semibold bold uppercase capitalize muted breakWords tabularNums |
| Surface | bordered borderTop borderBottom rounded pill circle scrim |
| Interaction | pointer notAllowed inert clickable focusRing disabled |
The ones carrying a decision are the reason the list exists: flexFill includes the
min-width: 0 a flex child needs before its text will truncate; muted is the token,
not a hand-picked grey; bordered, scrim and stickyTop take their colour and z-index
off the scales; focusRing is :focus-visible only, so a mouse click doesn't ring it;
clickable turns off the iOS tap flash along with text selection; tabularNums stops a
live counter jittering as its digits change.
Presets
Named objects for the patterns that only work as a set — miss one declaration and the effect silently doesn't happen:
import { truncate, lineClamp, stack, scrollY } from '@fluixi-ui/fx';
<Text fx={truncate} /> {/* overflow + text-overflow + white-space */}
<Text fx={lineClamp(3)} /> {/* the -webkit-box trio */}
<Box fx={stack(3)} /> {/* column flex, gap off the space scale */}
<Box fx={scrollY(320)} /> {/* scrolls without chaining to the page */}
| Layout | stack row center absoluteFill size square aspect |
| Text | truncate lineClamp gradientText |
| Scroll | scrollY scrollX scrollbarHidden touchScroll |
| Interaction | visuallyHidden noSelect noTapHighlight |
visuallyHidden is the clip recipe, not display: none — the point is to stay in the
accessibility tree. Each preset is a plain object, so it merges and loses to whatever
comes after it:
fx={mergeFx(stack(3), { gap: 6 })} // gap 6 wins
One-liners are deliberately absent: { display: 'flex' } is already short, and a preset
for it would only be a second vocabulary for the same thing.
Animation
keyframes() defines an animation and hands back its name:
import { keyframes, animate } from '@fluixi-ui/fx';
const pulse = keyframes({ from: { opacity: 0.4 }, to: { opacity: 1 } });
const shimmer = keyframes({
'0%': { backgroundPosition: '-200% 0' },
'100%': { backgroundPosition: '200% 0' },
});
<Box fx={animate(pulse, { repeat: 'infinite' })} />
<Box fx={animate(shimmer, 'slow')} />
Stops are from/to or percentages — what CSS accepts, so there's nothing to translate
in your head — and their values go through the same resolver, so tokens work inside a
stop. The name is hashed from the resolved body, so defining the same animation in two
places is one rule.
animate() writes the longhands for you. A bare second argument is the duration, since
that's the one you always set:
| Option | |
|---|---|
duration |
Token or milliseconds. Defaults to normal |
easing |
Token or any timing function. Defaults to standard |
delay |
Token or milliseconds |
repeat |
A count, or 'infinite' |
direction · fill |
As in CSS, left off unless you ask |
paused |
Hold at the current frame |
Duration and easing default because a CSS animation with no duration doesn't run at all,
and that silence is hard to debug; everything else is omitted unless you ask, so what
lands is what CSS would do. It returns a plain object rather than the animation
shorthand string, so it merges with the properties around it and stays overridable — a
shorthand would silently reset every longhand it doesn't mention:
fx={mergeFx(animate(pulse, 600), { animationDuration: 'fast' })} // fast wins
The longhands are always available if you'd rather write them out:
fx={{ animationName: pulse, animationDuration: 600, animationIterationCount: 'infinite' }}
Stock animations
The common ones are ready-made, and each takes the same options:
import { fadeIn, scaleIn, slideIn, spin, pulse, shimmer } from '@fluixi-ui/fx';
<Card fx={scaleIn()} />
<Toast fx={slideIn('bottom', { distance: 16 })} />
<Spinner fx={spin()} />
<Skeleton fx={shimmer()} />
<Box fx={mergeFx(fadeIn('slow'), { p: 4 })} />
fadeIn · fadeOut |
Opacity |
scaleIn · scaleOut |
The overlay/menu entrance: a fade with a slight scale |
slideIn(edge) · slideOut(edge) |
Travels from (or to) top · bottom · left · right, fading as it goes. distance defaults to 8px |
spin |
Linear rotation, for loaders |
pulse |
Attention without movement |
shimmer |
The loading sweep — carries its own gradient, since the animation alone would paint nothing |
The enter animations set fill: 'both', because otherwise the element sits at its
finished style until the animation starts and you see a flash of the end state. The
@keyframes rule is registered on first use, not on import, so pulling one utility
out of the package doesn't put every animation's rule in your sheet.
None of them opts out of motion for you — what reduced motion should mean depends on the animation, since a decorative fade should stop and a loading spinner mostly shouldn't. Say it where you use it:
fx={{ ...pulse(), _motionReduce: { animationName: 'none' } }}
Call it at module scope. It registers a rule for the life of the process, which is what
an animation definition is; calling it per render would hash the same body over and over
for nothing. A stop takes flat declarations only: selectors and breakpoints have no
meaning inside @keyframes, and the type says so rather than dropping them silently.
Pair it with _motionReduce when the movement is decorative:
fx={{ animationName: pulse, _motionReduce: { animationName: 'none' } }}
Variants
Mapping your own component's props to fx, without a ternary chain that drifts from the
prop type:
import { variants } from '@fluixi-ui/fx';
const panel = variants({
base: { p: 3, radius: 'md' },
variants: {
tone: { neutral: { bg: 'bgSubtle' }, danger: { bg: 'dangerSubtle' } },
size: { sm: { p: 2 }, lg: { p: 4 } },
},
defaults: { tone: 'neutral', size: 'sm' },
});
<Box fx={panel({ tone: 'danger' })} />
Later groups win over earlier ones and base loses to all of them — the order you declare
them is the precedence. Passing false for a group turns a defaulted one off without
having to know which option the default was:
panel({ size: false }) // back to base padding
Reaching a compound component's parts
Two channels, because they answer different questions:
<PageLayout sp={{ header: { sticky: true, fx: { gap: 2 } } }} /> {/* a part's props */}
<PageLayout fx={{ p: 4, sp: { header: { gap: 2 } } }} /> {/* only its styles */}
sp forwards a part whatever props it takes, since styling is just one of them; fx.sp
keeps dressing a whole component in one object. Where both name the same part,
sp.<part>.fx wins — it names that part exactly, while the shorthand is addressed to the
component.
Components whose parts you compose yourself take fx on each part instead, since you're
already holding them:
<Dialog>
<DialogContent fx={{ p: 5, maxWidth: 480 }}>…</DialogContent>
</Dialog>
Escape hatches
A raw style prop is never token-resolved and merges last, so it always wins:
<Box fx={{ bg: 'primary' }} style="background: red" /> {/* red */}
To use tokens where fx doesn't reach — an inline SVG, a canvas, a third-party component
— read the variable map:
import { vars } from '@fluixi-ui/fx';
<svg fill={vars.color.primary} /> // var(--flx-ui-color-primary)
Reactivity
fx is reactive at the object level. Read a signal inside it and the styling follows —
props compile to accessors, so the read is tracked where you wrote it:
const [dense, setDense] = createSignal(false);
<Box fx={{ p: dense() ? 1 : 4 }} />
An accessor works too — fx={() => (dense() ? compact : roomy)} — and is what you want
when the whole object is swapped rather than one value in it. What isn't supported is a
per-property accessor: { width: () => w() } is not a value, it's a function, and the
resolver would serialise it as one.
Underneath, fxProps resolves inside a memo, so a change re-resolves once and writes the
style attribute of that one element. Nothing re-renders and the element is never
replaced. Only the properties that actually changed differ in the attribute.
Everything else in the package is pure and reads whatever you hand it at call time:
resolveFx · mergeFx |
Pure. Accept an accessor and call it, so they track inside a memo and don't outside one |
insertFx |
Imperative and idempotent — inserting the same rule twice is a Set lookup |
collectFx · resetFx |
Server lifecycle, not reactive: a snapshot and a reset |
One caveat worth knowing. Flat declarations are inline, so a changing value costs nothing but an attribute write. A value inside a selector block or breakpoint is rule-backed, and rules are content-addressed — a new value means a new rule, and old ones are kept for reuse rather than swept. That's ideal for a handful of states and wrong for a continuous one:
fx={{ _hover: { width: x() } }} // a rule per value — don't
fx={{ '--x': `${x()}px`, _hover: { width: 'var(--x)' } }} // one rule, inline variable
How it lands
Flat declarations and every responsive base go inline on the element, so most usage
never touches a stylesheet at all. Only selector blocks, non-base breakpoints and
container queries are hashed, deduped and inserted as [data-fx="…"] rules.
Those rules live in the flx.fx layer, last in the order the sheet declares for itself:
@layer flx.reset, flx.base, flx.tokens, flx.components, flx.skin, flx.fx;
So fx beats components and skins — it's the local escape hatch — while your own
unlayered CSS still beats fx. Redeclaring the order in the sheet is a no-op, but it
pins the layer position whatever stylesheet the document happened to load first.
Vendor prefixes are handled for the properties Safari still only understands prefixed
(backdropFilter, backgroundClip, maskImage, userSelect, appearance, hyphens,
textSizeAdjust). The prefixed name is written first, so the standard one wins wherever
it's supported. This is not a general autoprefixer — a stale entry in that list is a dead
declaration shipped to every user forever.
Server rendering
Emit the collected rules into <head> and the client adopts the sheet instead of
re-inserting them:
import { renderToString } from '@fluixi/server';
import { collectFx, resetFx } from '@fluixi-ui/fx';
resetFx(); // per request, if the runtime is reused
const html = renderToString(<App />);
const { css, tokens } = collectFx();
`<style data-fx-sheet data-fx-tokens="${tokens}">${css}</style>`
The data-fx-tokens list seeds the client's inserted set, so hydration doesn't re-insert
rules that are already on the page. Animations survive resetFx() — they're module-scope
definitions rather than per-request rules, so dropping them would leave every request
after the first naming an animation nobody emits.
Part of the Fluixi UI component library. Made with ☕ by the Fluixi team.