Skip to content

feat(ui): add Mosaic SubmitButton - #9342

Open
alexcarpenter wants to merge 2 commits into
mainfrom
carp/mosaic-submit-button
Open

feat(ui): add Mosaic SubmitButton#9342
alexcarpenter wants to merge 2 commits into
mainfrom
carp/mosaic-submit-button

Conversation

@alexcarpenter

@alexcarpenter alexcarpenter commented Aug 5, 2026

Copy link
Copy Markdown
Member

Description

Preview: https://swingset-git-carp-mosaic-submit-button.clerkstage.dev/components/button

Adds SubmitButton to Mosaic: a Button that defaults type to submit and takes an isPending prop.

While pending, 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 and nothing around it reflows when the state flips. Every child sits in one box, so an icon fades with its label instead of hanging on beside the spinner. Text runs still get their own truncatable box inside that wrapper.

The pending state is immediate — aria-busy, aria-disabled, data-pending, pointer events dropped, press cancelled — but the spinner waits 300ms before it's drawn and stays up at least 200ms once it is, so an action that resolves quickly never flashes one. spinDelay moves both numbers, or removes the wait entirely with { delay: 0 }.

Accessibility:

  • The indicator enters the accessibility tree as an indeterminate progressbar the moment isPending flips, including during the delay when it's mounted but not yet drawn. That's why the delay is opacity and not conditional rendering — not rendering it removes it from the tree just as display: none would.
  • The button goes inert via aria-disabled rather than the disabled attribute, so it stays focusable and doesn't drop focus mid-action just as the progressbar is announced. The press is cancelled in JS instead. Marked with a TODO to fold into the headless button's focusableWhenDisabled from feat(headless): add a Button primitive with focusableWhenDisabled #9319 / feat(ui): wire Mosaic Button to the headless Button with focusableWhenDisabled #9320 once those land.
  • The progressbar is named in its own right via pendingLabel rather than folded into the button's name: progressbar is a range role, so name computation reads its value — absent, since it's indeterminate — over its label, and a descendant one contributes nothing to the button above it.

Button gates its hover and pressed fills on :enabled, which a pending button still is. Pointer events are dropped for the pointer half; the 9 active-fill selectors now exclude [data-pending] for the keyboard half, since a focused button takes :active from space and enter with no pointer involved.

Spinner and useSpinDelay come over from carp/account-button-switcher. One divergence to reconcile when that branch merges: spinner.styles.ts derives both colors from currentColor instead of the fixed --cl-color-card-foreground pair, which would be invisible on a filled-primary button.

Swingset's button page documents all of it, with a pressable example and a slow/fast pair showing the delay.

Checklist

  • pnpm test runs as expected.
  • pnpm build runs as expected.
  • (If applicable) JSDoc comments have been added or updated for any package exports
  • (If applicable) Documentation has been updated

Type of change

  • 🐛 Bug fix
  • 🌟 New feature
  • 🔨 Breaking change
  • 📖 Refactoring / dependency upgrade / documentation
  • other:

A `Button` that defaults `type` to `submit` and takes `isPending`. While
pending it fades its label to zero opacity and centers a spinner over it, so
the button keeps its width and nothing around it reflows.

The pending state applies immediately; only the spinner is delayed, so a fast
action never flashes one. `spinDelay` tunes both ends of that window.

The button goes inert via `aria-disabled` rather than the `disabled`
attribute, so it stays focusable and its progressbar stays announced.

Brings `Spinner` and `useSpinDelay` over from carp/account-button-switcher.
@vercel

vercel Bot commented Aug 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
clerk-js-sandbox Ready Ready Preview Aug 5, 2026 7:36pm
swingset Ready Ready Preview Aug 5, 2026 7:36pm

Request Review

@changeset-bot

changeset-bot Bot commented Aug 5, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 86554aa

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 0 packages

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Aug 5, 2026

Copy link
Copy Markdown

Open in StackBlitz

@clerk/astro

npm i https://pkg.pr.new/@clerk/astro@9342

@clerk/backend

npm i https://pkg.pr.new/@clerk/backend@9342

@clerk/chrome-extension

npm i https://pkg.pr.new/@clerk/chrome-extension@9342

@clerk/clerk-js

npm i https://pkg.pr.new/@clerk/clerk-js@9342

@clerk/electron

npm i https://pkg.pr.new/@clerk/electron@9342

@clerk/electron-passkeys

npm i https://pkg.pr.new/@clerk/electron-passkeys@9342

@clerk/eslint-plugin

npm i https://pkg.pr.new/@clerk/eslint-plugin@9342

@clerk/expo

npm i https://pkg.pr.new/@clerk/expo@9342

@clerk/expo-google-signin

npm i https://pkg.pr.new/@clerk/expo-google-signin@9342

@clerk/expo-passkeys

npm i https://pkg.pr.new/@clerk/expo-passkeys@9342

@clerk/express

npm i https://pkg.pr.new/@clerk/express@9342

@clerk/fastify

npm i https://pkg.pr.new/@clerk/fastify@9342

@clerk/hono

npm i https://pkg.pr.new/@clerk/hono@9342

@clerk/localizations

npm i https://pkg.pr.new/@clerk/localizations@9342

@clerk/nextjs

npm i https://pkg.pr.new/@clerk/nextjs@9342

@clerk/nuxt

npm i https://pkg.pr.new/@clerk/nuxt@9342

@clerk/react

npm i https://pkg.pr.new/@clerk/react@9342

@clerk/react-router

npm i https://pkg.pr.new/@clerk/react-router@9342

@clerk/shared

npm i https://pkg.pr.new/@clerk/shared@9342

@clerk/tanstack-react-start

npm i https://pkg.pr.new/@clerk/tanstack-react-start@9342

@clerk/testing

npm i https://pkg.pr.new/@clerk/testing@9342

@clerk/ui

npm i https://pkg.pr.new/@clerk/ui@9342

@clerk/upgrade

npm i https://pkg.pr.new/@clerk/upgrade@9342

@clerk/vue

npm i https://pkg.pr.new/@clerk/vue@9342

commit: 86554aa

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

API Changes Report

Generated by Break Check on 2026-08-05T19:38:02.063Z

Summary

Metric Count
Packages analyzed 19
Packages with changes 0
🔴 Breaking changes 0
🟡 Non-breaking changes 0
🟢 Additions 0

No API Changes Detected

All packages have stable APIs with no detected changes.


Report generated by Break Check

Last ran on 86554aa.

@alexcarpenter
alexcarpenter requested a review from a team August 5, 2026 19:32
The wrapper is where a consumer's children actually land, so it carries
`cl-button-content` rather than being reachable only through StyleX atoms.
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: 625c5178-1252-4852-a89b-91fc052ebca1

📥 Commits

Reviewing files that changed from the base of the PR and between b36c903 and 86554aa.

📒 Files selected for processing (3)
  • packages/swingset/src/stories/button.mdx
  • packages/ui/src/mosaic/components/button/submit-button.test.tsx
  • packages/ui/src/mosaic/components/button/submit-button.tsx
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • clerk/clerk_go (manual)
  • clerk/dashboard (manual)
  • clerk/accounts (manual)
  • clerk/backoffice (manual)
  • clerk/clerk (manual)
  • clerk/clerk-docs (manual)
  • clerk/cloudflare-workers (manual)
  • clerk/clerk-ios (auto-detected)
  • clerk/clerk-android (auto-detected)
  • clerk/cli (auto-detected)
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/swingset/src/stories/button.mdx
  • packages/ui/src/mosaic/components/button/submit-button.tsx
  • packages/ui/src/mosaic/components/button/submit-button.test.tsx

📝 Walkthrough

Walkthrough

Added SubmitButton with pending-state accessibility semantics, delayed and minimum-duration spinner behavior, focus preservation, click suppression, label truncation, and size-aware styling. Added the reusable Spinner component and useSpinDelay hook with tests. Updated button styles and public exports. Added stories and documentation for submit behavior, delays, sizes, variants, and accessibility semantics. Added an empty changeset file.

Estimated code review effort: 4 (Complex) | ~45 minutes

Suggested reviewers: austincalvelage, maxyinger

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: adding a Mosaic SubmitButton.
Description check ✅ Passed The description accurately explains the SubmitButton feature, pending behavior, accessibility, styling, tests, and documentation.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.changeset/tender-months-attack.md:
- Around line 1-2: Replace the empty Changeset front matter with a minor release
entry for the `@clerk/ui` package, preserving the standard Changesets format so
the new public SubmitButton and Spinner exports produce a package version and
changelog entry.

In `@packages/ui/src/mosaic/components/button/submit-button.test.tsx`:
- Around line 8-9: Update the comment above the spinner helper in the
submit-button tests to reflect that SubmitButton’s spinner exposes
role="progressbar" and the pending label, while the helper still queries its
slot class. Do not describe the spinner as decorative or lacking an accessible
role or name.

In `@packages/ui/src/mosaic/components/button/submit-button.tsx`:
- Around line 101-115: Move the pending Spinner announcement out of the button
into a sibling live region, while retaining aria-busy on the button. Update the
isPending/showPending rendering in SubmitButton so the pendingLabel and
progressbar role are exposed by the sibling region rather than nested under the
native button, preserving the existing pending visibility and spinner styling
behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: 26b1dbe3-6b22-4919-8b5f-496dc33a61e6

📥 Commits

Reviewing files that changed from the base of the PR and between 1ef84c3 and b36c903.

📒 Files selected for processing (16)
  • .changeset/tender-months-attack.md
  • packages/swingset/src/stories/button.mdx
  • packages/swingset/src/stories/button.stories.tsx
  • packages/ui/src/mosaic/components/button/button.styles.ts
  • packages/ui/src/mosaic/components/button/button.tsx
  • packages/ui/src/mosaic/components/button/index.ts
  • packages/ui/src/mosaic/components/button/submit-button.styles.ts
  • packages/ui/src/mosaic/components/button/submit-button.test.tsx
  • packages/ui/src/mosaic/components/button/submit-button.tsx
  • packages/ui/src/mosaic/components/spinner/index.ts
  • packages/ui/src/mosaic/components/spinner/spinner.styles.ts
  • packages/ui/src/mosaic/components/spinner/spinner.test.tsx
  • packages/ui/src/mosaic/components/spinner/spinner.tsx
  • packages/ui/src/mosaic/hooks/__tests__/useSpinDelay.test.ts
  • packages/ui/src/mosaic/hooks/useSpinDelay.ts
  • packages/ui/src/mosaic/styles/index.ts
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • clerk/clerk_go (manual)
  • clerk/dashboard (manual)
  • clerk/accounts (manual)
  • clerk/backoffice (manual)
  • clerk/clerk (manual)
  • clerk/clerk-docs (manual)
  • clerk/cloudflare-workers (manual)
  • clerk/clerk-ios (auto-detected)
  • clerk/clerk-android (auto-detected)
  • clerk/cli (auto-detected)

Comment on lines +1 to +2
---
---

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Add a @clerk/ui release entry.

This PR adds public SubmitButton and Spinner exports. The empty Changeset creates no package version or changelog entry. Add a minor @clerk/ui Changeset.

As per coding guidelines, “Use Changesets for version management and changelogs.” Based on learnings, an empty Changeset is acceptable only when no published package release is involved.

Proposed Changeset
 ---
+'`@clerk/ui`': minor
 ---
+
+Add Mosaic SubmitButton and Spinner components.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
---
---
---
'`@clerk/ui`': minor
---
Add Mosaic SubmitButton and Spinner components.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.changeset/tender-months-attack.md around lines 1 - 2, Replace the empty
Changeset front matter with a minor release entry for the `@clerk/ui` package,
preserving the standard Changesets format so the new public SubmitButton and
Spinner exports produce a package version and changelog entry.

Sources: Coding guidelines, Learnings

Comment on lines +8 to +9
/** The spinner is decorative, so it has no role or name to query — only its slot class. */
const spinner = () => document.querySelector('.cl-spinner');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the stale helper comment.

The comment states that the spinner is decorative and has no role or name. In SubmitButton the spinner sets role='progressbar' and aria-label={pendingLabel}, and the tests at lines 75 and 90 query it by role and name. The comment is copied from spinner.test.tsx, where it is accurate.

📝 Proposed comment fix
-/** The spinner is decorative, so it has no role or name to query — only its slot class. */
+/** Queried by slot class, since the assertions below cover both its hidden and revealed states. */
 const spinner = () => document.querySelector('.cl-spinner');
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/** The spinner is decorative, so it has no role or name to query — only its slot class. */
const spinner = () => document.querySelector('.cl-spinner');
/** Queried by slot class, since the assertions below cover both its hidden and revealed states. */
const spinner = () => document.querySelector('.cl-spinner');
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/mosaic/components/button/submit-button.test.tsx` around lines
8 - 9, Update the comment above the spinner helper in the submit-button tests to
reflect that SubmitButton’s spinner exposes role="progressbar" and the pending
label, while the helper still queries its slot class. Do not describe the
spinner as decorative or lacking an accessible role or name.

Source: Coding guidelines

Comment on lines +101 to +115
{isPending || showPending ? (
<Spinner
// The indicator has to be in the accessibility tree the moment the button goes
// pending, so it opts out of `Spinner`'s decorative default and carries an
// indeterminate progressbar role. It is named in its own right rather than folded
// into the button's name: `progressbar` is a range role, so the name computation
// takes its value — absent, since it is indeterminate — over its label, and a
// descendant one contributes nothing to the button above it.
role='progressbar'
aria-hidden={undefined}
aria-label={pendingLabel}
size={spinnerSizes[size]}
{...stylex.props(styles.spinner, !showPending && styles.spinnerHidden)}
/>
) : null}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

ARIA button role presentational children: are descendant roles like progressbar exposed inside a button in browsers?

💡 Result:

No, descendant roles like progressbar are not exposed inside a button to assistive technologies [1][2]. The button role is defined in WAI-ARIA as having children that are strictly presentational [1][2]. To compensate for the fact that accessibility APIs generally cannot represent semantic elements (such as a progressbar, heading, or link) inside a button, browsers automatically treat all descendant elements of a button as having role=presentation [1][2]. When a descendant element is treated as presentational, its semantic role and ARIA attributes are hidden from the accessibility tree, effectively rendering it as if it were simple text or a generic container [1][3]. While the text content of these descendants remains visible to screen reader users, their structural and interactive roles do not [1][4]. It is considered an authoring error to nest interactive elements or elements with significant semantics (like a progressbar) inside a button [5][6]. If an element inside a button is focusable (for example, if it has a tabindex), browsers may sometimes inconsistently expose the role due to conflict resolution rules, but this behavior is discouraged and can lead to severe accessibility issues, including broken keyboard navigation and confusion for assistive technology users [5][4][6]. If you need to indicate a loading state within a button, it is standard practice to use techniques like aria-busy="true" on an appropriate container, rather than nesting a semantic progressbar role inside the button [7][8][9].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- submit button ---'
cat -n packages/ui/src/mosaic/components/button/submit-button.tsx | sed -n '1,160p'

printf '%s\n' '--- related tests ---'
fd -i 'submit-button.test.tsx' . -x sh -c 'cat -n "$1" | sed -n "1,140p"' sh {}

printf '%s\n' '--- button call sites and accessibility patterns ---'
rg -n --glob '*.{ts,tsx}' "SubmitButton|aria-busy|role=['\"]progressbar['\"]|aria-live" packages/ui/src/mosaic | head -200

Repository: clerk/javascript

Length of output: 20751


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- button implementation ---'
cat -n packages/ui/src/mosaic/components/button/button.tsx | sed -n '1,220p'

printf '%s\n' '--- spinner implementation ---'
cat -n packages/ui/src/mosaic/components/spinner/spinner.tsx | sed -n '1,180p'

printf '%s\n' '--- pending-related tests ---'
cat -n packages/ui/src/mosaic/components/button/submit-button.test.tsx | sed -n '140,230p'

Repository: clerk/javascript

Length of output: 10038


🌐 Web query:

site:w3.org WAI-ARIA button role descendants presentational progressbar live region button loading pendingLabel

💡 Result:

In WAI-ARIA, the button role is defined as having presentational children, meaning that all DOM descendants of an element with role="button" are treated as presentational [1][2][3]. User agents are required to hide the semantics of these descendant elements from accessibility APIs [1][2]. Key implications of this behavior include: 1. Presentational Children and Semantics: When an element has the button role, its descendants (such as a nested div, span, or complex structure) are effectively stripped of their own semantic roles [2]. For example, a heading or a list inside a button will not be announced as such by screen readers; they are treated as plain text or part of the button's accessible name [2]. 2. Focusable Descendants: Because descendants are presentational, they must not be focusable [4]. Including interactive elements (like a link or another button) inside a button is a violation of accessibility standards because the inner elements cannot be exposed to the accessibility tree or reliably focused [4]. 3. Accessible Name Computation: The button's accessible name is typically computed from its descendant content [5][6]. If you use aria-label or aria-labelledby on the button, it overrides the default behavior and hides the descendant content from accessibility APIs [6]. 4. Live Regions and Progressbars: You cannot meaningfully nest a live region or a progressbar inside a button [2][4]. Because the button forces its children to be presentational, a progressbar (or any live region) placed as a descendant will lose its semantic role and its functional ability to communicate updates to assistive technology [2]. If a button needs to indicate a loading state (e.g., using a progressbar or pending label), the recommended pattern is to manage the state externally or update the button's label text dynamically (e.g., changing "Submit" to "Loading...") and utilizing aria-busy="true" or an external status element [5][7][8].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
from pathlib import Path
import re

source = Path("packages/ui/src/mosaic/components/button/submit-button.tsx").read_text()
button = re.search(r"<Button\b(?P<body>.*?)</Button>", source, re.S)
assert button, "SubmitButton must render a Button"
body = button.group("body")
assert "aria-busy={isPending || undefined}" in body
assert "role='progressbar'" in body
assert "aria-label={pendingLabel}" in body
assert body.index("role='progressbar'") > body.index("<Spinner")
print("SubmitButton renders a native Button with a nested, named progressbar and aria-busy.")
PY

Repository: clerk/javascript

Length of output: 239


Move the pending announcement outside the button. A native button makes its descendants presentational, so the nested progressbar and pendingLabel are not exposed reliably. Use a sibling live region and retain aria-busy on the button; the Testing Library role test does not model this browser behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/mosaic/components/button/submit-button.tsx` around lines 101
- 115, Move the pending Spinner announcement out of the button into a sibling
live region, while retaining aria-busy on the button. Update the
isPending/showPending rendering in SubmitButton so the pendingLabel and
progressbar role are exposed by the sibling region rather than nested under the
native button, preserving the existing pending visibility and spinner styling
behavior.

Source: Coding guidelines

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant