feat(ui): add Mosaic SubmitButton - #9342

Merged
alexcarpenter merged 2 commits into
mainfrom
carp/mosaic-submit-button
Aug 6, 2026
Merged

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

Conversation

@alexcarpenter

@alexcarpenteralexcarpenter 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

vercelBot commented Aug 5, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
clerk-js-sandboxReadyReadyPreviewAug 5, 2026 7:36pm
swingsetReadyReadyPreviewAug 5, 2026 7:36pm

Request Review

@changeset-bot

changeset-botBot 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-newBot 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-actionsBot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

API Changes Report

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

Summary

MetricCount
Packages analyzed19
Packages with changes0
🔴 Breaking changes0
🟡 Non-breaking changes0
🟢 Additions0

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 teamAugust 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

coderabbitaiBot 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 nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main change: adding a Mosaic SubmitButton.
Description check✅ PassedThe description accurately explains the SubmitButton feature, pending behavior, accessibility, styling, tests, and documentation.
Docstring Coverage✅ PassedDocstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

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

@coderabbitaicoderabbitaiBot 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. */
constspinner=()=>document.querySelector('.cl-spinner');
/** Queried by slot class, since the assertions below cover both its hidden and revealed states. */
constspinner=()=>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/bashset -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/bashset -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/bashset -eu
python3 - <<'PY'from pathlib import Pathimport resource = 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 bodyassert "role='progressbar'" in bodyassert "aria-label={pendingLabel}" in bodyassert 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

Comment on lines +66 to +70
// `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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Love this

// 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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I am curious if this delay could be a little shorter, but I think we will discover that through usage

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

yeah, I am inclined to reduce it as well! will tweak values once we start implemnting in the user button 👍🏼

@alexcarpenter
alexcarpenter merged commit 2f24826 into mainAug 6, 2026
84 of 85 checks passed
@alexcarpenter
alexcarpenter deleted the carp/mosaic-submit-button branch August 6, 2026 17:50
Sign up for freeto 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.

2 participants

@alexcarpenter@austincalvelage
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(ui): add Mosaic SubmitButton - #9342

Merged
alexcarpenter merged 2 commits into
mainfrom
carp/mosaic-submit-button
Aug 6, 2026
Merged

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

Conversation

@alexcarpenter

@alexcarpenteralexcarpenter 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

vercelBot commented Aug 5, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
clerk-js-sandboxReadyReadyPreviewAug 5, 2026 7:36pm
swingsetReadyReadyPreviewAug 5, 2026 7:36pm

Request Review

@changeset-bot

changeset-botBot 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-newBot 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-actionsBot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

API Changes Report

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

Summary

MetricCount
Packages analyzed19
Packages with changes0
🔴 Breaking changes0
🟡 Non-breaking changes0
🟢 Additions0

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 teamAugust 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

coderabbitaiBot 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 nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main change: adding a Mosaic SubmitButton.
Description check✅ PassedThe description accurately explains the SubmitButton feature, pending behavior, accessibility, styling, tests, and documentation.
Docstring Coverage✅ PassedDocstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

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

@coderabbitaicoderabbitaiBot 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. */
constspinner=()=>document.querySelector('.cl-spinner');
/** Queried by slot class, since the assertions below cover both its hidden and revealed states. */
constspinner=()=>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/bashset -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/bashset -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/bashset -eu
python3 - <<'PY'from pathlib import Pathimport resource = 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 bodyassert "role='progressbar'" in bodyassert "aria-label={pendingLabel}" in bodyassert 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

Comment on lines +66 to +70
// `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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Love this

// 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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I am curious if this delay could be a little shorter, but I think we will discover that through usage

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

yeah, I am inclined to reduce it as well! will tweak values once we start implemnting in the user button 👍🏼

@alexcarpenter
alexcarpenter merged commit 2f24826 into mainAug 6, 2026
84 of 85 checks passed
@alexcarpenter
alexcarpenter deleted the carp/mosaic-submit-button branch August 6, 2026 17:50
Sign up for freeto 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.

2 participants

@alexcarpenter@austincalvelage
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(ui): add Mosaic SubmitButton - #9342

Merged
alexcarpenter merged 2 commits into
mainfrom
carp/mosaic-submit-button
Aug 6, 2026
Merged

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

Conversation

@alexcarpenter

@alexcarpenteralexcarpenter 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

vercelBot commented Aug 5, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
clerk-js-sandboxReadyReadyPreviewAug 5, 2026 7:36pm
swingsetReadyReadyPreviewAug 5, 2026 7:36pm

Request Review

@changeset-bot

changeset-botBot 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-newBot 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-actionsBot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

API Changes Report

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

Summary

MetricCount
Packages analyzed19
Packages with changes0
🔴 Breaking changes0
🟡 Non-breaking changes0
🟢 Additions0

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 teamAugust 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

coderabbitaiBot 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 nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main change: adding a Mosaic SubmitButton.
Description check✅ PassedThe description accurately explains the SubmitButton feature, pending behavior, accessibility, styling, tests, and documentation.
Docstring Coverage✅ PassedDocstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

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

@coderabbitaicoderabbitaiBot 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. */
constspinner=()=>document.querySelector('.cl-spinner');
/** Queried by slot class, since the assertions below cover both its hidden and revealed states. */
constspinner=()=>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/bashset -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/bashset -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/bashset -eu
python3 - <<'PY'from pathlib import Pathimport resource = 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 bodyassert "role='progressbar'" in bodyassert "aria-label={pendingLabel}" in bodyassert 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

Comment on lines +66 to +70
// `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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Love this

// 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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I am curious if this delay could be a little shorter, but I think we will discover that through usage

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

yeah, I am inclined to reduce it as well! will tweak values once we start implemnting in the user button 👍🏼

@alexcarpenter
alexcarpenter merged commit 2f24826 into mainAug 6, 2026
84 of 85 checks passed
@alexcarpenter
alexcarpenter deleted the carp/mosaic-submit-button branch August 6, 2026 17:50
Sign up for freeto 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.

2 participants

@alexcarpenter@austincalvelage
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(ui): add Mosaic SubmitButton - #9342

Merged
alexcarpenter merged 2 commits into
mainfrom
carp/mosaic-submit-button
Aug 6, 2026
Merged

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

Conversation

@alexcarpenter

@alexcarpenteralexcarpenter 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

vercelBot commented Aug 5, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
clerk-js-sandboxReadyReadyPreviewAug 5, 2026 7:36pm
swingsetReadyReadyPreviewAug 5, 2026 7:36pm

Request Review

@changeset-bot

changeset-botBot 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-newBot 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-actionsBot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

API Changes Report

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

Summary

MetricCount
Packages analyzed19
Packages with changes0
🔴 Breaking changes0
🟡 Non-breaking changes0
🟢 Additions0

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 teamAugust 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

coderabbitaiBot 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 nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main change: adding a Mosaic SubmitButton.
Description check✅ PassedThe description accurately explains the SubmitButton feature, pending behavior, accessibility, styling, tests, and documentation.
Docstring Coverage✅ PassedDocstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

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

@coderabbitaicoderabbitaiBot 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. */
constspinner=()=>document.querySelector('.cl-spinner');
/** Queried by slot class, since the assertions below cover both its hidden and revealed states. */
constspinner=()=>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/bashset -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/bashset -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/bashset -eu
python3 - <<'PY'from pathlib import Pathimport resource = 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 bodyassert "role='progressbar'" in bodyassert "aria-label={pendingLabel}" in bodyassert 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

Comment on lines +66 to +70
// `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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Love this

// 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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I am curious if this delay could be a little shorter, but I think we will discover that through usage

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

yeah, I am inclined to reduce it as well! will tweak values once we start implemnting in the user button 👍🏼

@alexcarpenter
alexcarpenter merged commit 2f24826 into mainAug 6, 2026
84 of 85 checks passed
@alexcarpenter
alexcarpenter deleted the carp/mosaic-submit-button branch August 6, 2026 17:50
Sign up for freeto 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.

2 participants

@alexcarpenter@austincalvelage
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat(ui): add Mosaic SubmitButton - #9342

Merged
alexcarpenter merged 2 commits into
mainfrom
carp/mosaic-submit-button
Aug 6, 2026
Merged

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

Conversation

@alexcarpenter

@alexcarpenteralexcarpenter 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

vercelBot commented Aug 5, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
clerk-js-sandboxReadyReadyPreviewAug 5, 2026 7:36pm
swingsetReadyReadyPreviewAug 5, 2026 7:36pm

Request Review

@changeset-bot

changeset-botBot 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-newBot 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-actionsBot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

API Changes Report

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

Summary

MetricCount
Packages analyzed19
Packages with changes0
🔴 Breaking changes0
🟡 Non-breaking changes0
🟢 Additions0

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 teamAugust 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

coderabbitaiBot 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 nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main change: adding a Mosaic SubmitButton.
Description check✅ PassedThe description accurately explains the SubmitButton feature, pending behavior, accessibility, styling, tests, and documentation.
Docstring Coverage✅ PassedDocstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

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

@coderabbitaicoderabbitaiBot 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. */
constspinner=()=>document.querySelector('.cl-spinner');
/** Queried by slot class, since the assertions below cover both its hidden and revealed states. */
constspinner=()=>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/bashset -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/bashset -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/bashset -eu
python3 - <<'PY'from pathlib import Pathimport resource = 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 bodyassert "role='progressbar'" in bodyassert "aria-label={pendingLabel}" in bodyassert 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

Comment on lines +66 to +70
// `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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Love this

// 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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I am curious if this delay could be a little shorter, but I think we will discover that through usage

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

yeah, I am inclined to reduce it as well! will tweak values once we start implemnting in the user button 👍🏼

@alexcarpenter
alexcarpenter merged commit 2f24826 into mainAug 6, 2026
84 of 85 checks passed
@alexcarpenter
alexcarpenter deleted the carp/mosaic-submit-button branch August 6, 2026 17:50
Sign up for freeto 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.

2 participants

@alexcarpenter@austincalvelage
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(ui): add Mosaic SubmitButton - #9342

Merged
alexcarpenter merged 2 commits into
mainfrom
carp/mosaic-submit-button
Aug 6, 2026
Merged

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

Conversation

@alexcarpenter

@alexcarpenteralexcarpenter 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

vercelBot commented Aug 5, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
clerk-js-sandboxReadyReadyPreviewAug 5, 2026 7:36pm
swingsetReadyReadyPreviewAug 5, 2026 7:36pm

Request Review

@changeset-bot

changeset-botBot 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-newBot 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-actionsBot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

API Changes Report

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

Summary

MetricCount
Packages analyzed19
Packages with changes0
🔴 Breaking changes0
🟡 Non-breaking changes0
🟢 Additions0

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 teamAugust 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

coderabbitaiBot 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 nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main change: adding a Mosaic SubmitButton.
Description check✅ PassedThe description accurately explains the SubmitButton feature, pending behavior, accessibility, styling, tests, and documentation.
Docstring Coverage✅ PassedDocstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

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

@coderabbitaicoderabbitaiBot 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. */
constspinner=()=>document.querySelector('.cl-spinner');
/** Queried by slot class, since the assertions below cover both its hidden and revealed states. */
constspinner=()=>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/bashset -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/bashset -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/bashset -eu
python3 - <<'PY'from pathlib import Pathimport resource = 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 bodyassert "role='progressbar'" in bodyassert "aria-label={pendingLabel}" in bodyassert 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

Comment on lines +66 to +70
// `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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Love this

// 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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I am curious if this delay could be a little shorter, but I think we will discover that through usage

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

yeah, I am inclined to reduce it as well! will tweak values once we start implemnting in the user button 👍🏼

@alexcarpenter
alexcarpenter merged commit 2f24826 into mainAug 6, 2026
84 of 85 checks passed
@alexcarpenter
alexcarpenter deleted the carp/mosaic-submit-button branch August 6, 2026 17:50
Sign up for freeto 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.

2 participants

@alexcarpenter@austincalvelage
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(ui): add Mosaic SubmitButton - #9342

Merged
alexcarpenter merged 2 commits into
mainfrom
carp/mosaic-submit-button
Aug 6, 2026
Merged

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

Conversation

@alexcarpenter

@alexcarpenteralexcarpenter 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

vercelBot commented Aug 5, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
clerk-js-sandboxReadyReadyPreviewAug 5, 2026 7:36pm
swingsetReadyReadyPreviewAug 5, 2026 7:36pm

Request Review

@changeset-bot

changeset-botBot 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-newBot 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-actionsBot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

API Changes Report

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

Summary

MetricCount
Packages analyzed19
Packages with changes0
🔴 Breaking changes0
🟡 Non-breaking changes0
🟢 Additions0

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 teamAugust 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

coderabbitaiBot 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 nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main change: adding a Mosaic SubmitButton.
Description check✅ PassedThe description accurately explains the SubmitButton feature, pending behavior, accessibility, styling, tests, and documentation.
Docstring Coverage✅ PassedDocstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

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

@coderabbitaicoderabbitaiBot 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. */
constspinner=()=>document.querySelector('.cl-spinner');
/** Queried by slot class, since the assertions below cover both its hidden and revealed states. */
constspinner=()=>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/bashset -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/bashset -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/bashset -eu
python3 - <<'PY'from pathlib import Pathimport resource = 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 bodyassert "role='progressbar'" in bodyassert "aria-label={pendingLabel}" in bodyassert 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

Comment on lines +66 to +70
// `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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Love this

// 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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I am curious if this delay could be a little shorter, but I think we will discover that through usage

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

yeah, I am inclined to reduce it as well! will tweak values once we start implemnting in the user button 👍🏼

@alexcarpenter
alexcarpenter merged commit 2f24826 into mainAug 6, 2026
84 of 85 checks passed
@alexcarpenter
alexcarpenter deleted the carp/mosaic-submit-button branch August 6, 2026 17:50
Sign up for freeto 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.

2 participants

@alexcarpenter@austincalvelage
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat(ui): add Mosaic SubmitButton - #9342

Merged
alexcarpenter merged 2 commits into
mainfrom
carp/mosaic-submit-button
Aug 6, 2026
Merged

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

Conversation

@alexcarpenter

@alexcarpenteralexcarpenter 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

vercelBot commented Aug 5, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
clerk-js-sandboxReadyReadyPreviewAug 5, 2026 7:36pm
swingsetReadyReadyPreviewAug 5, 2026 7:36pm

Request Review

@changeset-bot

changeset-botBot 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-newBot 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-actionsBot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

API Changes Report

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

Summary

MetricCount
Packages analyzed19
Packages with changes0
🔴 Breaking changes0
🟡 Non-breaking changes0
🟢 Additions0

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 teamAugust 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

coderabbitaiBot 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 nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main change: adding a Mosaic SubmitButton.
Description check✅ PassedThe description accurately explains the SubmitButton feature, pending behavior, accessibility, styling, tests, and documentation.
Docstring Coverage✅ PassedDocstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

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

@coderabbitaicoderabbitaiBot 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. */
constspinner=()=>document.querySelector('.cl-spinner');
/** Queried by slot class, since the assertions below cover both its hidden and revealed states. */
constspinner=()=>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/bashset -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/bashset -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/bashset -eu
python3 - <<'PY'from pathlib import Pathimport resource = 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 bodyassert "role='progressbar'" in bodyassert "aria-label={pendingLabel}" in bodyassert 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

Comment on lines +66 to +70
// `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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Love this

// 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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I am curious if this delay could be a little shorter, but I think we will discover that through usage

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

yeah, I am inclined to reduce it as well! will tweak values once we start implemnting in the user button 👍🏼

@alexcarpenter
alexcarpenter merged commit 2f24826 into mainAug 6, 2026
84 of 85 checks passed
@alexcarpenter
alexcarpenter deleted the carp/mosaic-submit-button branch August 6, 2026 17:50
Sign up for freeto 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.

2 participants

@alexcarpenter@austincalvelage