|
| 1 | +# Styling a component with StyleX |
| 2 | + |
| 3 | +Mosaic styling is migrating off the Emotion slot-recipe engine (`styling.md`) |
| 4 | +onto **StyleX** (`@stylexjs/stylex` 0.19). StyleX is compile-time atomic CSS: the |
| 5 | +style objects become hashed atom classes plus one static stylesheet, with zero |
| 6 | +runtime. This file is the authoring model for the StyleX layer; read it against |
| 7 | +the pilot, `packages/ui/src/mosaic/components/button/`. |
| 8 | + |
| 9 | +The public contract is unchanged from the recipe era — consumers still target |
| 10 | +`--cl-*` vars, the `.cl-<slot>` class, and `data-<axis>` attrs, never StyleX's |
| 11 | +hashed `x…` atoms. What changes is how a component is authored internally. |
| 12 | + |
| 13 | +## File layout & the `.stylex.ts` rule |
| 14 | + |
| 15 | +| File | Holds | |
| 16 | +| ------------------------- | ---------------------------------------------------------- | |
| 17 | +| `tokens.stylex.ts` | `defineVars` / `defineConsts` only (the tokens) | |
| 18 | +| `<comp>/<comp>.styles.ts` | `stylex.create({...})` — the atoms | |
| 19 | +| `<comp>/<comp>.tsx` | component; spreads `stylex.props(...)` via `mergeProps` | |
| 20 | +| `props.ts` | `themeProps` (`.cl-<slot>` + `data-<axis>`) + `mergeProps` | |
| 21 | +| `styles/index.ts` | isolated-build barrel; derives `*VarName` types | |
| 22 | + |
| 23 | +All 9 `@stylexjs` eslint rules run on `src/mosaic/**`. The `enforce-extension` |
| 24 | +rule reserves the `.stylex.ts` extension for token files: **a `.stylex.ts` file |
| 25 | +may export nothing but `defineVars`/`defineConsts` results.** So: |
| 26 | + |
| 27 | +- `stylex.create(...)` lives in a plain `.styles.ts` file, never `.stylex.ts`. |
| 28 | +- Derived types (`type ColorVarName = keyof typeof colorVars`) live in |
| 29 | + `styles/index.ts`, not in `tokens.stylex.ts` (they'd be a disallowed export). |
| 30 | + |
| 31 | +## Tokens (`tokens.stylex.ts`) |
| 32 | + |
| 33 | +**`defineVars` keys that start with `--` emit verbatim** as real custom |
| 34 | +properties; other keys get hashed. That verbatim behavior is the whole point — |
| 35 | +it gives consumers stable `--cl-*` vars to override in plain CSS: |
| 36 | + |
| 37 | +```ts |
| 38 | +const colorDefaults = { |
| 39 | + // one value carries light + dark; resolves against the in-scope `color-scheme`, |
| 40 | + // so dark mode lives in the token, no `@media (prefers-color-scheme)` copy. |
| 41 | + '--cl-color-primary': 'light-dark(oklch(0.205 0 0), oklch(0.922 0 0))', |
| 42 | +} as const; |
| 43 | +export const colorVars = stylex.defineVars(colorDefaults); |
| 44 | +``` |
| 45 | + |
| 46 | +**Spacing is one exposed var plus a `defineConsts` scale.** Only `--cl-spacing` |
| 47 | +is a custom property; every step is inlined at build as `calc(var(--cl-spacing) * |
| 48 | +n)` and carries no var of its own: |
| 49 | + |
| 50 | +```ts |
| 51 | +export const spacingVars = stylex.defineVars({ '--cl-spacing': '0.25rem' }); |
| 52 | + |
| 53 | +const step = (multiple: number): string => `calc(var(--cl-spacing) * ${multiple})`; |
| 54 | +export const space = stylex.defineConsts({ '0': '0px', '2': step(2), '8': step(8) /* … */ }); |
| 55 | +// usage: gap: space['2'] |
| 56 | +``` |
| 57 | + |
| 58 | +Why `defineConsts` and not a `space(n)` helper: StyleX evaluates |
| 59 | +`stylex.create` **statically at build time** and cannot inline a helper imported |
| 60 | +from a non-`.stylex` module (it errors "Could not resolve the path to the |
| 61 | +imported file"). `defineConsts` is StyleX's shareable inlined-value primitive, so |
| 62 | +it's the only way to get a scale that is both shared across components and free |
| 63 | +of per-step vars. |
| 64 | + |
| 65 | +## Atoms (`<comp>.styles.ts`) |
| 66 | + |
| 67 | +`stylex.create` values must be statically resolvable. Allowed: literals, |
| 68 | +**same-file** locals, and references to `.stylex` tokens (`colorVars[...]`, |
| 69 | +`space['2']`). Not allowed: a value from a helper imported across modules. For |
| 70 | +arithmetic, inline a `calc()` template literal with a token: |
| 71 | +`` `calc(-1 * ${space['2']})` ``. |
| 72 | + |
| 73 | +**Conditions.** Use StyleX's conditional-value objects (a `default` plus |
| 74 | +pseudo / at-rule keys), and nest to guard hover so it never sticks on touch — |
| 75 | +this is the StyleX form of the old `_hover` condition: |
| 76 | + |
| 77 | +```ts |
| 78 | +backgroundColor: { |
| 79 | + default: colorVars['--cl-color-primary'], |
| 80 | + ':active': `color-mix(in oklab, ${colorVars['--cl-color-primary']}, ${colorVars['--cl-color-primary-foreground']} 24%)`, |
| 81 | + '@media (hover: hover)': { |
| 82 | + // only the top-level value needs `default`; the nested block just adds `:hover` |
| 83 | + ':hover': `color-mix(in oklab, ${colorVars['--cl-color-primary']}, ${colorVars['--cl-color-primary-foreground']} 12%)`, |
| 84 | + }, |
| 85 | +}, |
| 86 | +``` |
| 87 | + |
| 88 | +Guard `:hover` behind `@media (hover: hover)`; leave `:active`, `:focus-visible`, |
| 89 | +`:disabled` unguarded. Runtime conditions the component owns (disabled, etc.) are |
| 90 | +also reflected as `data-<axis>` attrs via `themeProps` so they stay overridable. |
| 91 | + |
| 92 | +Write the guard **raw**, as above — there is no `hover()` helper to import, |
| 93 | +because StyleX can't inline a cross-module helper into `create` (the Emotion |
| 94 | +engine's `hover()`/`motionSafe()` utils don't translate). `*.styles.ts` files are |
| 95 | +therefore exempted from the repo's `no-restricted-syntax` media-query rule |
| 96 | +(`eslint.config.mjs`); the `@stylexjs/*` rules still apply. |
| 97 | + |
| 98 | +## Public contract (`props.ts`) |
| 99 | + |
| 100 | +The element carries three things, and nothing else is a contract: |
| 101 | + |
| 102 | +1. `--cl-*` vars (from tokens), 2. `.cl-<slot>` class, 3. `data-<axis>` attrs. |
| 103 | + |
| 104 | +`mergeProps` fuses them in precedence order — **theme props → StyleX atoms → |
| 105 | +consumer `className`/`style`** — so the consumer always wins: |
| 106 | + |
| 107 | +```tsx |
| 108 | +<button {...mergeProps(themeProps('button', { intent, variant }), stylex.props(styles.base, ...), className, style)} /> |
| 109 | +``` |
| 110 | + |
| 111 | +## Build & CSS delivery (two contexts, same babel) |
| 112 | + |
| 113 | +- **Published** (`build:mosaic` → `@stylexjs/rollup-plugin`): compiles the |
| 114 | + `styles/index.ts` barrel into `dist-mosaic/styles.css`, exported as |
| 115 | + `@clerk/ui/styles.css`. Consumers choose the cascade layer at import: |
| 116 | + `@import '@clerk/ui/styles.css' layer(components)`. |
| 117 | +- **Swingset** (source-consumed): `@stylexjs/unplugin/webpack` in `next.config` |
| 118 | + transforms StyleX **JS only** (calls → static atoms; SWC/Emotion untouched); |
| 119 | + `@stylexjs/postcss-plugin` extracts the **CSS** by replacing `@stylex;` in |
| 120 | + `globals.css`. Both must share the same StyleX babel version + options so atom |
| 121 | + hashes line up. |
| 122 | + |
| 123 | +Both set **`useCSSLayers: true`** (StyleX emits `@layer priorityN` for its own |
| 124 | +precedence; the consumer's `@import … layer()` picks the outer layer). Both pin |
| 125 | +**lightningcss targets** to browsers with native `light-dark()`/`oklch()` so the |
| 126 | +token colors aren't down-leveled into an invalid polyfill. |
| 127 | + |
| 128 | +## CSS features we lean on / caveats |
| 129 | + |
| 130 | +- YES: `var()`, `calc()`, `color-mix()`, `light-dark()`, nested `@media`+pseudo, |
| 131 | + `:hover`/`:active`/`:focus-visible`/`:disabled`, `::before`/`::after`. |
| 132 | +- Prefer CSS-native solutions over JS workarounds for anything StyleX supports. |
| 133 | +- Avoid manual `@layer` / `@property` inside `create` (StyleX owns layering; |
| 134 | + `@property` compiles but emits invalid output). |
| 135 | +- We do not use functions-in-`create` (dynamic runtime values); Mosaic styling is |
| 136 | + fully static. |
0 commit comments