GitHub

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.