Readiness: Implemented
Build an app
Styling and content
CSS, Markdown and MDX all work without being switched on. This page is written in MDX and imports nothing to say so.
What you will be able to do: style with CSS, or with StyleX and its tokens, write pages in Markdown and MDX with highlighted code, and serve public files.
What you need first: a project — Your first project.
CSS
Import a stylesheet from any module:
import "./_design/seam.css";
Vite bundles it, hashes it, and — because uf prerenders — links it from the
generated HTML. CSS Modules work with the usual .module.css suffix, and so do
the preprocessors Vite supports, if you install one.
There is no runtime CSS-in-JS in uf and none is planned. A stylesheet the browser can cache separately from the JavaScript is faster than one it cannot, and the prerendered HTML needs the styles before hydration anyway. uf's own styling system, below, is a compiler: it writes CSS at build time and leaves one small function in the bundle.
StyleX, and the tokens that come with it
app.builtins.style is "style-x" by default, so stylex.create in any module
of your project is compiled by uf transform — the same pass that handles Flow
and the React Compiler — into class names, with the rules extracted as CSS.
There is no Babel plugin to add and no PostCSS step.
"none" turns that compiler off. Plain CSS and CSS modules keep going through
Vite, which is the non-StyleX path. "none" does not rewrite a StyleX file
into CSS: a stylex.create call stays in the module, and the runtime stub
throws if that call runs.
// @flow
import * as React from "@uniflowed/react";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
const styles = stylex.create({
card: {
backgroundColor: ufTokens.surface,
color: ufTokens.ink,
borderRadius: ufTokens.radiusMd,
padding: ufTokens.space4,
},
});
export component Card(children: React.Node) {
return <section {...props(styles.card)}>{children}</section>;
}
props is the only function in that import that exists at run time; it merges
compiled styles left to right, one winner per CSS property. create,
defineVars and createTheme are compile-time forms, and each of them throws
if it is ever reached at run time, naming the binding that got there rather than
silently doing nothing.
The tokens
@uniflowed/stylex/tokens.stylex.js exports ufTokens, 45 custom properties
the build declares on :root. They are named for what they are for, never for
what they look like — accent is the colour a primary action wears, and it can
become green without a rule in the preset changing. The shipped values are
neutral greys with one quiet cobalt accent, and light and dark are designed as
two palettes; the design note
explains the choice.
| Group | Names |
|---|---|
| Surfaces | canvas, sunken, surface, surfaceHover, border |
| Text | ink, muted |
| Accent | accent, accentHover, accentInk, accentSoft |
| Danger | danger, dangerHover, dangerInk, dangerSoft |
| State | focus, scrim |
| Type | fontSans, fontMono, textXs … text2Xl, leadingTight, leadingBase, weightRegular, weightMedium, weightBold |
| Space | space1 … space12 |
| Shape | radiusSm (2px), radiusMd (4px), radiusLg (6px), radiusPill, sizeControl |
| Motion | durationFast (120ms), durationBase (200ms), durationSlow (280ms); easing (standard), easingEnter (decelerate), easingExit (accelerate), none of which overshoots. There are no shadow tokens: surfaces are separated by a 1px border and a change of background |
What is deliberately not a token is geometry exactly one control uses. A switch's track is 36 by 20 because that is what a switch is; promoting it to a token would invite a theme to change it into something the thumb no longer fits.
The presets
@uniflowed/stylex/preset is uf's default look, as twelve functions that each
answer with { className } and nothing else:
import { buttonStyles, cardStyles } from "@uniflowed/stylex/preset";
import { Dialog } from "@uniflowed/ui";
<div {...cardStyles()}>
<button {...buttonStyles({ tone: "primary" })}>Save</button>
</div>;
surfaceStyles, cardStyles, textStyles, buttonStyles, fieldStyles,
backdropStyles, dialogStyles, menuStyles, menuItemStyles,
tabListStyles, tabStyles and controlStyles, with variants like
tone: "primary" | "neutral" | "ghost" | "danger" and size: "sm" | "md" | "lg".
Because they answer with a plain className, they style
the headless components without those components knowing StyleX
exists — <Switch {...controlStyles({ shape: "track" })} /> keeps every one of
its ARIA attributes and its keyboard behaviour. Two files assert that rather
than assuming it, and they are not the same file:
npm/stylex/stylex.test.js renders the styled switch and holds the preset
against it — the classes land on the control, and it still reports
aria-checked — while the keys are npm/ui/ui.test.js's, in "toggles a
switch on Space and on Enter", over a switch with no classes at all. Which is
the arrangement the claim needs: a className cannot reach a key handler, so
the styled test proves the styling arrives and the unstyled one proves there was
behaviour there to keep.
The state a part announces
A headless part says what state it is in: aria-selected="true" on the
selected tab, aria-expanded="true" on a trigger whose panel is showing,
data-active="true" on the option a select's keyboard cursor rests on. A
condition can select exactly that, so the style follows the part rather than a
prop the page has to keep in step with it:
const styles = stylex.create({
tab: {
color: { default: ufTokens.muted, ":is([aria-selected=true])": ufTokens.ink },
borderBottomColor: { default: "transparent", ":is([aria-selected=true])": ufTokens.accent },
},
});
<Tabs.Tab {...props(styles.tab)} value="billing">
Billing
</Tabs.Tab>;
A tab the arrow keys selected is drawn selected, and the page never had to
learn which one it was. The select's cursor is the case with no other answer:
focus stays on the trigger, so no pseudo-class matches the option, and without
a highlight a keyboard user cannot see which option Enter will take.
The shape is narrow on purpose: an attribute selector inside :is() or
:not(), with an unquoted value — [data-state=open], never
[data-state="open"]. A state is emitted after :hover, :focus-visible,
:active and the form states, so a pressed toggle stays pressed under the
pointer; ":is([aria-pressed=true]):hover" is emitted after the state on its
own, which is how that toggle gets a hover colour of its own.
Dark mode
Two themes ship, and both are one spread:
import { props } from "@uniflowed/stylex";
import { ufAutoTheme } from "@uniflowed/stylex/theme";
<body {...props(ufAutoTheme)}>…</body>;
ufAutoTheme wraps every override in @media (prefers-color-scheme: dark), so
it follows the operating system and costs nothing in light mode.
ufDarkTheme is unconditional, for a switch a reader flips. Both override the
same 19 tokens, and applying both leaves the unconditional one winning token by
token.
Your own theme is createTheme over the same token set, so it changes what the
presets and every component built on them look like:
export const brandTheme = stylex.createTheme(ufTokens, {
accent: "#0f766e",
accentHover: "#115e59",
});
The contrast is checked rather than claimed.
crates/uf_stylex/src/tests/preset.rs compiles the stylesheet and computes
WCAG relative luminance over the 14 foreground/background pairs the preset
actually pairs, asserting 4.5:1 in the light default and again in the dark
theme, and 3:1 for the focus ring against both surface and canvas,
which is the non-text threshold. The tightest of those margins today are
accent on accentSoft at 4.69:1 and the focus ring at 3.89:1 on canvas.
Three more tests in the same file hold the two themes to the same token set, and
one asserts the automatic theme emits nothing at all in light mode.
What holds it to a built page
An application build writes the stylesheet it compiled, and links it from the
prerendered HTML together with the :root block that declares the tokens its
rules read. crates/uf_cli/tests/vite_styles.rs builds such an application and
checks the artefact rather than the pipeline: every class on the prerendered
page has a rule, every custom property a rule reads is declared, and
ufAutoTheme's overrides sit inside the media query that makes them a dark
theme. Until issue #306
closed, the classes on a built page named rules that did not exist, and this
section said so.
The rest is tested where it lives: the compiler (crates/uf_stylex, including
the contrast suite above), the runtime merge in props
(npm/stylex/stylex.test.js), and the preset applied to a headless
component.
Not there, and saying so
keyframes, firstThatWorks and positionTry are not implemented. Writing one
is a compile error today rather than a call that silently does nothing, which is
the deliberate half of that sentence.
@uniflowed/stylex is also not a global reset, and not a component library.
Getting uf's look means the preset functions plus the tokens; getting uf's
behaviour means @uniflowed/ui; and either can be replaced without
the other noticing.
What uf ui add does not rewrite
uf ui add writes components in this StyleX. Each file calls stylex.create
and reads these tokens, and the command copies that file. It does not rewrite
the copy into plain CSS. A project that does not use the StyleX integration
styles the headless parts itself —
owned or headless. Those parts take a
className and do not import @uniflowed/stylex.
npm/ui/headless-consumer.test.js renders one that way.
Markdown and MDX
A $page.mdx is a page. Markdown is GitHub-flavoured, front matter is
parsed, and headings get ids so they can be linked:
export const frontmatter = { title: "Release notes" };
# Release notes
Tables, footnotes and task lists all work.
MDX means components in prose. Import one and use it:
import { Chart } from "../_design/chart.js";
Last quarter looked like this:
<Chart data={data} />
The MDX pipeline is on by default, configured for React with GitHub-flavoured
markdown, front matter and heading ids. app.builtins.markdown in
the config turns pieces of it off or points them
elsewhere.
Syntax highlighting
Fenced code is highlighted during the build, so the colours are in the HTML and no highlighter is shipped to the browser:
component Counter(initial: number = 0) renders React.Node {
const [count, setCount] = useState(initial);
return <output onClick={() => setCount(count + 1)}>{count}</output>;
}
Both a light and a dark theme are emitted together as CSS variables, because a build cannot know which the reader prefers; the stylesheet picks one.
No highlighter has a Flow grammar, so a JavaScript one runs and uf makes up the
difference at both ends. Words it reads as plain names — component, hook,
renders, match, opaque, and the mixed and empty types — are marked
afterwards. Syntax it cannot parse at all, a component or hook declaration
head and the {| … |} of an exact object, is shown to it as ordinary
JavaScript and put back before the HTML is written, so the parameter list of a
component is highlighted like the function it becomes.
To change the themes, or to add a language:
export default defineConfig({
app: {
builtins: {
markdown: {
mdx: {
highlight: {
themes: { light: "min-light", dark: "vitesse-dark" },
langs: ["python", "sql"],
},
},
},
},
},
});
highlight.enabled: false turns it off.
Public files
Anything in public/ is served from the root and copied into the build
untouched. public/brand/favicon.svg is /brand/favicon.svg.
What this page is
This page is app/guide/styling/$page.mdx. The .lede paragraph above it
is plain HTML in the MDX, the sidebar comes from the layout two directories up,
and the whole thing is one route in a table nobody wrote.
Where to go next
UI components is next: uf ui add writes components in this
StyleX, built on parts that own the keyboard, ARIA and composition. The
command does not rewrite a copy into plain CSS.
Edit this pagedocs/app/guide/styling/$page.mdx