@fluixi-ui/fx
v0.1.0-alpha.3
Published
Token-aware style objects for the Fluixi UI ecosystem — the `fx` prop engine
Readme
@fluixi-ui/fx
Token-aware style objects for Fluixi UI — the engine behind the fx prop.
<Box fx={{ p: 4, bg: 'bgSubtle', radius: 'lg', _hover: { bg: 'bgMuted' } }} />No CSS-in-JS runtime, no dependencies. Values resolve to the --flx-ui-* custom
properties @fluixi-ui/tokens defines, so anything written with fx follows the
theme, the accent, and the skin — including a theme driven from @fluixi-css.
Values
A property's scale decides both which tokens a string key resolves against and what a bare number means:
p: 3 // padding: var(--flx-ui-space-3) — space scale
m: -2 // margin: calc(var(--flx-ui-space-2) * -1)
width: 240 // width: 240px — sizing is pixels
zIndex: 3 // z-index: 3 — unitless stays bare
radius: 'lg' // border-radius: var(--flx-ui-radius-lg)
bg: 'primary' // background-color: var(--flx-ui-color-primary)
bg: 'accent-9'// background-color: var(--flx-ui-accent-9)
bg: '#ff0000' // background-color: #ff0000 — raw CSS always allowedAliases are deliberately few — p px py m mx my bg radius shadow.
Everything else is the real CSS property name (paddingTop, not pt), so there's
no table to learn. px/py map to the logical properties and follow writing
direction.
Selectors
A closed set of _-prefixed keys. They expand to the library's real conventions,
not just the CSS pseudo — headless parts signal state through data-*/ARIA:
_disabled: { opacity: 'disabled' }
// :disabled, [disabled], [data-disabled]State: _hover _active _focus _focusVisible _focusWithin _disabled
_checked _selected _expanded _open _invalid
Pseudo-elements: _before _after _placeholder _selection
Structural: _first _last _odd _even _notFirst _notLast _children
Environment: _dark _light _hc _rtl _motionReduce _motionSafe
_dark targets [data-theme='dark'] — the axis createTheme drives — and matches
the element itself as well as a themed ancestor, so scoped theming works.
The set being closed is the point: fx can't reach an app's class names, so a
component can never couple itself to consumer CSS.
Nesting composes:
_dark: { _hover: { bg: 'primaryHover' } }Responsive
Mobile-first objects keyed by breakpoint. base carries no media query:
fx={{ p: { base: 3, md: 5 } }}Container queries
Respond to the space a component is actually given rather than the viewport. The container is an ancestor — an element can't query itself:
<Card fx={{ containerType: 'inline-size' }}>
<Stack fx={{ _container: { md: { flexDirection: 'row' } } }} />
</Card>Keys are a breakpoint name or a raw condition. Element-relative sizes often don't line up with the page scale, so raw conditions are first-class:
_container: { '(min-width: 30rem)': { gap: 4 } }Name a container when the nearest one isn't the one you mean:
<aside fx={{ containerType: 'inline-size', containerName: 'panel' }}>
<div fx={{ _container: { name: 'panel', lg: { p: 6 } } }} />
</aside>At-rules nest rather than combine, so a container query and a media query can wrap the same declarations.
Colour
Helpers emit CSS; they never compute a colour in JS. That matters here: tokens
are custom properties that re-resolve against data-theme / data-accent, so a
value computed at render would freeze whichever theme happened to be active.
color-mix() and relative colour syntax re-resolve on their own, so a derived
colour keeps following the theme, the accent and the skin.
import { alpha, mix, lighten, darken } from '@fluixi-ui/fx';
alpha('primary', 12) // color-mix(in oklab, var(--flx-ui-color-primary) 12%, transparent)
mix('primary', 'bg', 20) // 20% primary over the page background
lighten('primary', 8) // oklch(from … calc(l + 0.08) c h / alpha)
darken('border', 10)Every amount is a percentage, 0–100. lighten/darken shift lightness in OKLCH,
which keeps chroma and hue — a tint stays the same colour instead of drifting grey
the way mixing with white or black does. They need relative colour syntax
(Chrome 119+, Safari 16.4+, Firefox 128+); for a wider floor use
mix(color, 'white', n).
How it lands on the page
Flat declarations and every base value go to the style attribute — most fx
usage never touches the stylesheet. Only selector keys and non-base breakpoints
need a rule; those are hashed, deduped, and inserted under [data-fx="…"] into
@layer flx.fx, which sits above components and skins so fx wins, and below
unlayered consumer CSS so an app can still override it.
Server rendering
Inline declarations need nothing. For the rule tier, emit what was collected:
const { css, tokens } = collectFx();
`<style data-fx-sheet data-fx-tokens="${tokens}">${css}</style>`;The client adopts that sheet instead of re-inserting its rules.
Composing
mergeFx folds a component's own styling, its sp entries, and the consumer's
fx into one object — later wins per property, selector blocks and breakpoint
records merge rather than replace:
resolveFx(mergeFx(componentOwn, sp.body?.fx, props.fx));A compound component reaches its parts with slot, which takes both channels: the
part's own sp entry, and the styles a caller addressed to it through fx.sp.
<PageBody {...slot('body', local.fx, local.sp)} />Binding it to an element
const [local, others] = splitProps(props, ['fx', 'style']);
return <div {...fxProps(() => local.fx, () => local.style)} {...others} />;Pass accessors, not values. The resolve runs inside a memo, so a reactive fx
updates without re-rendering. A style prop is merged last and stays raw CSS —
no token resolution — so it remains the lowest-level escape hatch.
License
MIT
