From 99c922f03b11313f39727805e433abe70356a031 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 2 Nov 2026 10:15:26 -0500 Subject: [PATCH 1/2] fix(mosaic): tighten component motion --- .changeset/calm-mosaic-motion.md | 5 + .../__tests__/input-motion.browser.test.tsx | 114 ++++++++++++++++++ .../components/checkbox/checkbox.styles.ts | 7 +- .../src/components/drawer/drawer.styles.ts | 6 +- .../mosaic/src/components/flow/flow.styles.ts | 1 - .../mosaic/src/components/otp/otp.styles.ts | 2 + .../src/components/radio/radio.styles.ts | 6 +- .../src/components/table/table.styles.ts | 7 +- .../components/tag-input/tag-input.styles.ts | 30 ++++- .../src/components/toast/toast.styles.ts | 10 +- .../src/features/switcher/switcher.styles.ts | 6 +- packages/mosaic/src/styles/input.styles.ts | 20 ++- 12 files changed, 189 insertions(+), 25 deletions(-) create mode 100644 .changeset/calm-mosaic-motion.md create mode 100644 packages/mosaic/src/components/__tests__/input-motion.browser.test.tsx diff --git a/.changeset/calm-mosaic-motion.md b/.changeset/calm-mosaic-motion.md new file mode 100644 index 00000000000..3b5a662c249 --- /dev/null +++ b/.changeset/calm-mosaic-motion.md @@ -0,0 +1,5 @@ +--- +'@clerk/mosaic': patch +--- + +Tighten component motion. Input hover and focus now appear instantly and fade on the way out, toasts leave faster than they arrive, hover and press feedback on `Checkbox`, `Radio`, the `Drawer` handle, the switcher trigger and the tag remove button responds instantly, and the `Table` expand icon respects `prefers-reduced-motion`. diff --git a/packages/mosaic/src/components/__tests__/input-motion.browser.test.tsx b/packages/mosaic/src/components/__tests__/input-motion.browser.test.tsx new file mode 100644 index 00000000000..57a71c4f34f --- /dev/null +++ b/packages/mosaic/src/components/__tests__/input-motion.browser.test.tsx @@ -0,0 +1,114 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { userEvent } from 'vitest/browser'; + +import { Input } from '../input'; +import { InputGroup } from '../input-group'; +import { Otp } from '../otp'; + +afterEach(() => cleanup()); + +describe('input surface motion', () => { + it('shows hover at once and fades it on the way out', async () => { + render( + <> + + + , + ); + const input = screen.getByRole('textbox'); + + await userEvent.hover(input); + await nextFrame(); + expect(runningProperties(input)).toEqual([]); + + await settle(); + await userEvent.hover(screen.getByRole('button')); + await nextFrame(); + expect(runningProperties(input)).toContain('border-top-color'); + }); + + it('shows focus at once and fades the ring and border on the way out', async () => { + render( + <> + + + , + ); + const input = screen.getByRole('textbox'); + + await userEvent.tab(); + await nextFrame(); + expect(input.matches(':focus-visible')).toBe(true); + expect(runningProperties(input)).toEqual([]); + + await settle(); + await userEvent.tab(); + await nextFrame(); + expect(runningProperties(input)).toEqual(expect.arrayContaining(['border-top-color', 'box-shadow'])); + }); + + it('fades a validation colour that lands on a focused field', async () => { + const { rerender } = render(); + const input = screen.getByRole('textbox'); + + await userEvent.tab(); + await settle(); + rerender( + , + ); + await nextFrame(); + + expect(runningProperties(input)).toEqual(expect.arrayContaining(['border-top-color', 'box-shadow'])); + }); + + it('shows focus at once on an input group', async () => { + render( + + + , + ); + + await userEvent.tab(); + await nextFrame(); + + expect(runningProperties(screen.getByTestId('group'))).toEqual([]); + }); + + it('fades the success colour on the focused otp slot', async () => { + const { rerender } = render(); + + await userEvent.tab(); + await settle(); + const slot = document.activeElement; + if (!(slot instanceof HTMLInputElement)) { + throw new Error('Expected an otp slot to hold focus'); + } + rerender( + , + ); + await nextFrame(); + + expect(runningProperties(slot)).toContain('border-top-color'); + }); +}); + +function runningProperties(element: Element) { + return element + .getAnimations() + .map(animation => (animation instanceof CSSTransition ? animation.transitionProperty : animation.constructor.name)); +} + +function nextFrame() { + return new Promise(resolve => requestAnimationFrame(() => resolve())); +} + +function settle() { + return new Promise(resolve => setTimeout(resolve, 300)); +} diff --git a/packages/mosaic/src/components/checkbox/checkbox.styles.ts b/packages/mosaic/src/components/checkbox/checkbox.styles.ts index 6cff123de9c..6487135c039 100644 --- a/packages/mosaic/src/components/checkbox/checkbox.styles.ts +++ b/packages/mosaic/src/components/checkbox/checkbox.styles.ts @@ -35,7 +35,12 @@ export const styles = stylex.create({ flexShrink: 0, opacity: { default: null, ':disabled': 0.5 }, position: 'relative', - transitionDuration: durationVars['--cl-duration-fast'], + transitionDuration: { + default: durationVars['--cl-duration-base'], + ':checked': durationVars['--cl-duration-instant'], + ':indeterminate': durationVars['--cl-duration-instant'], + ':hover': durationVars['--cl-duration-instant'], + }, transitionProperty: 'background-color, border-color', transitionTimingFunction: 'linear', }, diff --git a/packages/mosaic/src/components/drawer/drawer.styles.ts b/packages/mosaic/src/components/drawer/drawer.styles.ts index f6f40cf5a8f..0483de18d84 100644 --- a/packages/mosaic/src/components/drawer/drawer.styles.ts +++ b/packages/mosaic/src/components/drawer/drawer.styles.ts @@ -143,6 +143,10 @@ export const styles = stylex.create({ default: colorVars['--cl-color-border'], ':active': `color-mix(in oklab, ${colorVars['--cl-color-border']}, ${colorVars['--cl-color-foreground-secondary']} 35%)`, }, + '--_cl-grip-duration': { + default: durationVars['--cl-duration-base'], + ':active': durationVars['--cl-duration-instant'], + }, placeItems: 'center', display: 'grid', flexShrink: 0, @@ -155,7 +159,7 @@ export const styles = stylex.create({ backgroundColor: 'var(--_cl-grip-color)', blockSize: space['1.5'], inlineSize: space['11.5'], - transitionDuration: durationVars['--cl-duration-fast'], + transitionDuration: 'var(--_cl-grip-duration)', transitionProperty: 'background-color', transitionTimingFunction: 'linear', }, diff --git a/packages/mosaic/src/components/flow/flow.styles.ts b/packages/mosaic/src/components/flow/flow.styles.ts index ebb4a50e570..39eaff98e33 100644 --- a/packages/mosaic/src/components/flow/flow.styles.ts +++ b/packages/mosaic/src/components/flow/flow.styles.ts @@ -66,7 +66,6 @@ export const styles = stylex.create({ default: easingVars['--cl-ease-enter'], ':where([data-ending-style])': easingVars['--cl-ease-in-out'], }, - willChange: 'transform', minWidth: 0, width: '100%', }, diff --git a/packages/mosaic/src/components/otp/otp.styles.ts b/packages/mosaic/src/components/otp/otp.styles.ts index 42887c5a8e3..09cb3d93ef8 100644 --- a/packages/mosaic/src/components/otp/otp.styles.ts +++ b/packages/mosaic/src/components/otp/otp.styles.ts @@ -2,6 +2,7 @@ import * as stylex from '@stylexjs/stylex'; import { colorVars, + durationVars, fontFamilyVars, fontWeightVars, radiusVars, @@ -46,6 +47,7 @@ export const styles = stylex.create({ default: null, ':focus-visible': positiveFocusShadow, }, + transitionDuration: durationVars['--cl-duration-base'], }, touchTarget: { minHeight: { default: null, '@media (pointer: coarse)': targetVars['--cl-target-coarse'] }, diff --git a/packages/mosaic/src/components/radio/radio.styles.ts b/packages/mosaic/src/components/radio/radio.styles.ts index 83017f3f927..be75ad779cb 100644 --- a/packages/mosaic/src/components/radio/radio.styles.ts +++ b/packages/mosaic/src/components/radio/radio.styles.ts @@ -38,7 +38,11 @@ export const styles = stylex.create({ flexShrink: 0, opacity: { default: null, ':disabled': 0.5 }, position: 'relative', - transitionDuration: durationVars['--cl-duration-fast'], + transitionDuration: { + default: durationVars['--cl-duration-base'], + ':checked': durationVars['--cl-duration-instant'], + ':hover': durationVars['--cl-duration-instant'], + }, transitionProperty: 'background-color, border-color', transitionTimingFunction: 'linear', }, diff --git a/packages/mosaic/src/components/table/table.styles.ts b/packages/mosaic/src/components/table/table.styles.ts index 6d2036d8d67..01ade1fcb8d 100644 --- a/packages/mosaic/src/components/table/table.styles.ts +++ b/packages/mosaic/src/components/table/table.styles.ts @@ -3,6 +3,7 @@ import * as stylex from '@stylexjs/stylex'; import { colorVars, durationVars, + easingVars, focusVars, fontWeightVars, radiusVars, @@ -156,7 +157,11 @@ export const styles = stylex.create({ [stylex.when.ancestor('[data-expanded]', tableStateMarker)]: 'rotate(90deg)', }, transitionDuration: durationVars['--cl-duration-fast'], - transitionProperty: 'transform', + transitionProperty: { + default: 'transform', + '@media (prefers-reduced-motion: reduce)': 'none', + }, + transitionTimingFunction: easingVars['--cl-ease-default'], }, }); diff --git a/packages/mosaic/src/components/tag-input/tag-input.styles.ts b/packages/mosaic/src/components/tag-input/tag-input.styles.ts index 203b3394dd2..466579c0525 100644 --- a/packages/mosaic/src/components/tag-input/tag-input.styles.ts +++ b/packages/mosaic/src/components/tag-input/tag-input.styles.ts @@ -1,11 +1,19 @@ import * as stylex from '@stylexjs/stylex'; -import { colorVars, durationVars, fontFamilyVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex'; +import { + colorVars, + durationVars, + easingVars, + fontFamilyVars, + radiusVars, + space, + typeScaleVars, +} from '../../tokens.stylex'; export const styles = stylex.create({ root: { - '--cl-layout-duration': '200ms', - '--cl-layout-easing': 'ease-out', + '--cl-layout-duration': durationVars['--cl-duration-base'], + '--cl-layout-easing': easingVars['--cl-ease-enter'], padding: space['2'], borderRadius: radiusVars['--cl-radius-md'], gap: space['2'], @@ -49,12 +57,18 @@ export const styles = stylex.create({ ':where([data-starting-style], [data-ending-style])': 'scale(1)', }, }, - transitionDuration: durationVars['--cl-duration-base'], + transitionDuration: { + default: `${durationVars['--cl-duration-fast']}, ${durationVars['--cl-duration-base']}`, + ':where([data-ending-style])': durationVars['--cl-duration-fast'], + }, transitionProperty: { default: 'opacity, transform', '@media (prefers-reduced-motion: reduce)': 'opacity', }, - transitionTimingFunction: { default: 'ease-out', ':where([data-ending-style])': 'ease-in' }, + transitionTimingFunction: { + default: `${easingVars['--cl-ease-enter']}, ${easingVars['--cl-ease-default']}`, + ':where([data-ending-style])': easingVars['--cl-ease-exit'], + }, height: space['6'], maxWidth: '100%', minWidth: 0, @@ -88,7 +102,11 @@ export const styles = stylex.create({ display: 'inline-flex', flexShrink: 0, justifyContent: 'center', - transitionDuration: durationVars['--cl-duration-base'], + transitionDuration: { + default: durationVars['--cl-duration-base'], + ':enabled:active': durationVars['--cl-duration-instant'], + ':enabled:hover': durationVars['--cl-duration-instant'], + }, transitionProperty: 'background-color', transitionTimingFunction: 'linear', height: space['4'], diff --git a/packages/mosaic/src/components/toast/toast.styles.ts b/packages/mosaic/src/components/toast/toast.styles.ts index 2bf2149f655..bc2e80dfd42 100644 --- a/packages/mosaic/src/components/toast/toast.styles.ts +++ b/packages/mosaic/src/components/toast/toast.styles.ts @@ -65,12 +65,18 @@ export const styles = stylex.create({ default: `translateY(calc(var(--toast-index) * -1 * ${stackGap})) scale(calc(1 - var(--toast-index) * 0.05))`, ':where([data-expanded])': `translateY(calc(var(--toast-offset-y) * -1 - var(--toast-index) * ${stackGap}))`, }, - transitionDuration: durationVars['--cl-duration-slow'], + transitionDuration: { + default: durationVars['--cl-duration-slow'], + ':where([data-ending-style])': durationVars['--cl-duration-base'], + }, transitionProperty: { default: 'opacity, transform, translate, height', '@media (prefers-reduced-motion: reduce)': 'opacity', }, - transitionTimingFunction: easingVars['--cl-ease-enter'], + transitionTimingFunction: { + default: easingVars['--cl-ease-enter'], + ':where([data-ending-style])': easingVars['--cl-ease-exit'], + }, translate: { default: '0 0', ':where([data-starting-style], [data-ending-style])': '0 100%', diff --git a/packages/mosaic/src/features/switcher/switcher.styles.ts b/packages/mosaic/src/features/switcher/switcher.styles.ts index 2b104bae86e..3669f1d1f93 100644 --- a/packages/mosaic/src/features/switcher/switcher.styles.ts +++ b/packages/mosaic/src/features/switcher/switcher.styles.ts @@ -17,7 +17,11 @@ export const styles = stylex.create({ backgroundColor: 'transparent', cursor: 'pointer', display: 'inline-flex', - transitionDuration: durationVars['--cl-duration-base'], + transitionDuration: { + default: durationVars['--cl-duration-base'], + ':hover': durationVars['--cl-duration-instant'], + }, + transitionTimingFunction: 'linear', maxWidth: '100%', minWidth: 0, }, diff --git a/packages/mosaic/src/styles/input.styles.ts b/packages/mosaic/src/styles/input.styles.ts index 3a499676c4d..134c89d9dc3 100644 --- a/packages/mosaic/src/styles/input.styles.ts +++ b/packages/mosaic/src/styles/input.styles.ts @@ -1,6 +1,6 @@ import * as stylex from '@stylexjs/stylex'; -import { colorVars, durationVars, easingVars } from '../tokens.stylex'; +import { colorVars, durationVars } from '../tokens.stylex'; // Focus is marked with a border colour change and a soft box-shadow rather than the // system focus ring, so a text-entry field reads as a field rather than a control. @@ -44,13 +44,12 @@ export const inputStyles = stylex.create({ }, transitionDuration: { default: durationVars['--cl-duration-base'], - ':focus-visible': durationVars['--cl-duration-fast'], + ':focus-visible': durationVars['--cl-duration-instant'], + ':focus-visible:where([aria-invalid="true"])': durationVars['--cl-duration-base'], + ':hover:not([aria-invalid="true"])': durationVars['--cl-duration-instant'], }, transitionProperty: 'color, background-color, border-color, box-shadow', - transitionTimingFunction: { - default: 'linear', - ':focus-visible': `linear, linear, linear, ${easingVars['--cl-ease-default']}`, - }, + transitionTimingFunction: 'linear', }, group: { borderColor: { @@ -86,13 +85,12 @@ export const inputStyles = stylex.create({ }, transitionDuration: { default: durationVars['--cl-duration-base'], - ':has(input:focus-visible, textarea:focus-visible)': durationVars['--cl-duration-fast'], + ':hover:not([data-invalid])': durationVars['--cl-duration-instant'], + ':has(input:focus-visible, textarea:focus-visible)': durationVars['--cl-duration-instant'], + ':has(input:focus-visible, textarea:focus-visible):where([data-invalid])': durationVars['--cl-duration-base'], }, transitionProperty: 'color, background-color, border-color, box-shadow', - transitionTimingFunction: { - default: 'linear', - ':has(input:focus-visible, textarea:focus-visible)': `linear, linear, linear, ${easingVars['--cl-ease-default']}`, - }, + transitionTimingFunction: 'linear', }, disabled: { backgroundColor: disabledBackgroundColor, From 298e2e4d78bf2041db63175fde8c7795e7b0fd22 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 2 Nov 2026 11:06:46 -0500 Subject: [PATCH 2/2] test(mosaic): remove input motion browser test --- .../__tests__/input-motion.browser.test.tsx | 114 ------------------ 1 file changed, 114 deletions(-) delete mode 100644 packages/mosaic/src/components/__tests__/input-motion.browser.test.tsx diff --git a/packages/mosaic/src/components/__tests__/input-motion.browser.test.tsx b/packages/mosaic/src/components/__tests__/input-motion.browser.test.tsx deleted file mode 100644 index 57a71c4f34f..00000000000 --- a/packages/mosaic/src/components/__tests__/input-motion.browser.test.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { cleanup, render, screen } from '@testing-library/react'; -import { afterEach, describe, expect, it } from 'vitest'; -import { userEvent } from 'vitest/browser'; - -import { Input } from '../input'; -import { InputGroup } from '../input-group'; -import { Otp } from '../otp'; - -afterEach(() => cleanup()); - -describe('input surface motion', () => { - it('shows hover at once and fades it on the way out', async () => { - render( - <> - - - , - ); - const input = screen.getByRole('textbox'); - - await userEvent.hover(input); - await nextFrame(); - expect(runningProperties(input)).toEqual([]); - - await settle(); - await userEvent.hover(screen.getByRole('button')); - await nextFrame(); - expect(runningProperties(input)).toContain('border-top-color'); - }); - - it('shows focus at once and fades the ring and border on the way out', async () => { - render( - <> - - - , - ); - const input = screen.getByRole('textbox'); - - await userEvent.tab(); - await nextFrame(); - expect(input.matches(':focus-visible')).toBe(true); - expect(runningProperties(input)).toEqual([]); - - await settle(); - await userEvent.tab(); - await nextFrame(); - expect(runningProperties(input)).toEqual(expect.arrayContaining(['border-top-color', 'box-shadow'])); - }); - - it('fades a validation colour that lands on a focused field', async () => { - const { rerender } = render(); - const input = screen.getByRole('textbox'); - - await userEvent.tab(); - await settle(); - rerender( - , - ); - await nextFrame(); - - expect(runningProperties(input)).toEqual(expect.arrayContaining(['border-top-color', 'box-shadow'])); - }); - - it('shows focus at once on an input group', async () => { - render( - - - , - ); - - await userEvent.tab(); - await nextFrame(); - - expect(runningProperties(screen.getByTestId('group'))).toEqual([]); - }); - - it('fades the success colour on the focused otp slot', async () => { - const { rerender } = render(); - - await userEvent.tab(); - await settle(); - const slot = document.activeElement; - if (!(slot instanceof HTMLInputElement)) { - throw new Error('Expected an otp slot to hold focus'); - } - rerender( - , - ); - await nextFrame(); - - expect(runningProperties(slot)).toContain('border-top-color'); - }); -}); - -function runningProperties(element: Element) { - return element - .getAnimations() - .map(animation => (animation instanceof CSSTransition ? animation.transitionProperty : animation.constructor.name)); -} - -function nextFrame() { - return new Promise(resolve => requestAnimationFrame(() => resolve())); -} - -function settle() { - return new Promise(resolve => setTimeout(resolve, 300)); -}