@@ -78,11 +78,29 @@ export const colorVars = stylex.defineVars(colorDefaults);
7878- ** DO** name internal, non-contract vars with a ` --_cl-* ` prefix (e.g. a value a
7979 parent writes for a child to read). They still emit verbatim but the ` _ ` marks
8080 them "not a contract, don't override."
81+ - ** DO** name a radius by ** what the surface is** , not by size, so the steps nest:
82+ ` --cl-radius-inner ` for a mark inside a control, ` --cl-radius-control ` for the
83+ control itself (button, avatar square), ` --cl-radius-container ` for anything
84+ wrapping controls. A role name survives a value change; ` --cl-radius-md ` doesn't.
8185- ** DO** compute tints at the call site with ` color-mix() ` , not as their own
8286 tokens. ` color-mix(in oklab, ${primary}, ${fg} 12%) ` beats minting
8387 ` --cl-color-primary-hover-12 ` .
8488- ** DON'T** mint a per-step derivative token for something a ` calc() ` /` color-mix() `
8589 can express from an existing token.
90+ - ** DO** build a fill that sits _ on top of_ an unknown backdrop — a hover or pressed
91+ wash on a transparent ` outline ` /` ghost ` control — as a ** scrim** : an opacity of
92+ black-on-light / white-on-dark over ` transparent ` , not a percentage of a gray token.
93+
94+ ``` ts
95+ const step = ` color-mix(in oklab, light-dark(oklch(0 0 0), oklch(1 0 0)) 12%, transparent) ` ;
96+ ```
97+
98+ A gray token like ` --cl-color-neutral ` is a 900, not black, so the same percentage of
99+ it lands lighter than the percentage of black — and by an amount that shifts with
100+ whatever the control sits on, so the step numbers stop describing what they render.
101+ The scrim composites, so one ramp reads consistently on every surface. This applies
102+ to overlay fills only: a fill with a color of its own (` filled-primary ` ) still blends
103+ from that color toward its own on-fill.
86104
87105** Spacing is one exposed var plus a ` defineConsts ` scale.** Only ` --cl-spacing `
88106is a custom property; every step is inlined at build as `calc(var(--cl-spacing) *
@@ -166,23 +184,119 @@ objects and compose them at the call site.
166184- A helper that assembles a slot may ** return an array** of atoms
167185 (` [base, direction[x], gap[n]] ` ) for the caller to spread into ` stylex.props ` .
168186
187+ ### Multiplying axes: flatten, don't plumb vars
188+
189+ When two visual axes multiply (` color ` × ` variant ` ), the tempting move is to have
190+ one axis write custom properties and the other read them — 3 + 4 declarations
191+ instead of 12, with each axis staying independent. ** Don't.**
192+
193+ - ** DON'T** declare custom properties inside a component's ` stylex.create ` to
194+ decouple that component's own axes. They emit into the stylesheet and appear on
195+ every instance in devtools, which reads as public API nobody agreed to support.
196+ A ` --cl-* ` name is worse still — that prefix is reserved for the overridable
197+ contract (see "Tokens").
198+ - ** DON'T** reach for ` defineVars ` + ` createTheme ` to dodge that. The names get
199+ hashed instead of ` --cl-* ` , but they still land in the inspector on every element.
200+ - ** DO** flatten the cross product into one variant map keyed ` <axis-a>-<axis-b> ` ,
201+ indexed directly. A template-literal key makes an unhandled pair a compile error,
202+ with no lookup map to maintain:
203+
204+ ``` ts
205+ const variants = stylex .create ({
206+ ' filled-primary' : {
207+ /* …*/
208+ },
209+ ' filled-negative' : {
210+ /* …*/
211+ },
212+ ' outline-primary' : {
213+ /* …*/
214+ },
215+ // …one entry per cell
216+ });
217+ // <comp>.tsx — TS resolves the template against the literal keys
218+ variants [` ${variant }-${color } ` ];
219+ ```
220+
221+ Each entry is self-contained, so a cell can be read and tuned straight against a
222+ design matrix — which is usually the form these specs arrive in.
223+
224+ - ** DO** hoist a value shared across cells to a ** same-file** ` const ` . StyleX
225+ inlines it at build, so the duplication leaves the source without emitting a var:
226+
227+ ``` ts
228+ const primaryHover = ` color-mix(in oklab, ${colorVars [' --cl-color-primary' ]}, ${colorVars [' --cl-color-primary-foreground' ]} 12%) ` ;
229+ ```
230+
231+ Same-file is required — an imported one fails static evaluation ("Atoms" above).
232+
233+ ** Where a var is still right.** This rule is about a component plumbing values to
234+ itself. A custom property remains the correct tool when a value must cross an
235+ element boundary (a parent computes, a descendant reads) or collapse an unbounded
236+ dynamic value into a single atom — the ` --_cl-* ` cases in "Tokens" and sub-pattern
237+ A in "Dynamic styles". Those have no var-free equivalent; decoupling one element's
238+ own axes does.
239+
240+ Flattening does scale multiplicatively, so for a genuinely large product reach for
241+ role atoms instead — each color exports the same role names (` solid ` , ` ink ` ,
242+ ` tint ` ) and the variant picks which to apply. Still var-free, at the cost of an
243+ indirection the flattened map doesn't have.
244+
169245### Conditions & state
170246
171247Use StyleX's conditional-value objects (a ` default ` plus pseudo / at-rule keys).
172248
173249``` ts
174250backgroundColor : {
175251 default : colorVars [' --cl-color-primary' ],
176- ' :active' : ` color-mix(in oklab, ${ colorVars [ ' --cl-color-primary ' ]}, ${ colorVars [ ' --cl-color-primary-foreground ' ]} 24%) ` ,
252+ ' :active' : primaryActive ,
177253 ' @media (hover: hover)' : {
178- // only the top-level value needs `default`; the nested block just adds `:hover`
179- ' :hover' : ` color-mix(in oklab, ${colorVars [' --cl-color-primary' ]}, ${colorVars [' --cl-color-primary-foreground' ]} 12%) ` ,
254+ // the media block contributes only the pseudo; the top-level `default` still
255+ // paints the rest state. `:not(:active)` is load-bearing — see below.
256+ default: null ,
257+ ' :hover:not(:active)' : primaryHover ,
180258 },
181259},
182260```
183261
184262- ** DO** guard ` :hover ` behind ` @media (hover: hover) ` so it never sticks on touch;
185263 leave ` :active ` , ` :focus-visible ` , ` :disabled ` unguarded.
264+
265+ #### A media-wrapped ` :hover ` outranks a bare ` :active `
266+
267+ StyleX encodes precedence by ** repeating the class name** , and an at-rule adds
268+ priority. So a ` @media (hover: hover) ` ` :hover ` compiles to a doubled selector while
269+ a bare ` :active ` stays single — and the hover wins while the button is pressed:
270+
271+ ``` css
272+ .x1ozrsgg :active {
273+ } /* 0,1,1 — loses */
274+ @media (hover : hover ) {
275+ .x1f1bnkq.x1f1bnkq :hover {
276+ } /* 0,3,0 — wins during the press */
277+ }
278+ ```
279+
280+ The symptom is a hovered button that never shows its pressed colour on a mouse
281+ device, while touch devices look correct.
282+
283+ - ** DO** write the hover branch as ` ':hover:not(:active)' ` . It stops the hover rule
284+ ** matching** during a press, so the fix rests on selector semantics rather than on
285+ how StyleX happens to order equal-specificity rules.
286+ - ** DO** keep the bare ` ':active' ` outside the media query. It is the only pressed
287+ state a no-hover device ever sees, since such a device never matches
288+ ` (hover: hover) ` .
289+ - ** DON'T** fix it by re-declaring ` ':active' ` _ inside_ the ` @media ` block. It does
290+ work — both selectors end up doubled and StyleX emits ` :active ` after ` :hover ` —
291+ but it depends on StyleX's emission order for the tiebreak and duplicates the
292+ value in every cell.
293+ - Applies to any state pair where one side is inside an at-rule and the other is
294+ not. Confirm the output rather than trusting it: ` pnpm build:mosaic --filter @clerk/ui ` ,
295+ then grep ` dist-mosaic/styles.css ` for the two selectors and compare their
296+ specificity.
297+
298+ Worked example: ` packages/ui/src/mosaic/components/button/button.styles.ts ` .
299+
186300- ** DO** use ` :focus-visible ` for focus rings (never bare ` :focus ` ). For a
187301 ** container** that should ring when a child is focused, use
188302 ` :has(:focus-visible) ` — ** not** ` :focus-within ` . ` :focus-within ` matches any
@@ -208,15 +322,59 @@ backgroundColor: {
208322 outlineOffset : { default : null , ' :focus-visible' : space [' 0.5' ] },
209323 ```
210324
211- - ** DO** gate every transition/animation on reduced motion, in the same object:
325+ - ** DO** take durations from ` durationVars ` (` --cl-duration-instant ` / ` -fast ` /
326+ ` -base ` / ` -slow ` / ` -slower ` ) rather than a literal, and vary them by state
327+ where the feedback should read as more or less direct:
212328
213329 ``` ts
214330 transitionDuration : {
215331 default : durationVars [' --cl-duration-fast' ],
332+ ' :active' : durationVars [' --cl-duration-instant' ],
333+ },
334+ ```
335+
336+ - ** DO** pick the timing function by ** what the property does** , not by how long
337+ it runs. Properties that move — ` transform ` , ` translate ` , ` scale ` , ` rotate ` ,
338+ insets — take ` easingVars['--cl-ease-default'] ` , whose slight overshoot is what
339+ sells the motion. Color and opacity take plain ` linear ` : their interpolation is
340+ already perceptually non-uniform, so an ease on top only makes the midpoint
341+ drag, and an overshoot extrapolates past the target color for nothing. A
342+ transform at ` --cl-duration-fast ` still wants the curve.
343+
344+ - ** DO** gate transitions/animations of ** motion-bearing** properties on reduced
345+ motion — ` transform ` , ` translate ` , ` scale ` , ` rotate ` , positional insets — in the
346+ same object. ` prefers-reduced-motion ` is a vestibular-safety signal, so color
347+ and opacity transitions do ** not** need it:
348+
349+ ``` ts
350+ transitionDuration : {
351+ default : durationVars [' --cl-duration-base' ],
216352 ' @media (prefers-reduced-motion: reduce)' : ' 0.01ms' ,
217353 },
218354 ```
219355
356+ - ** DO** floor an interactive control's hit area at ` targetVars['--cl-target-coarse'] `
357+ under ` @media (pointer: coarse) ` . Use ` minHeight ` /` minWidth ` so the size axis stays
358+ in charge otherwise, and give it its own atom rather than writing it into every
359+ size — the floor is one physical constant, and a part that isn't really a control
360+ (a ` link ` variant, which reads as text) opts out by not receiving the atom:
361+
362+ ``` ts
363+ touchTarget : {
364+ minHeight : { default : null , ' @media (pointer: coarse)' : targetVars [' --cl-target-coarse' ] },
365+ },
366+ ```
367+
368+ Square controls need the floor on ** both** axes, or the target grows tall and narrow.
369+ ` --cl-target-coarse ` is deliberately off the ` --cl-spacing ` scale: a consumer
370+ rescaling density must not shrink a touch target with it.
371+
372+ - ** DON'T** suppress interaction with ` pointer-events: none ` on a disabled control.
373+ An element that isn't hit-tested supplies no cursor, so ` cursor: not-allowed ` never
374+ renders, and a wrapping tooltip never gets the pointer to explain the disabled
375+ state. Gate the interactive states on ` :enabled ` instead — ` :hover ` /` :active ` still
376+ match a disabled element, so every one of them needs the gate.
377+
220378- ** DO** reflect runtime conditions the component owns (disabled, selected,
221379 invalid) as ` data-<axis> ` attrs via ` themeProps ` , in addition to the atom, so
222380 the state stays overridable in plain consumer CSS.
@@ -364,6 +522,7 @@ token colors aren't down-leveled into an invalid polyfill.
364522
365523- YES: ` var() ` , ` calc() ` , ` color-mix() ` , ` light-dark() ` , nested ` @media ` +pseudo,
366524 ` :hover ` /` :active ` /` :focus-visible ` /` :focus-within ` /` :disabled ` , ` :has() ` ,
525+ ` :not() ` and compound pseudo keys (` ':hover:not(:active)' ` compiles and lints),
367526 ` ::before ` /` ::after ` /` ::backdrop ` , ` @starting-style ` (enter animations),
368527 ` stylex.keyframes(...) ` , ` anchor-size(width|height) ` (popover/menu matching its
369528 trigger), CSS counters, ` @media (hover: hover) ` / ` (prefers-reduced-motion) ` /
0 commit comments