Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/button-hover-timing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 2 additions & 0 deletions .changeset/mosaic-popover.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions .claude/skills/mosaic/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ this skill is the _how-to_.
| -------------------------------------------------------------------- | ------------------------------------------------------ |
| Building on / authoring a headless primitive (`@clerk/headless`) | `references/headless.md` |
| Styling a component with StyleX (tokens, `stylex.create`, CSS build) | `references/stylex.md` |
| Building an enter/exit transition, or any motion that reads as wrong | `references/motion.md` |
| Styling a component the legacy way (slot recipes, `useRecipe`) | `references/styling.md` |
| Authoring or debugging a state machine, or wiring one to React | `references/machines.md` → in-tree `machine/README.md` |
| Writing the controller (Clerk adapter, permissions, revalidate) | `references/controllers.md` |
Expand Down
361 changes: 361 additions & 0 deletions .claude/skills/mosaic/references/motion.md

Large diffs are not rendered by default.

86 changes: 85 additions & 1 deletion .claude/skills/mosaic/references/stylex.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,28 @@ device, while touch devices look correct.
then grep `dist-mosaic/styles.css` for the two selectors and compare their
specificity.

- **A button that opens something takes the pressed fill while open**, so a
disclosure trigger stays visibly engaged for as long as its surface is. Disclosure
primitives already set `data-open` on the trigger (`popover-trigger.tsx` and
friends), so this is styling-only — no headless change. It needs the _same_
exclusion as `:active`, or hovering an open trigger lifts it back to the lighter
hover step:

```ts
backgroundColor: {
default: 'transparent',
':enabled:active': neutralStep1,
':enabled[data-open]': neutralStep1,
'@media (hover: hover)': {
default: null,
':enabled:hover:not(:active):not([data-open])': neutralStep0,
},
},
```

Worked example: `button.styles.ts`, applied across every filled/outline/ghost cell
(`link` opts out — it reads as text, not a control).

Worked example: `packages/ui/src/mosaic/components/button/button.styles.ts`.

- **DO** use `:focus-visible` for focus rings (never bare `:focus`). For a
Expand Down Expand Up @@ -341,6 +363,12 @@ Worked example: `packages/ui/src/mosaic/components/button/button.styles.ts`.
drag, and an overshoot extrapolates past the target color for nothing. A
transform at `--cl-duration-fast` still wants the curve.

- **DON'T** reuse `--cl-ease-default` for something **leaving**. It is an arrival
curve; run backwards it stalls for most of its duration and its overshoot
becomes a wobble past the target. Departures take `easingVars['--cl-ease-exit']`
at a shorter duration. See `motion.md` — enter/exit asymmetry has its own
reference, with the measurements behind these rules.

- **DO** gate transitions/animations of **motion-bearing** properties on reduced
motion — `transform`, `translate`, `scale`, `rotate`, positional insets — in the
same object. `prefers-reduced-motion` is a vestibular-safety signal, so color
Expand Down Expand Up @@ -468,6 +496,61 @@ value, sub-pattern A collapses it to a single `--var` atom; reach for a raw inli
> values, and even then the first move is usually to write a single `--cl`/`--_cl`
> var rather than a raw inline style.

**Every condition is a value key, never a top-level object.** A pseudo/at-rule
goes _inside_ the property it modifies (`transitionProperty: { default: …, '@media …': … }`),
not as a bare key on the style object. A top-level `'@media …': { … }` block is
legacy syntax and the `@stylexjs/no-legacy-contextual-styles` +
`@stylexjs/valid-styles` rules reject it (only `::before`/`::after` may sit at the
top level). Reduced-motion is the common case:

```ts
transitionProperty: { default: 'opacity, transform', '@media (prefers-reduced-motion: reduce)': 'none' },
```

### Reacting to `data-*` state (the headless-transition case)

Headless primitives drive animation off `data-*` attributes — e.g. the popover
popup carries its own `data-starting-style` (entering frame) and
`data-ending-style` (exiting). You can style off these in StyleX; it depends on
_whose_ attribute you're reading:

- **The element's own attribute → wrap in `:where(...)`.** Conditional keys must
start with `:` or `@`, so a bare `[data-*]` is rejected — but `:where([data-*])`
is a valid pseudo-class string that matches the same element (zero specificity;
StyleX self-doubles the atom class so the conditional still wins):

```ts
popup: {
opacity: { default: 1, ':where([data-starting-style], [data-ending-style])': 0 },
transform: { default: 'scale(1)', ':where([data-starting-style], [data-ending-style])': 'scale(0.94)' },
// Reduced motion drops `transform` and keeps the fade — the gate belongs on the
// moving property, not the whole transition.
transitionProperty: { default: 'opacity, transform', '@media (prefers-reduced-motion: reduce)': 'opacity' },
// Positional against `transitionProperty`, and branched by direction: the exit is
// shorter and takes the departure curve. See `motion.md`.
transitionDuration: {
default: `${durationVars['--cl-duration-fast']}, ${durationVars['--cl-duration-base']}`,
':where([data-ending-style])': durationVars['--cl-duration-fast'],
},
transitionTimingFunction: {
default: `linear, ${easingVars['--cl-ease-default']}`,
':where([data-ending-style])': `linear, ${easingVars['--cl-ease-exit']}`,
},
},
```

- **Another element's attribute → `stylex.when.*`.** For relational state use
`stylex.when.ancestor(sel)` / `.descendant(sel)` / `.siblingBefore(sel)` /
`.siblingAfter(sel)` / `.anySibling(sel)`, each taking a `:${string}` or
`[${string}]` selector and returning a valid conditional key
(`:where-ancestor(...)` etc.). Use this when a parent/sibling owns the state
(e.g. a `[data-open]` container theming its children); use `:where([data-*])`
when the element owns it.

So: `:where(...)` for self-state, `stylex.when.*` for relational state. Both
compile to real attribute selectors in `styles.css`, so animation stays
CSS-native — no JS state plumbing through the component.

## Public contract & composition (`props.ts`)

The element carries three things, and nothing else is a contract:
Expand Down Expand Up @@ -526,7 +609,8 @@ token colors aren't down-leveled into an invalid polyfill.
`::before`/`::after`/`::backdrop`, `@starting-style` (enter animations),
`stylex.keyframes(...)`, `anchor-size(width|height)` (popover/menu matching its
trigger), CSS counters, `@media (hover: hover)` / `(prefers-reduced-motion)` /
`(pointer: coarse)`.
`(pointer: coarse)`, `data-*` state via `:where([data-*])` (self) or
`stylex.when.*` (relational) — see "Reacting to `data-*` state" above.
- Prefer CSS-native solutions over JS workarounds for anything StyleX supports.
- Avoid manual `@layer` / `@property` inside `create` (StyleX owns layering;
`@property` compiles but emits invalid output).
Expand Down
62 changes: 60 additions & 2 deletions packages/headless/src/utils/css-vars.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,63 @@ describe('cssVars middleware', () => {
});
});

describe('--cl-anchor-origin', () => {
it("keeps both axes of the anchor's center", async () => {
const mw = cssVars({ sideOffset: 8 });
const state = createMockState({
placement: 'bottom',
referenceWidth: 100,
referenceHeight: 40,
});
await mw.fn(state);

const vars = getVars(state);
// Unlike --cl-transform-origin, the cross axis is the anchor's center (40/2), not
// the floating element's own edge.
expect(vars.get('--cl-anchor-origin')).toBe('50px 20px');
expect(vars.get('--cl-transform-origin')).toBe('50px -8px');
});

it('is the same point on every side', async () => {
const mw = cssVars({ sideOffset: 8 });

for (const placement of ['top', 'bottom', 'left', 'right', 'bottom-end']) {
const state = createMockState({ placement, referenceWidth: 100, referenceHeight: 40 });
await mw.fn(state);
expect(getVars(state).get('--cl-anchor-origin')).toBe('50px 20px');
}
});

it('is relative to the floating element', async () => {
const mw = cssVars();
const state = createMockState({ referenceWidth: 100, referenceHeight: 40 });
// Floating element positioned away from the anchor.
(state as { x: number }).x = 30;
(state as { y: number }).y = 60;
await mw.fn(state);

const vars = getVars(state);
expect(vars.get('--cl-anchor-origin')).toBe('20px -40px');
});

it('ignores the arrow', async () => {
const mw = cssVars({ sideOffset: 4 });
const state = createMockState({
placement: 'bottom',
referenceWidth: 100,
referenceHeight: 40,
arrowX: 50,
arrowElWidth: 12,
});
await mw.fn(state);

const vars = getVars(state);
// The arrow moves --cl-transform-origin but not the anchor's own center.
expect(vars.get('--cl-transform-origin')).toBe('56px -4px');
expect(vars.get('--cl-anchor-origin')).toBe('50px 20px');
});
});

describe('return value', () => {
it('returns empty object (no position changes)', async () => {
const mw = cssVars();
Expand All @@ -247,8 +304,8 @@ describe('cssVars middleware', () => {
});
});

describe('all five CSS vars are set', () => {
it('sets exactly 5 CSS custom properties', async () => {
describe('all six CSS vars are set', () => {
it('sets exactly 6 CSS custom properties', async () => {
const mw = cssVars({ sideOffset: 4 });
const state = createMockState({ placement: 'bottom' });
await mw.fn(state);
Expand All @@ -263,6 +320,7 @@ describe('cssVars middleware', () => {
'--cl-available-width',
'--cl-available-height',
'--cl-transform-origin',
'--cl-anchor-origin',
]);
});
});
Expand Down
14 changes: 11 additions & 3 deletions packages/headless/src/utils/css-vars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { detectOverflow, type Middleware } from '@floating-ui/react';
* - `--cl-available-width` – available width between anchor and viewport edge (px)
* - `--cl-available-height` – available height between anchor and viewport edge (px)
* - `--cl-transform-origin` – CSS transform-origin pointing back toward the anchor
* - `--cl-anchor-origin` – CSS transform-origin at the anchor's own center
*
* Place **after** `arrow()` so arrow position data is available for transform-origin.
*/
Expand Down Expand Up @@ -48,6 +49,10 @@ export function cssVars(opts?: { sideOffset?: number }): Middleware {
// The arrow is the only FloatingArrow <svg> descendant carrying data-side.
const arrowEl = elements.floating.querySelector('svg[data-side]');

// The anchor's center, relative to the floating element.
const anchorX = rects.reference.x + rects.reference.width / 2 - state.x;
const anchorY = rects.reference.y + rects.reference.height / 2 - state.y;

let transformX: number;
let transformY: number;

Expand All @@ -57,9 +62,8 @@ export function cssVars(opts?: { sideOffset?: number }): Middleware {
transformX = arrowX + arrowEl.clientWidth / 2;
transformY = arrowY + arrowEl.clientHeight / 2;
} else {
// No arrow — use the anchor's center relative to the floating element
transformX = rects.reference.x + rects.reference.width / 2 - state.x;
transformY = rects.reference.y + rects.reference.height / 2 - state.y;
transformX = anchorX;
transformY = anchorY;
}

const originMap: Record<string, string> = {
Expand All @@ -70,6 +74,10 @@ export function cssVars(opts?: { sideOffset?: number }): Middleware {
};

style.setProperty('--cl-transform-origin', originMap[side]);
// Keeps both axes, where `--cl-transform-origin` pins the cross axis to the floating
// element's own edge. Scaling about this point makes the popup travel out of the
// anchor instead of growing in place.
style.setProperty('--cl-anchor-origin', `${anchorX}px ${anchorY}px`);

return {};
},
Expand Down
1 change: 1 addition & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
dialog: dynamic(() => import('../stories/dialog.component.mdx')),
heading: dynamic(() => import('../stories/heading.mdx')),
icon: dynamic(() => import('../stories/icon.mdx')),
popover: dynamic(() => import('../stories/popover.component.mdx')),
tabs: dynamic(() => import('../stories/tabs.component.mdx')),
text: dynamic(() => import('../stories/text.mdx')),
},
Expand Down
14 changes: 14 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ import {
meta as organizationProfileProfileSectionMeta,
} from '../stories/organization-profile-profile-section.stories';
import { meta as otpMeta } from '../stories/otp.stories';
import {
Alignment as PopoverComponentAlignment,
Default as PopoverComponentDefault,
meta as popoverComponentMeta,
Placement as PopoverComponentPlacement,
} from '../stories/popover.component.stories';
import { meta as popoverMeta } from '../stories/popover.stories';
import { meta as selectMeta } from '../stories/select.stories';
import { Default as TabsComponentDefault, meta as tabsComponentMeta } from '../stories/tabs.component.stories';
Expand Down Expand Up @@ -155,6 +161,13 @@ const inputModule: StoryModule = { meta: inputMeta, Default, Sizes: InputSizes,

const dialogComponentModule: StoryModule = { meta: dialogComponentMeta, Default: DialogDefault };

const popoverComponentModule: StoryModule = {
meta: popoverComponentMeta,
Default: PopoverComponentDefault,
Placement: PopoverComponentPlacement,
Alignment: PopoverComponentAlignment,
};

const itemModule: StoryModule = {
meta: itemMeta,
Default: ItemDefault,
Expand Down Expand Up @@ -221,6 +234,7 @@ export const registry: StoryModule[] = [
dialogComponentModule,
headingModule,
iconModule,
popoverComponentModule,
tabsComponentModule,
textModule,
// Primitives — alphabetical within the group.
Expand Down
Loading
Loading