diff --git a/.changeset/tender-months-attack.md b/.changeset/tender-months-attack.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/tender-months-attack.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/swingset/src/stories/button.mdx b/packages/swingset/src/stories/button.mdx
index e0ce70678e9..ff8db065e8e 100644
--- a/packages/swingset/src/stories/button.mdx
+++ b/packages/swingset/src/stories/button.mdx
@@ -123,6 +123,100 @@ by the styles, not by `pointer-events`: the button stays hit-testable, which is
`cursor: not-allowed` render at all and what lets a wrapping tooltip explain _why_ it's disabled.
That tooltip is worth adding — a disabled control with no explanation is a dead end.
+### Submitting
+
+`SubmitButton` is a `Button` that defaults `type` to `submit` and adds `isPending`. Use it for
+the button that commits a form; reach for plain `Button` everywhere else.
+
+
+
+Press the button above to run a stand-in action: it goes pending for two seconds, then comes back.
+
+While `isPending`, the label fades to zero opacity and a spinner centers over it. The label stays
+mounted rather than being swapped out, so the button holds the width its content gives it — watch
+that it doesn't resize across the flip — and nothing around it reflows. Every child sits in one
+box, so an icon fades with its label instead of hanging on beside the spinner. That box is the slot
+`cl-button-content`, so it can be targeted directly — `.cl-button-content` for the content row of
+any submit button, `.cl-button[data-pending] .cl-button-content` for it mid-action.
+
+A pending button is inert but not `disabled`: it carries `aria-disabled`, drops its pointer events
+so hover and press stop firing, and cancels the press so the form can't be submitted twice. The
+native `disabled` attribute would do all of that too, but it takes the button out of the tab order
+mid-action — pulling focus away at the exact moment the spinner is announced. The state is also
+reflected as `data-pending` for styling.
+
+#### The spinner is delayed
+
+The button becomes pending the instant `isPending` flips, but the spinner waits 300ms before it's
+drawn, then stays up at least 200ms once it is. Plenty of actions resolve faster than a spinner
+takes to read, and one that appears and vanishes inside a few frames registers as a glitch rather
+than as progress.
+
+
+
+Nothing about the pending _state_ is delayed — only the pixels. Both buttons above go inert and
+announce themselves the moment they're pressed, which is what stops a double submit; the fast one
+simply finishes before its spinner is due.
+
+Both numbers move with `spinDelay`. An action already known to be slow has nothing to gain by
+waiting, so it can skip straight to the spinner:
+
+```tsx
+
+ Save changes
+
+```
+
+#### What assistive tech gets
+
+The spinner is decorative everywhere else in Mosaic, but here it is the only signal the action is
+running, so it enters the accessibility tree as an indeterminate `progressbar` the moment
+`isPending` flips — including during the delay above, when it's mounted but not yet drawn. That's
+why the delay is `opacity` and not conditional rendering: `visibility: hidden` or `display: none`
+would take it back out of the tree, and so would not rendering it. Fading the label with `opacity`
+is the same call — it keeps the button named "Save changes" for the whole action instead of going
+briefly nameless.
+
+The indicator is named in its own right, via `pendingLabel` (default `pending`). It is not folded
+into the button's name: `progressbar` is a range role, so name computation reads its _value_ —
+absent, since it's indeterminate — rather than its label, and a descendant one contributes nothing
+to the button above it. `pendingLabel` is untranslated, so pass a localized string wherever the
+surrounding copy is localized.
+
+```tsx
+
+ Save changes
+
+```
+
+
+
+The spinner is sized off the `Icon` scale, since it stands in for one. That scale stops at `md`,
+so `md` and `lg` buttons share the larger ring.
+
+
+
+Both the ring and its arc are mixed from `currentColor`, so the spinner reads on a `filled`
+button's fill and on a bare surface alike without a color prop to keep in step with the button's.
+
### Touch target
Every size is shorter than the 44px a fingertip needs, so under `pointer: coarse` the button grows
diff --git a/packages/swingset/src/stories/button.stories.tsx b/packages/swingset/src/stories/button.stories.tsx
index e2e6fefc3a2..e74c512b977 100644
--- a/packages/swingset/src/stories/button.stories.tsx
+++ b/packages/swingset/src/stories/button.stories.tsx
@@ -1,6 +1,6 @@
/** @jsxImportSource @emotion/react */
import type { ButtonProps } from '@clerk/ui/mosaic/components/button';
-import { Button } from '@clerk/ui/mosaic/components/button';
+import { Button, SubmitButton } from '@clerk/ui/mosaic/components/button';
import { Icon } from '@clerk/ui/mosaic/components/icon';
import React from 'react';
@@ -301,3 +301,94 @@ export function Disabled(props: Record) {
);
}
+
+// Stands in for an async submit, so the example can be pressed and the flip between the two
+// states watched — including that the button doesn't resize under the spinner.
+function usePendingOnPress(duration = 2000) {
+ const [isPending, setIsPending] = React.useState(false);
+ const timeout = React.useRef>(undefined);
+
+ React.useEffect(() => () => clearTimeout(timeout.current), []);
+
+ return {
+ isPending,
+ onClick: () => {
+ setIsPending(true);
+ timeout.current = setTimeout(() => setIsPending(false), duration);
+ },
+ };
+}
+
+export function Submit(props: Record) {
+ const { isPending, onClick } = usePendingOnPress();
+ return (
+
+ Save changes
+
+ );
+}
+
+// Press both: only the slow one ever draws a spinner. The fast one is pending the whole time it
+// says it is — it just finishes before the spinner is due, so nothing flashes.
+export function SubmitDelay(props: Record) {
+ const slow = usePendingOnPress(2000);
+ const fast = usePendingOnPress(150);
+ return (
+
+ {(['sm', 'md', 'lg'] as const).map(size => (
+
+ Save changes
+
+ ))}
+
+ );
+}
+
+// The spinner takes its arc from `currentColor`, so it reads on a fill and on a bare surface
+// alike — no color prop to keep in step with the button's.
+export function SubmitVariants(props: Record) {
+ return (
+
+ {(['filled', 'outline', 'ghost'] as const).map(variant => (
+
+ Save changes
+
+ ))}
+
+ );
+}
diff --git a/packages/ui/src/mosaic/components/button/button.styles.ts b/packages/ui/src/mosaic/components/button/button.styles.ts
index 536fa32db13..6b6b51428e8 100644
--- a/packages/ui/src/mosaic/components/button/button.styles.ts
+++ b/packages/ui/src/mosaic/components/button/button.styles.ts
@@ -57,6 +57,11 @@ const iconFadedOnNegative = `color-mix(in oklab, ${colorVars['--cl-color-negativ
// Both selectors are written out per cell rather than hoisted to a const: `@stylexjs/sort-keys`
// reads a computed key as its identifier name and fails the ordering.
//
+// `:active` also excludes `[data-pending]`, which `SubmitButton` sets while its action runs. That
+// button drops its pointer events, which is enough for the pointer, but a focused button still
+// takes `:active` from the keyboard — space and enter — and a pending button shouldn't flash a
+// pressed fill for a press it ignores.
+//
// `[data-open]` takes the pressed fill too, so a button acting as a disclosure trigger stays
// visibly engaged for as long as its surface is open. Disclosure primitives set it on the
// trigger (`popover-trigger.tsx` and friends); a plain button never carries it. It is excluded
@@ -186,7 +191,7 @@ export const variants = stylex.create({
},
backgroundColor: {
default: colorVars['--cl-color-primary'],
- ':enabled:active': primaryActive,
+ ':enabled:not([data-pending]):active': primaryActive,
':enabled[data-open]': primaryActive,
'@media (hover: hover)': {
default: null,
@@ -206,7 +211,7 @@ export const variants = stylex.create({
},
backgroundColor: {
default: neutralStep0,
- ':enabled:active': neutralStep2,
+ ':enabled:not([data-pending]):active': neutralStep2,
':enabled[data-open]': neutralStep2,
'@media (hover: hover)': {
default: null,
@@ -226,7 +231,7 @@ export const variants = stylex.create({
},
backgroundColor: {
default: colorVars['--cl-color-negative'],
- ':enabled:active': negativeActive,
+ ':enabled:not([data-pending]):active': negativeActive,
':enabled[data-open]': negativeActive,
'@media (hover: hover)': {
default: null,
@@ -251,7 +256,7 @@ export const variants = stylex.create({
borderColor: colorVars['--cl-color-border'],
backgroundColor: {
default: 'transparent',
- ':enabled:active': neutralStep1,
+ ':enabled:not([data-pending]):active': neutralStep1,
':enabled[data-open]': neutralStep1,
'@media (hover: hover)': {
default: null,
@@ -272,7 +277,7 @@ export const variants = stylex.create({
borderColor: colorVars['--cl-color-border'],
backgroundColor: {
default: 'transparent',
- ':enabled:active': neutralStep1,
+ ':enabled:not([data-pending]):active': neutralStep1,
':enabled[data-open]': neutralStep1,
'@media (hover: hover)': {
default: null,
@@ -293,7 +298,7 @@ export const variants = stylex.create({
borderColor: colorVars['--cl-color-border'],
backgroundColor: {
default: 'transparent',
- ':enabled:active': neutralStep1,
+ ':enabled:not([data-pending]):active': neutralStep1,
':enabled[data-open]': neutralStep1,
'@media (hover: hover)': {
default: null,
@@ -314,7 +319,7 @@ export const variants = stylex.create({
},
backgroundColor: {
default: 'transparent',
- ':enabled:active': neutralStep1,
+ ':enabled:not([data-pending]):active': neutralStep1,
':enabled[data-open]': neutralStep1,
'@media (hover: hover)': {
default: null,
@@ -334,7 +339,7 @@ export const variants = stylex.create({
},
backgroundColor: {
default: 'transparent',
- ':enabled:active': neutralStep1,
+ ':enabled:not([data-pending]):active': neutralStep1,
':enabled[data-open]': neutralStep1,
'@media (hover: hover)': {
default: null,
@@ -356,7 +361,7 @@ export const variants = stylex.create({
},
backgroundColor: {
default: 'transparent',
- ':enabled:active': `color-mix(in oklab, ${colorVars['--cl-color-negative-faded']}, ${colorVars['--cl-color-negative']} 8%)`,
+ ':enabled:not([data-pending]):active': `color-mix(in oklab, ${colorVars['--cl-color-negative-faded']}, ${colorVars['--cl-color-negative']} 8%)`,
':enabled[data-open]': `color-mix(in oklab, ${colorVars['--cl-color-negative-faded']}, ${colorVars['--cl-color-negative']} 8%)`,
'@media (hover: hover)': {
default: null,
diff --git a/packages/ui/src/mosaic/components/button/button.tsx b/packages/ui/src/mosaic/components/button/button.tsx
index 83b4bd8b4bb..201ee833754 100644
--- a/packages/ui/src/mosaic/components/button/button.tsx
+++ b/packages/ui/src/mosaic/components/button/button.tsx
@@ -49,7 +49,7 @@ export interface ButtonProps extends MosaicElementProps<'button'> {
// adjacent text shares one box, or `Delete {name}` would split into two flex items with the
// button's `gap` opening up mid-sentence. Element children (icons) pass through untouched,
// so they stay direct flex items and `gap` still applies.
-function withTruncatableLabel(children: React.ReactNode): React.ReactNode {
+export function withTruncatableLabel(children: React.ReactNode): React.ReactNode {
const result: React.ReactNode[] = [];
let run: React.ReactNode[] = [];
diff --git a/packages/ui/src/mosaic/components/button/index.ts b/packages/ui/src/mosaic/components/button/index.ts
index 588b977f11c..7279cfde856 100644
--- a/packages/ui/src/mosaic/components/button/index.ts
+++ b/packages/ui/src/mosaic/components/button/index.ts
@@ -1,2 +1,7 @@
export { Button } from './button';
export type { ButtonProps } from './button';
+export { SubmitButton } from './submit-button';
+export type { SubmitButtonProps } from './submit-button';
+// Named here rather than only inside `SubmitButtonProps`, so a consumer can type the object they
+// pass to `spinDelay`.
+export type { SpinDelayOptions } from '../../hooks/useSpinDelay';
diff --git a/packages/ui/src/mosaic/components/button/submit-button.styles.ts b/packages/ui/src/mosaic/components/button/submit-button.styles.ts
new file mode 100644
index 00000000000..39d84077083
--- /dev/null
+++ b/packages/ui/src/mosaic/components/button/submit-button.styles.ts
@@ -0,0 +1,53 @@
+import * as stylex from '@stylexjs/stylex';
+
+export const styles = stylex.create({
+ // The containing block the spinner centers against. Unconditional, so the button's stacking
+ // and its coarse-pointer `::after` overlay behave the same in both states.
+ root: {
+ position: 'relative',
+ },
+ // Button gates its hover and pressed fills on `:enabled`, which a pending button still is —
+ // `aria-disabled` keeps it focusable, so the native attribute is out. Dropping pointer events
+ // stops `:hover` and `:active` matching for the pointer in one line, across every variant cell.
+ // Unlike `disabled` there's nothing lost by it: pending is self-explanatory and brief, so the
+ // button isn't carrying a tooltip that has to stay hoverable to explain itself.
+ //
+ // It doesn't cover the keyboard, though — a focused button still takes `:active` from space and
+ // enter with no pointer involved. That half is handled where the fills are declared, by the
+ // `:not([data-pending])` on each cell's active selector in `button.styles.ts`.
+ rootPending: {
+ pointerEvents: 'none',
+ },
+
+ // One box around every child, so the whole content fades as a unit rather than per-run. It
+ // stands in for the button's own content row — `gap` picks up whatever the size axis set —
+ // so an icon and its label keep their spacing across the extra nesting level.
+ content: {
+ gap: 'inherit',
+ alignItems: 'center',
+ display: 'inline-flex',
+ // Releases the flex-item min-width floor so the label boxes inside can still clip.
+ minWidth: 0,
+ },
+ // Opacity rather than unmounting the label or swapping in the spinner: the content keeps its
+ // box, so the button holds its width and nothing around it reflows when the state flips.
+ contentPending: {
+ opacity: 0,
+ },
+
+ // Out of flow and centered by `inset: 0` + `margin: auto`, which resolves against the padding
+ // box on both axes without a transform and stays correct under any writing mode.
+ spinner: {
+ margin: 'auto',
+ insetBlock: 0,
+ insetInline: 0,
+ position: 'absolute',
+ },
+ // The spinner mounts the instant the action starts but waits out `useSpinDelay` before it is
+ // drawn, so a fast action never flashes one. Hiding it with `opacity` rather than by not
+ // rendering it is what keeps the progressbar in the accessibility tree for the whole action —
+ // `visibility: hidden` or `display: none` would take it back out.
+ spinnerHidden: {
+ opacity: 0,
+ },
+});
diff --git a/packages/ui/src/mosaic/components/button/submit-button.test.tsx b/packages/ui/src/mosaic/components/button/submit-button.test.tsx
new file mode 100644
index 00000000000..b8398f07523
--- /dev/null
+++ b/packages/ui/src/mosaic/components/button/submit-button.test.tsx
@@ -0,0 +1,366 @@
+import { act, render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import React from 'react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { SubmitButton } from './submit-button';
+
+/** The spinner is decorative, so it has no role or name to query — only its slot class. */
+const spinner = () => document.querySelector('.cl-spinner');
+const content = () => screen.getByRole('button').firstElementChild;
+
+/**
+ * StyleX classes, as a set. Which atoms carry which declaration is an implementation detail, so
+ * the visual assertions below compare one state's atoms against another's rather than naming any.
+ */
+const atoms = (el: Element | null | undefined) => el?.className.split(' ').filter(Boolean) ?? [];
+const isSupersetOf = (all: string[], some: string[]) => some.every(atom => all.includes(atom));
+
+describe('Mosaic SubmitButton', () => {
+ it('renders a submit button with its children', () => {
+ render(Save);
+ const button = screen.getByRole('button', { name: 'Save' });
+ expect(button).toHaveClass('cl-button');
+ expect(button).toHaveAttribute('type', 'submit');
+ });
+
+ it('lets the consumer override the type', () => {
+ render(Save);
+ expect(screen.getByRole('button')).toHaveAttribute('type', 'button');
+ });
+
+ // The wrapper is not an implementation detail a consumer can ignore — it is the box their
+ // children actually land in — so it carries a slot class they can target.
+ it('names the content box with a slot class', () => {
+ render(Save);
+ expect(content()).toHaveClass('cl-button-content');
+ });
+
+ it('boxes every child in one content span', () => {
+ render(
+
+
+ Save
+ ,
+ );
+ const button = screen.getByRole('button');
+ expect(button.children).toHaveLength(1);
+ expect(content()?.tagName).toBe('SPAN');
+ expect(content()).toContainElement(screen.getByTestId('icon'));
+ expect(button).toHaveAccessibleName('Save');
+ });
+
+ it('still gives a text run its own box to truncate against', () => {
+ render(Save);
+ const label = content()?.firstElementChild;
+ expect(label?.tagName).toBe('SPAN');
+ expect(label).toHaveTextContent('Save');
+ });
+
+ it('renders no spinner and announces nothing while idle', () => {
+ render(Save);
+ const button = screen.getByRole('button');
+ expect(spinner()).not.toBeInTheDocument();
+ expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
+ expect(button).not.toHaveAttribute('aria-busy');
+ expect(button).not.toHaveAttribute('aria-disabled');
+ expect(button).not.toHaveAttribute('data-pending');
+ });
+
+ it('renders the spinner and reflects the pending state', () => {
+ render(Save);
+ const button = screen.getByRole('button');
+ expect(spinner()).toBeInTheDocument();
+ expect(button).toHaveAttribute('aria-busy', 'true');
+ expect(button).toHaveAttribute('data-pending', '');
+ });
+
+ // The indicator is only decorative when nothing depends on it being announced; here it is the
+ // sole signal that the action is running, so it has to reach the accessibility tree.
+ it('puts the indicator in the accessibility tree as an indeterminate progressbar', () => {
+ render(Save);
+ const bar = screen.getByRole('progressbar', { name: 'pending' });
+ expect(bar).toBe(spinner());
+ expect(bar).not.toHaveAttribute('aria-hidden');
+ expect(bar).not.toHaveAttribute('aria-valuenow');
+ });
+
+ it('lets the consumer name the indicator', () => {
+ render(
+
+ Save
+ ,
+ );
+ expect(screen.getByRole('progressbar', { name: 'Saving' })).toBeInTheDocument();
+ });
+
+ // Fading the label rather than unmounting it keeps the button named for the whole action. The
+ // indicator is named separately: a `progressbar` descendant is a range role, so the name
+ // computation reads its value rather than its label and it contributes nothing here.
+ it('keeps the button named by its own label while pending', () => {
+ render(Save);
+ expect(screen.getByRole('button')).toHaveAccessibleName('Save');
+ });
+
+ // The whole point of fading the label rather than swapping it out: the button keeps the
+ // width its content gives it, so nothing around it reflows when the state flips.
+ it('keeps the label mounted while pending so the button holds its width', () => {
+ render(Save);
+ expect(content()).toHaveTextContent('Save');
+ });
+
+ // `aria-disabled` rather than the `disabled` attribute: the button stays focusable, so focus
+ // isn't dropped mid-action just as the progressbar is announced.
+ it('marks itself disabled to assistive tech while pending but stays focusable', async () => {
+ render(Save);
+ const button = screen.getByRole('button');
+ expect(button).toHaveAttribute('aria-disabled', 'true');
+ expect(button).toBeEnabled();
+
+ await userEvent.tab();
+ expect(button).toHaveFocus();
+ });
+
+ it('does not submit its form while pending', async () => {
+ const onSubmit = vi.fn(event => event.preventDefault());
+ const onClick = vi.fn();
+ render(
+ ,
+ );
+
+ await userEvent.click(screen.getByRole('button'));
+ expect(onClick).not.toHaveBeenCalled();
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
+ it('submits its form once it is no longer pending', async () => {
+ const onSubmit = vi.fn(event => event.preventDefault());
+ render(
+ ,
+ );
+
+ await userEvent.click(screen.getByRole('button'));
+ expect(onSubmit).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders the spinner after the content so it can center over the whole button', () => {
+ render(Save);
+ const button = screen.getByRole('button');
+ expect(button.children).toHaveLength(2);
+ expect(button.lastElementChild).toBe(spinner());
+ });
+
+ it.each([
+ ['sm', 'sm'],
+ ['md', 'md'],
+ ['lg', 'md'],
+ ] as const)('sizes the spinner to %s with the %s step', (size, expected) => {
+ render(
+
+ Save
+ ,
+ );
+ expect(spinner()).toHaveAttribute('data-size', expected);
+ });
+
+ it('wires the button variant props and consumer className/style through', () => {
+ render(
+
+ Save
+ ,
+ );
+ const button = screen.getByRole('button');
+ expect(button).toHaveAttribute('data-color', 'negative');
+ expect(button).toHaveAttribute('data-variant', 'outline');
+ expect(button).toHaveAttribute('data-size', 'sm');
+ expect(button).toHaveAttribute('data-full-width', '');
+ expect(button).toHaveClass('cl-button', 'my-button');
+ expect(button).toHaveStyle({ marginTop: '8px' });
+ });
+
+ it('keeps the isPending prop off the element', () => {
+ render(Save);
+ expect(screen.getByRole('button')).not.toHaveAttribute('ispending');
+ });
+
+ it('calls onClick when pressed', async () => {
+ const onClick = vi.fn();
+ render(Save);
+ await userEvent.click(screen.getByRole('button'));
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ it('forwards arbitrary button props and the ref', () => {
+ const ref = React.createRef();
+ render(
+
+ Save
+ ,
+ );
+ const button = screen.getByRole('button');
+ expect(ref.current).toBe(button);
+ expect(button).toHaveAttribute('form', 'profile');
+ });
+});
+
+// The pending *state* is immediate — it has to be, or a fast action could be submitted twice and
+// assistive tech would miss it. Only the pixels are delayed, so an action that resolves in a
+// couple of frames never flashes a spinner at all.
+describe('Mosaic SubmitButton spin delay', () => {
+ beforeEach(() => vi.useFakeTimers());
+ afterEach(() => vi.useRealTimers());
+
+ const advance = (ms: number) => act(() => void vi.advanceTimersByTime(ms));
+
+ it('marks itself pending immediately, before the spinner is due to show', () => {
+ render(Save);
+ const button = screen.getByRole('button');
+ expect(button).toHaveAttribute('aria-busy', 'true');
+ expect(button).toHaveAttribute('aria-disabled', 'true');
+ expect(button).toHaveAttribute('data-pending', '');
+ });
+
+ // `opacity`, not conditional rendering: the indicator has to be in the accessibility tree the
+ // moment the action starts, whether or not it is on screen yet.
+ it('mounts the indicator immediately and reveals it once the delay elapses', () => {
+ render(Save);
+ const hidden = atoms(spinner());
+ expect(screen.getByRole('progressbar', { name: 'pending' })).toBeInTheDocument();
+
+ advance(300);
+ const shown = atoms(spinner());
+ expect(hidden.length).toBeGreaterThan(shown.length);
+ expect(isSupersetOf(hidden, shown)).toBe(true);
+ });
+
+ it('keeps the label at full opacity until the spinner shows', () => {
+ const { rerender } = render(Save);
+ const idle = atoms(content());
+
+ rerender(Save);
+ expect(atoms(content())).toEqual(idle);
+
+ advance(300);
+ const faded = atoms(content());
+ expect(faded.length).toBeGreaterThan(idle.length);
+ expect(isSupersetOf(faded, idle)).toBe(true);
+ });
+
+ it('never shows a spinner for an action that finishes inside the delay window', () => {
+ const { rerender } = render(Save);
+ const idle = atoms(content());
+
+ rerender(Save);
+ advance(200);
+ rerender(Save);
+ advance(1000);
+
+ expect(spinner()).not.toBeInTheDocument();
+ expect(atoms(content())).toEqual(idle);
+ });
+
+ it('lets the consumer lengthen the delay', () => {
+ render(
+
+ Save
+ ,
+ );
+ const hidden = atoms(spinner());
+
+ advance(300);
+ expect(atoms(spinner())).toEqual(hidden);
+
+ advance(700);
+ expect(atoms(spinner()).length).toBeLessThan(hidden.length);
+ });
+
+ // A consumer who already knows the action is slow has nothing to gain by waiting.
+ it('lets the consumer opt out of the delay', () => {
+ render(
+
+ Save
+ ,
+ );
+ const hidden = atoms(spinner());
+
+ advance(0);
+ expect(atoms(spinner()).length).toBeLessThan(hidden.length);
+ });
+
+ it('keeps the default minimum duration when only the delay is overridden', () => {
+ const { rerender } = render(
+
+ Save
+ ,
+ );
+ advance(0);
+
+ rerender(Save);
+ expect(spinner()).toBeInTheDocument();
+
+ advance(200);
+ expect(spinner()).not.toBeInTheDocument();
+ });
+
+ it('lets the consumer drop the minimum duration', () => {
+ const { rerender } = render(
+
+ Save
+ ,
+ );
+ advance(0);
+
+ rerender(Save);
+ expect(spinner()).not.toBeInTheDocument();
+ });
+
+ // Otherwise an action that resolves just after the spinner appears would flash it off again.
+ it('holds the spinner on screen briefly after the action finishes', () => {
+ const { rerender } = render(Save);
+ advance(300);
+ const shown = atoms(spinner());
+
+ rerender(Save);
+ expect(atoms(spinner())).toEqual(shown);
+
+ advance(200);
+ expect(spinner()).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/ui/src/mosaic/components/button/submit-button.tsx b/packages/ui/src/mosaic/components/button/submit-button.tsx
new file mode 100644
index 00000000000..711af575e38
--- /dev/null
+++ b/packages/ui/src/mosaic/components/button/submit-button.tsx
@@ -0,0 +1,123 @@
+import * as stylex from '@stylexjs/stylex';
+import React from 'react';
+
+import type { SpinDelayOptions } from '../../hooks/useSpinDelay';
+import { useSpinDelay } from '../../hooks/useSpinDelay';
+import { mergeStyleProps, themeProps } from '../../props';
+import { reset } from '../reset.styles';
+import { Spinner } from '../spinner';
+import type { ButtonProps } from './button';
+import { Button, withTruncatableLabel } from './button';
+import { styles } from './submit-button.styles';
+
+export interface SubmitButtonProps extends ButtonProps {
+ /**
+ * Marks the action as running: the button goes inert, announces itself busy, and — once the
+ * action outlasts a short delay — fades its label and centers a spinner over it. The label
+ * stays mounted at zero opacity so the button keeps its width and the form around it does not
+ * reflow while the action runs.
+ */
+ isPending?: boolean;
+ /**
+ * Accessible name for the pending indicator, announced alongside the button's own label. The
+ * default is untranslated, so pass a localized string wherever the surrounding copy is.
+ */
+ pendingLabel?: string;
+ /**
+ * Tunes when the spinner appears and how long it stays. `delay` (default `300`) is how long the
+ * action has to run before the spinner is drawn at all; `minDuration` (default `200`) is how
+ * long it stays once drawn. Neither affects the pending state itself, which always applies
+ * immediately. Set `delay: 0` for an action already known to be slow.
+ */
+ spinDelay?: SpinDelayOptions;
+}
+
+// The spinner scale stops at `md`, and a `lg` button's label is only one step up, so both take
+// the larger ring rather than `lg` asking for one the spinner cannot render.
+const spinnerSizes = { sm: 'sm', md: 'md', lg: 'md' } as const;
+
+// Long enough that a request served from cache or a local mutation never draws a spinner, short
+// enough that a press which is going to take a while doesn't sit there looking ignored. Set here
+// rather than on `useSpinDelay` itself: a button is pressed and watched, so it wants a tighter
+// window than a hook shared with background loads. `minDuration` has no such tension, so it takes
+// the hook's default.
+const DEFAULT_SPIN_DELAY = 300;
+
+/**
+ * A `Button` that submits its form, with a pending affordance. Takes every `Button` prop;
+ * `type` defaults to `submit` and can still be overridden.
+ *
+ * While `isPending`, the button is inert but stays focusable and announced — see the
+ * `isPending` prop for what that means for assistive tech.
+ *
+ * @example
+ * Save
+ *
+ * @example
+ * // Pending state on a destructive action
+ * Delete
+ */
+export const SubmitButton = React.forwardRef(function MosaicSubmitButton(
+ { isPending = false, pendingLabel = 'pending', size = 'md', spinDelay, className, children, onClick, ...rest },
+ ref,
+) {
+ const { delay = DEFAULT_SPIN_DELAY, minDuration } = spinDelay ?? {};
+
+ // `isPending` drives the semantics, this drives the pixels. The split is deliberate: the button
+ // has to go inert and start announcing the moment the action does, or a fast action gets
+ // submitted twice and assistive tech misses it — but drawing a spinner that fast only produces
+ // a flash, so the visual waits out the delay and then sticks around long enough to be read.
+ const showPending = useSpinDelay(isPending || null, { delay, minDuration }) !== null;
+
+ const handleClick = (event: React.MouseEvent) => {
+ if (isPending) {
+ // `aria-disabled` is advisory — it doesn't stop the native submit — so the press is
+ // cancelled here instead. The `disabled` attribute would do both, but it drops the button
+ // out of the tab order mid-action, taking focus with it just as the spinner is announced.
+ //
+ // TODO: fold this into the headless button's `focusableWhenDisabled` once it lands
+ // (clerk/javascript#9319, #9320), which owns the same behavior one layer down.
+ event.preventDefault();
+ return;
+ }
+ onClick?.(event);
+ };
+
+ return (
+
+ );
+});
diff --git a/packages/ui/src/mosaic/components/spinner/index.ts b/packages/ui/src/mosaic/components/spinner/index.ts
new file mode 100644
index 00000000000..602e3746b37
--- /dev/null
+++ b/packages/ui/src/mosaic/components/spinner/index.ts
@@ -0,0 +1,2 @@
+export { Spinner } from './spinner';
+export type { SpinnerProps } from './spinner';
diff --git a/packages/ui/src/mosaic/components/spinner/spinner.styles.ts b/packages/ui/src/mosaic/components/spinner/spinner.styles.ts
new file mode 100644
index 00000000000..c6c82ab9bb1
--- /dev/null
+++ b/packages/ui/src/mosaic/components/spinner/spinner.styles.ts
@@ -0,0 +1,39 @@
+import * as stylex from '@stylexjs/stylex';
+
+import { radiusVars, space } from '../../tokens.stylex';
+
+const spin = stylex.keyframes({
+ from: { transform: 'rotate(0deg)' },
+ to: { transform: 'rotate(360deg)' },
+});
+
+export const styles = stylex.create({
+ base: {
+ // Both colors come off `currentColor` rather than a fixed pair, so the spinner reads on
+ // whatever it sits on — a card, or the fill of a `filled` button, where a card-foreground
+ // arc would disappear.
+ borderColor: `color-mix(in oklab, currentColor 20%, transparent)`,
+ borderRadius: radiusVars['--cl-radius-full'],
+ borderStyle: 'solid',
+ // The ring is one uniform border with a single arc picked out in the foreground color;
+ // rotating the whole element is what animates the arc around it.
+ animationDuration: '600ms',
+ animationIterationCount: 'infinite',
+ animationName: {
+ default: spin,
+ '@media (prefers-reduced-motion: reduce)': 'none',
+ },
+ animationTimingFunction: 'linear',
+ borderBlockStartColor: 'currentColor',
+ boxSizing: 'border-box',
+ display: 'inline-block',
+ flexShrink: 0,
+ },
+});
+
+// Sized off the `Icon` scale, since a spinner stands in for an icon wherever it appears. The
+// border thins with it so the ring keeps its proportion rather than swallowing the small one.
+export const sizes = stylex.create({
+ sm: { borderWidth: '1.5px', height: space['3.5'], width: space['3.5'] },
+ md: { borderWidth: '2px', height: space['4'], width: space['4'] },
+});
diff --git a/packages/ui/src/mosaic/components/spinner/spinner.test.tsx b/packages/ui/src/mosaic/components/spinner/spinner.test.tsx
new file mode 100644
index 00000000000..24127752350
--- /dev/null
+++ b/packages/ui/src/mosaic/components/spinner/spinner.test.tsx
@@ -0,0 +1,52 @@
+import { render } from '@testing-library/react';
+import React from 'react';
+import { describe, expect, it } from 'vitest';
+
+import { Spinner } from './spinner';
+
+/** The spinner is decorative, so it has no role or name to query — only its slot class. */
+const spinner = (container: HTMLElement) => container.querySelector('.cl-spinner');
+
+describe('Mosaic Spinner', () => {
+ it('renders a decorative element carrying the slot class', () => {
+ const { container } = render();
+ const el = spinner(container);
+ expect(el).toBeInTheDocument();
+ expect(el).toHaveAttribute('aria-hidden', 'true');
+ });
+
+ it('applies the default size when none is passed', () => {
+ const { container } = render();
+ expect(spinner(container)).toHaveAttribute('data-size', 'md');
+ });
+
+ it.each(['sm', 'md'] as const)('reflects the %s size', size => {
+ const { container } = render();
+ expect(spinner(container)).toHaveAttribute('data-size', size);
+ });
+
+ it('lets the consumer className and style win', () => {
+ const { container } = render(
+ ,
+ );
+ const el = spinner(container);
+ expect(el).toHaveClass('cl-spinner', 'my-spinner');
+ expect(el).toHaveStyle({ marginTop: '8px' });
+ });
+
+ it('forwards arbitrary span props and the ref', () => {
+ const ref = React.createRef();
+ const { container } = render(
+ ,
+ );
+ const el = spinner(container);
+ expect(ref.current).toBe(el);
+ expect(el).toHaveAttribute('id', 'pending');
+ });
+});
diff --git a/packages/ui/src/mosaic/components/spinner/spinner.tsx b/packages/ui/src/mosaic/components/spinner/spinner.tsx
new file mode 100644
index 00000000000..ce034983030
--- /dev/null
+++ b/packages/ui/src/mosaic/components/spinner/spinner.tsx
@@ -0,0 +1,36 @@
+import * as stylex from '@stylexjs/stylex';
+import React from 'react';
+
+import type { MosaicElementProps } from '../../props';
+import { mergeStyleProps, themeProps } from '../../props';
+import { sizes, styles } from './spinner.styles';
+
+export type SpinnerProps = MosaicElementProps<'span'> & {
+ size?: 'sm' | 'md';
+};
+
+/**
+ * Indeterminate loading spinner. Decorative (`aria-hidden`) — pair it with a disabled control or an
+ * `aria-busy` container so assistive tech is informed of the pending state.
+ *
+ * @example
+ * // Default (md), sized to match a `md` Icon
+ *
+ *
+ * @example
+ * // Standing in for a `sm` Icon
+ *
+ */
+export const Spinner = React.forwardRef(function MosaicSpinner(
+ { size = 'md', className, style, ...rest },
+ ref,
+) {
+ return (
+
+ );
+});
diff --git a/packages/ui/src/mosaic/hooks/__tests__/useSpinDelay.test.ts b/packages/ui/src/mosaic/hooks/__tests__/useSpinDelay.test.ts
new file mode 100644
index 00000000000..66052c26835
--- /dev/null
+++ b/packages/ui/src/mosaic/hooks/__tests__/useSpinDelay.test.ts
@@ -0,0 +1,88 @@
+import { act, renderHook } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { useSpinDelay } from '../useSpinDelay';
+
+describe('useSpinDelay', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ const render = (value: string | null, options?: { delay?: number; minDuration?: number }) =>
+ renderHook(({ value }) => useSpinDelay(value, options), { initialProps: { value } });
+
+ const advance = (ms: number) => act(() => vi.advanceTimersByTime(ms));
+
+ it('returns null while idle', () => {
+ const { result } = render(null);
+ expect(result.current).toBeNull();
+ });
+
+ it('stays null during the delay window', async () => {
+ const { result, rerender } = render(null, { delay: 500, minDuration: 200 });
+ await act(() => rerender({ value: 'a' }));
+ expect(result.current).toBeNull();
+
+ await advance(499);
+ expect(result.current).toBeNull();
+ });
+
+ it('surfaces the value once it outlasts the delay', async () => {
+ const { result, rerender } = render(null, { delay: 500, minDuration: 200 });
+ await act(() => rerender({ value: 'a' }));
+
+ await advance(500);
+ expect(result.current).toBe('a');
+ });
+
+ it('never surfaces a value that clears faster than the delay', async () => {
+ const { result, rerender } = render(null, { delay: 500, minDuration: 200 });
+ await act(() => rerender({ value: 'a' }));
+
+ await advance(300);
+ await act(() => rerender({ value: null }));
+
+ await advance(1000);
+ expect(result.current).toBeNull();
+ });
+
+ it('holds the value for at least minDuration once shown', async () => {
+ const { result, rerender } = render(null, { delay: 500, minDuration: 200 });
+ await act(() => rerender({ value: 'a' }));
+ await advance(500);
+ expect(result.current).toBe('a');
+
+ // value clears almost immediately after it appeared
+ await act(() => rerender({ value: null }));
+ await advance(199);
+ expect(result.current).toBe('a');
+
+ await advance(1);
+ expect(result.current).toBeNull();
+ });
+
+ it('keeps showing while the value persists beyond minDuration, then clears', async () => {
+ const { result, rerender } = render(null, { delay: 500, minDuration: 200 });
+ await act(() => rerender({ value: 'a' }));
+ await advance(500);
+ await advance(5000);
+ expect(result.current).toBe('a');
+
+ await act(() => rerender({ value: null }));
+ expect(result.current).toBeNull();
+ });
+
+ it('swaps to a new value immediately when one replaces another mid-show', async () => {
+ const { result, rerender } = render(null, { delay: 500, minDuration: 200 });
+ await act(() => rerender({ value: 'a' }));
+ await advance(500);
+ expect(result.current).toBe('a');
+
+ await act(() => rerender({ value: 'b' }));
+ expect(result.current).toBe('b');
+ });
+});
diff --git a/packages/ui/src/mosaic/hooks/useSpinDelay.ts b/packages/ui/src/mosaic/hooks/useSpinDelay.ts
new file mode 100644
index 00000000000..b847c0bc517
--- /dev/null
+++ b/packages/ui/src/mosaic/hooks/useSpinDelay.ts
@@ -0,0 +1,58 @@
+import { useEffect, useRef, useState } from 'react';
+
+export interface SpinDelayOptions {
+ /** Wait this long before showing the value, so quick actions never flash a spinner. */
+ delay?: number;
+ /** Once shown, keep the value up at least this long, so the spinner never flickers off. */
+ minDuration?: number;
+}
+
+const DEFAULT_DELAY = 500;
+const DEFAULT_MIN_DURATION = 200;
+
+/**
+ * Spin-delays a nullable value: returns `null` until `value` has stayed non-null longer than `delay`
+ * (so quick actions never flash a spinner), then holds the last non-null value for at least
+ * `minDuration` after it clears (so the spinner never flickers off). A value-carrying rework of
+ * https://github.com/smeijer/spin-delay: each timer lives in a `const` and is cancelled by effect
+ * cleanup when `value` flips before it fires.
+ */
+export function useSpinDelay(value: T | null, options: SpinDelayOptions = {}): T | null {
+ const delay = options.delay ?? DEFAULT_DELAY;
+ const minDuration = options.minDuration ?? DEFAULT_MIN_DURATION;
+
+ const [shown, setShown] = useState(null);
+ const shownAt = useRef(0);
+
+ useEffect(() => {
+ // Nothing showing yet: arm a timer so the value only surfaces if it outlasts `delay`.
+ if (shown === null) {
+ if (value === null) {
+ return;
+ }
+ const timer = setTimeout(() => {
+ shownAt.current = Date.now();
+ setShown(value);
+ }, delay);
+ return () => clearTimeout(timer);
+ }
+
+ // Showing, and the value cleared: hold it for the rest of `minDuration`.
+ if (value === null) {
+ const remaining = minDuration - (Date.now() - shownAt.current);
+ if (remaining <= 0) {
+ setShown(null);
+ return;
+ }
+ const timer = setTimeout(() => setShown(null), remaining);
+ return () => clearTimeout(timer);
+ }
+
+ // Showing, and the value swapped to another non-null: surface it right away.
+ if (value !== shown) {
+ setShown(value);
+ }
+ }, [value, shown, delay, minDuration]);
+
+ return shown;
+}
diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts
index 826944639b8..3f65e3c03cb 100644
--- a/packages/ui/src/mosaic/styles/index.ts
+++ b/packages/ui/src/mosaic/styles/index.ts
@@ -10,8 +10,8 @@ export { Avatar } from '../components/avatar';
export type { AvatarProps, AvatarImageProps, AvatarFallbackProps } from '../components/avatar';
export { Badge } from '../components/badge';
export type { BadgeProps } from '../components/badge';
-export { Button } from '../components/button';
-export type { ButtonProps } from '../components/button';
+export { Button, SubmitButton } from '../components/button';
+export type { ButtonProps, SpinDelayOptions, SubmitButtonProps } from '../components/button';
export { Card } from '../components/card';
export type { CardProps } from '../components/card';
export { Heading, HeadingContext } from '../components/heading';
@@ -30,6 +30,8 @@ export type {
} from '../components/menu';
export { scrollAreaRoot, scrollAreaVars, scrollAreaViewport } from '../components/scroll-area';
export type { ScrollAreaGutter } from '../components/scroll-area';
+export { Spinner } from '../components/spinner';
+export type { SpinnerProps } from '../components/spinner';
export { Text, TextContext } from '../components/text';
export type { TextProps } from '../components/text';