@@ -10,24 +10,53 @@ The public contract is unchanged from the recipe era — consumers still target
1010` --cl-* ` vars, the ` .cl-<slot> ` class, and ` data-<axis> ` attrs, never StyleX's
1111hashed ` x… ` atoms. What changes is how a component is authored internally.
1212
13- ## File layout & the ` .stylex.ts ` rule
13+ The guidance below is opinionated and evidence-backed: it codifies patterns
14+ proven out across a full StyleX component library (tokens, ~ 100 components,
15+ complex cases like Table/Calendar/Popover/Slider). Each is framed as ** do X /
16+ don't Y** . Lint enforcement of some of these is still landing, so treat them as
17+ conventions to follow by hand, not guarantees the toolchain makes for you.
1418
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 |
19+ ## File layout & the ` .stylex.ts ` convention
2220
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:
21+ | File | Holds |
22+ | --------------------------------- | ---------------------------------------------------------- |
23+ | ` tokens.stylex.ts ` | ` defineVars ` / ` defineConsts ` only (the tokens) |
24+ | ` <comp>/<comp>.styles.ts ` | ` stylex.create({...}) ` — this component's atoms |
25+ | ` <family>.styles.ts ` | ** shared** atoms imported by a whole component family |
26+ | ` <comp>/<comp>.markers.stylex.ts ` | ` stylex.defineMarker() ` results for scoped ancestor states |
27+ | ` <comp>/<comp>.tsx ` | component; spreads ` stylex.props(...) ` via ` mergeProps ` |
28+ | ` props.ts ` | ` themeProps ` (` .cl-<slot> ` + ` data-<axis> ` ) + ` mergeProps ` |
29+ | ` styles/index.ts ` | isolated-build barrel; derives ` *VarName ` types |
30+
31+ The ` @stylexjs ` eslint rules run on ` src/mosaic/** ` . The ` enforce-extension `
32+ rule reserves the ` .stylex.ts ` extension for StyleX define-primitives: ** a
33+ ` .stylex.ts ` file may export nothing but ` defineVars ` / ` defineConsts ` /
34+ ` defineMarker ` results.** So:
2635
2736- ` stylex.create(...) ` lives in a plain ` .styles.ts ` file, never ` .stylex.ts ` .
37+ - ` stylex.defineMarker() ` is a define-primitive like the token builders, so it
38+ lives in its own ` <comp>.markers.stylex.ts ` , never a plain ` .ts ` .
2839- Derived types (` type ColorVarName = keyof typeof colorVars ` ) live in
2940 ` styles/index.ts ` , not in ` tokens.stylex.ts ` (they'd be a disallowed export).
3041
42+ ** Styles live in their own file, never inline in the ` .tsx ` .**
43+
44+ - ** DO** put a component's ` stylex.create ` in a co-located ` <comp>.styles.ts ` and
45+ import the atoms into the component. The ` .tsx ` stays rendering-only. This is a
46+ deliberate Mosaic convention — a single predictable place for a slot's styles
47+ and a readable component file. It diverges from StyleX libraries that author
48+ ` create ` inline in the component; we don't.
49+ - ** DON'T** inline ` stylex.create ` in a ` .tsx ` . No "it's only a few atoms"
50+ exception — small components get a ` .styles.ts ` too.
51+ - ** DO** hoist atoms into a shared ` <family>.styles.ts ` when several siblings
52+ render the same visual surface — e.g. inputs (` TextInput ` , ` NumberInput ` , date
53+ fields, ` Selector ` ) sharing one ` inputWrapper ` / ` inputStatusBorder ` /
54+ ` inputStatusFocusWithin ` set instead of redefining the border/focus treatment
55+ five times. (The shared-surface pattern to reach for when it appears; no shared
56+ styles file exists in Mosaic yet.)
57+ - ** DON'T** copy a slot's atoms between siblings. A repeated style object is the
58+ same signal as a repeated conditional: extract the shared concept.
59+
3160## Tokens (` tokens.stylex.ts ` )
3261
3362** ` defineVars ` keys that start with ` -- ` emit verbatim** as real custom
@@ -43,6 +72,18 @@ const colorDefaults = {
4372export const colorVars = stylex .defineVars (colorDefaults );
4473```
4574
75+ - ** DO** put light + dark in one token value via ` light-dark() ` . Never ship a
76+ second ` @media (prefers-color-scheme: dark) ` copy of a color.
77+ - ** DO** reserve ` --cl-* ` -prefixed keys for the public, overridable contract.
78+ - ** DO** name internal, non-contract vars with a ` --_cl-* ` prefix (e.g. a value a
79+ parent writes for a child to read). They still emit verbatim but the ` _ ` marks
80+ them "not a contract, don't override."
81+ - ** DO** compute tints at the call site with ` color-mix() ` , not as their own
82+ tokens. ` color-mix(in oklab, ${primary}, ${fg} 12%) ` beats minting
83+ ` --cl-color-primary-hover-12 ` .
84+ - ** DON'T** mint a per-step derivative token for something a ` calc() ` /` color-mix() `
85+ can express from an existing token.
86+
4687** Spacing is one exposed var plus a ` defineConsts ` scale.** Only ` --cl-spacing `
4788is a custom property; every step is inlined at build as `calc(var(--cl-spacing) *
4889n)` and carries no var of its own:
@@ -62,6 +103,10 @@ imported file"). `defineConsts` is StyleX's shareable inlined-value primitive, s
62103it's the only way to get a scale that is both shared across components and free
63104of per-step vars.
64105
106+ ** Reference tokens by bracket string key** , always: ` colorVars['--cl-color-primary'] ` ,
107+ ` space['2'] ` . A computed key (` colorVars[name] ` ) defeats StyleX static analysis
108+ and won't compile.
109+
65110## Atoms (` <comp>.styles.ts ` )
66111
67112` stylex.create ` values must be statically resolvable. Allowed: literals,
@@ -70,9 +115,60 @@ of per-step vars.
70115arithmetic, inline a ` calc() ` template literal with a token:
71116`` `calc(-1 * ${space['2']})` `` .
72117
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:
118+ ### Breaking up ` stylex.create `
119+
120+ There is no one big styles object. Split by concern into several small named
121+ objects and compose them at the call site.
122+
123+ - ** DO** split into: a ** base** object (structure the slot always has), one or
124+ more ** variant maps** keyed by a prop value, and small ** state** objects applied
125+ conditionally.
126+
127+ ``` ts
128+ // base: what the slot always is
129+ const styles = stylex .create ({
130+ base: { display: ' inline-flex' , borderRadius: radiusVars [' --cl-radius-element' ] },
131+ iconOnly: { aspectRatio: ' 1' },
132+ });
133+ // variant map: keyed by the prop value, indexed at the call site
134+ const variants = stylex .create ({
135+ primary: { backgroundColor: colorVars [' --cl-color-primary' ] },
136+ secondary: { backgroundColor: colorVars [' --cl-color-secondary' ] },
137+ });
138+ const sizes = stylex .create ({
139+ sm: { height: sizeVars [' --cl-size-sm' ] },
140+ md: {
141+ /* …*/
142+ },
143+ });
144+ ```
145+
146+ - ** DO** compose base + modifier in a ** single** ` stylex.props(...) ` call, adding
147+ conditional atoms with ` && ` / ternary. Order matters — later wins, so consumer
148+ ` xstyle ` goes last:
149+
150+ ``` tsx
151+ stylex .props (
152+ styles .base ,
153+ variants [variant ], // prop-keyed map lookup
154+ sizes [size ],
155+ isIconOnly && styles .iconOnly ,
156+ xstyle , // consumer StyleX — always LAST so it wins
157+ );
158+ ```
159+
160+ - ** DON'T** put everything in one monolithic object (you lose prop-keyed lookup and
161+ conditional composition), and ** don't** swing the other way into one object per
162+ property. Group by concern: base, variant, size, state.
163+ - ** DON'T** branch a variant with ` variant === 'primary' ? a : b ` inside the style.
164+ Encode the axis as a keyed map and look it up (` variants[variant] ` ). A repeated
165+ ternary is a missing variant map.
166+ - A helper that assembles a slot may ** return an array** of atoms
167+ (` [base, direction[x], gap[n]] ` ) for the caller to spread into ` stylex.props ` .
168+
169+ ### Conditions & state
170+
171+ Use StyleX's conditional-value objects (a ` default ` plus pseudo / at-rule keys).
76172
77173``` ts
78174backgroundColor : {
@@ -85,29 +181,153 @@ backgroundColor: {
85181},
86182```
87183
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.
184+ - ** DO** guard ` :hover ` behind ` @media (hover: hover) ` so it never sticks on touch;
185+ leave ` :active ` , ` :focus-visible ` , ` :disabled ` unguarded.
186+ - ** DO** use ` :focus-visible ` for focus rings (never bare ` :focus ` ), and
187+ ` :focus-within ` on wrapping containers.
188+ - ** DO** use ` default: null ` when a property exists ** only** in a pseudo/state
189+ branch, so the atom doesn't emit a base value that would clobber a merged style:
190+
191+ ``` ts
192+ outline : { default : null , ' :focus-visible' : ` 2px solid ${colorVars [' --cl-color-primary' ]} ` },
193+ outlineOffset : { default : null , ' :focus-visible' : space [' 0.5' ] },
194+ ```
195+
196+ - ** DO** gate every transition/animation on reduced motion, in the same object:
197+
198+ ``` ts
199+ transitionDuration : {
200+ default : durationVars [' --cl-duration-fast' ],
201+ ' @media (prefers-reduced-motion: reduce)' : ' 0.01ms' ,
202+ },
203+ ```
204+
205+ - ** DO** reflect runtime conditions the component owns (disabled, selected,
206+ invalid) as ` data-<axis> ` attrs via ` themeProps ` , in addition to the atom, so
207+ the state stays overridable in plain consumer CSS.
208+
209+ ** Scoped ancestor states.** When a child's style depends on an ** ancestor's**
210+ interaction state (a wrapper ` :hover ` /` :focus-visible ` styling an inner mark),
211+ don't reach for a descendant combinator by hand. Use ` stylex.when.ancestor() `
212+ with a ** marker** so the selector is scoped to _ your_ subtree and can't be
213+ triggered by unrelated ancestors:
214+
215+ ``` ts
216+ // checkbox.markers.stylex.ts — a define-primitive module, its own file
217+ export const checkboxScope = stylex .defineMarker ();
218+
219+ // checkbox.styles.ts — the inner mark reacts to the labelled wrapper's state
220+ borderColor : {
221+ default : colorVars [' --cl-color-border' ],
222+ [stylex .when .ancestor (' :hover' , checkboxScope )]: {
223+ ' @media (hover: hover)' : ` color-mix(in oklab, ${colorVars [' --cl-color-border' ]}, ${colorVars [' --cl-color-tint' ]} 20%) ` ,
224+ },
225+ },
226+ ```
227+
228+ Apply the marker atom to the ancestor, reference the scope in the descendant's
229+ ` create ` . This replaces the Emotion engine's parent-selector hacks.
230+
231+ Write the hover guard and these selectors ** raw** , as above — there is no
232+ ` hover() ` helper to import, because StyleX can't inline a cross-module helper into
233+ ` create ` (the Emotion engine's ` hover() ` /` motionSafe() ` utils don't translate).
234+ ` *.styles.ts ` files are therefore exempted from the repo's ` no-restricted-syntax `
235+ media-query rule (` eslint.config.mjs ` ); the ` @stylexjs/* ` rules still apply.
236+
237+ ## Dynamic styles: dos and don'ts
238+
239+ A style key can be a ** function** of a runtime value:
240+ ` const dyn = stylex.create({ width: (w) => ({ width: w }) }) ` , applied
241+ ` dyn.width(value) ` . This is the right tool for a ** continuous or unbounded**
242+ runtime value that can't be enumerated as a variant map — sizes, transforms,
243+ offsets, indents, counts, delays, computed widths. It is ** not** a substitute for
244+ a variant map over a small closed set.
245+
246+ ** When to reach for dynamic (and when not):**
91247
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.
248+ - ** DO** use a dynamic function for a value that is continuous, per-instance, or
249+ formula-derived: an avatar pixel size (` size => ({ width: size, height: size }) ` ,
250+ font size ` size * 0.4 ` ), a drag transform
251+ (` t => ({ transform: t }) ` with ` t = \ ` scale(${zoom}) translate(...)\` `), a
252+ tree-row indent ` (depth-1) \* step ` , a line clamp ` n => ({ WebkitLineClamp: n }) ` ,
253+ a staggered ` animationDelay ` .
254+ - ** DON'T** use dynamic for a closed enum (sm/md/lg, primary/secondary) — that's a
255+ variant map. ** DON'T** use it for a plain static token — that's a static atom.
97256
98- ## Public contract (` props.ts ` )
257+ ** The important sub-distinction — write a ` --var ` , or set the property?**
258+
259+ There are two shapes of dynamic style. Prefer the first whenever the value feeds a
260+ rule that also needs media/pseudo variants, needs to be read by descendants, or
261+ must stay overridable by the consumer:
262+
263+ - ** A. Write a CSS custom property; let a static rule consume it.** Only ** one**
264+ atom is generated no matter how many distinct values flow through, and — this is
265+ the subtle win — a ` var() ` written into a class does ** not** beat a consumer's
266+ class-level ` @media ` override, whereas a raw inline ` style ` property would.
267+
268+ ``` ts
269+ // set the var dynamically…
270+ const dyn = stylex .create ({
271+ gutter : (digits : number ) => ({ ' --_cl-gutter' : ` ${digits }ch ` }),
272+ tabIndicator : (offset : string ) => ({ ' --_cl-tab-indicator' : offset }),
273+ });
274+ // …and consume it from a normal static atom / descendant rule
275+ const styles = stylex .create ({ row: { gridTemplateColumns: ' var(--_cl-gutter) 1fr' } });
276+ ```
277+
278+ Also the right shape for a value a parent computes and ** descendants** read
279+ (` --_cl-content-width ` , an avatar-group ` --_cl-overlap ` a sibling rule consumes):
280+ the var inherits; no per-descendant atom.
281+
282+ - ** B. Set the real property directly** — fine when the value is genuinely
283+ per-instance, only this element uses it, and no rule/descendant needs to read it
284+ (a live drag ` transform ` , a one-off ` width ` , ` WebkitLineClamp ` ). Accept that
285+ each distinct value emits its own atom.
286+
287+ ** Perf model to keep in mind:** each _ distinct argument value_ emits a new atom /
288+ injected rule. A bounded numeric set (~ 10–20 sizes) is fine. For an unbounded
289+ value, sub-pattern A collapses it to a single ` --var ` atom; reach for a raw inline
290+ ` style ` only when you specifically do ** not** want it to be overridable.
291+
292+ > This supersedes the earlier blanket "Mosaic styling is fully static." Static is
293+ > still the default — most styling is atoms, variant maps, and conditional-value
294+ > objects. Dynamic functions are the deliberate exception for continuous runtime
295+ > values, and even then the first move is usually to write a single ` --cl ` /` --_cl `
296+ > var rather than a raw inline style.
297+
298+ ## Public contract & composition (` props.ts ` )
99299
100300The element carries three things, and nothing else is a contract:
101301
1023021 . ` --cl-* ` vars (from tokens), 2. ` .cl-<slot> ` class, 3. ` data-<axis> ` attrs.
103303
104- ` mergeProps ` fuses them in precedence order — ** theme props → StyleX atoms →
105- consumer ` className ` /` style ` ** — so the consumer always wins:
304+ ` themeProps('button', { intent, variant }) ` produces the stable ` .cl-<slot> ` class
305+ plus a kebab-cased ` data-<axis> ` reflection of the visual props, so consumers
306+ target stable data-attribute selectors, not collision-prone class names.
307+
308+ ` mergeProps ` fuses everything in precedence order — ** theme props → StyleX atoms →
309+ consumer ` className ` /` style ` ** — so the consumer always wins. It concatenates
310+ className left-to-right and merges ` style ` with the consumer object spread last:
106311
107312``` tsx
108- <button { ... mergeProps (themeProps (' button' , { intent , variant }), stylex .props (styles .base , ... ), className , style )} />
313+ <button
314+ { ... mergeProps (
315+ themeProps (' button' , { intent , variant }),
316+ stylex .props (styles .base , variants [variant ], xstyle ),
317+ className ,
318+ style ,
319+ )}
320+ />
109321```
110322
323+ - ** DO** put consumer ` xstyle ` ** last** inside ` stylex.props(...) ` (so their atoms
324+ win the cascade) and consumer ` className ` /` style ` last inside ` mergeProps ` (so
325+ their raw CSS wins).
326+ - ** DON'T** forward ` xstyle ` down to internal slot elements — it targets the slot
327+ the consumer named, not your private structure.
328+ - ** DON'T** call ` stylex.props ` twice on one element or spread ` {...props} ` after
329+ the merge result — fuse everything through the one ` mergeProps ` call.
330+
111331## Build & CSS delivery (two contexts, same babel)
112332
113333- ** Published** (` build:mosaic ` → ` @stylexjs/rollup-plugin ` ): compiles the
@@ -128,9 +348,18 @@ token colors aren't down-leveled into an invalid polyfill.
128348## CSS features we lean on / caveats
129349
130350- YES: ` var() ` , ` calc() ` , ` color-mix() ` , ` light-dark() ` , nested ` @media ` +pseudo,
131- ` :hover ` /` :active ` /` :focus-visible ` /` :disabled ` , ` ::before ` /` ::after ` .
351+ ` :hover ` /` :active ` /` :focus-visible ` /` :focus-within ` /` :disabled ` , ` :has() ` ,
352+ ` ::before ` /` ::after ` /` ::backdrop ` , ` @starting-style ` (enter animations),
353+ ` stylex.keyframes(...) ` , ` anchor-size(width|height) ` (popover/menu matching its
354+ trigger), CSS counters, ` @media (hover: hover) ` / ` (prefers-reduced-motion) ` /
355+ ` (pointer: coarse) ` .
132356- Prefer CSS-native solutions over JS workarounds for anything StyleX supports.
133357- Avoid manual ` @layer ` / ` @property ` inside ` create ` (StyleX owns layering;
134358 ` @property ` compiles but emits invalid output).
135- - We do not use functions-in-` create ` (dynamic runtime values); Mosaic styling is
136- fully static.
359+ - No need for ` stylex.firstThatWorks ` or ` stylex.attrs ` — a proven full library
360+ ships without either; reach for conditional-value objects and ` mergeProps `
361+ instead.
362+ - Dynamic functions-in-` create ` are allowed but exceptional — see "Dynamic styles"
363+ above. Default to static atoms, variant maps, and conditional-value objects; use
364+ a dynamic function only for a continuous runtime value, and prefer writing a
365+ single ` --cl ` /` --_cl ` var over setting a property raw.
0 commit comments