diff --git a/.changeset/mosaic-captcha.md b/.changeset/mosaic-captcha.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/mosaic-captcha.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/mosaic/src/components/captcha/__tests__/captcha.browser.test.tsx b/packages/mosaic/src/components/captcha/__tests__/captcha.browser.test.tsx
new file mode 100644
index 00000000000..cae73cad5ee
--- /dev/null
+++ b/packages/mosaic/src/components/captcha/__tests__/captcha.browser.test.tsx
@@ -0,0 +1,43 @@
+import { render } from '@testing-library/react';
+import React from 'react';
+import { describe, expect, it } from 'vitest';
+
+import { Captcha } from '../captcha';
+
+describe('Captcha color scheme', () => {
+ it('follows the color scheme it renders in when no theme is set', () => {
+ const { container } = render(
+
+
+
,
+ );
+
+ expect(container.querySelector('#clerk-captcha')).toHaveAttribute('data-cl-theme', 'dark');
+ });
+
+ it('lets an explicit theme win over the color scheme', () => {
+ const { container } = render(
+
+
+
,
+ );
+
+ expect(container.querySelector('#clerk-captcha')).toHaveAttribute('data-cl-theme', 'light');
+ });
+
+ it('uses the light theme when no color scheme is set', () => {
+ const { container } = render();
+
+ expect(container.querySelector('#clerk-captcha')).toHaveAttribute('data-cl-theme', 'light');
+ });
+
+ it('leaves the theme to Turnstile when the color scheme follows the visitor', () => {
+ const { container } = render(
+
+
+
,
+ );
+
+ expect(container.querySelector('#clerk-captcha')).toHaveAttribute('data-cl-theme', 'auto');
+ });
+});
diff --git a/packages/mosaic/src/components/captcha/__tests__/captcha.test.tsx b/packages/mosaic/src/components/captcha/__tests__/captcha.test.tsx
new file mode 100644
index 00000000000..9e298b89703
--- /dev/null
+++ b/packages/mosaic/src/components/captcha/__tests__/captcha.test.tsx
@@ -0,0 +1,34 @@
+import { render } from '@testing-library/react';
+import React from 'react';
+import { describe, expect, it } from 'vitest';
+
+import { Captcha } from '../captcha';
+
+describe('Captcha', () => {
+ it('renders the element clerk-js mounts Turnstile into, with the widget options', () => {
+ const { container } = render(
+ ,
+ );
+
+ const element = container.querySelector('#clerk-captcha');
+ expect(element).toHaveAttribute('data-cl-theme', 'dark');
+ expect(element).toHaveAttribute('data-cl-size', 'compact');
+ expect(element).toHaveAttribute('data-cl-language', 'fr');
+ expect(element).toBeEmptyDOMElement();
+ });
+
+ it('keeps the interactive state clerk-js sets across re-renders', () => {
+ const { container, rerender } = render();
+ const element = container.querySelector('#clerk-captcha');
+ element?.setAttribute('data-cl-interactive', 'true');
+
+ rerender();
+
+ expect(element).toHaveAttribute('data-cl-interactive', 'true');
+ expect(element).toHaveAttribute('data-cl-theme', 'dark');
+ });
+});
diff --git a/packages/mosaic/src/components/captcha/captcha.styles.ts b/packages/mosaic/src/components/captcha/captcha.styles.ts
new file mode 100644
index 00000000000..aee67d75d32
--- /dev/null
+++ b/packages/mosaic/src/components/captcha/captcha.styles.ts
@@ -0,0 +1,10 @@
+import * as stylex from '@stylexjs/stylex';
+
+export const styles = stylex.create({
+ root: {
+ position: {
+ default: 'absolute',
+ ':where([data-cl-interactive])': 'static',
+ },
+ },
+});
diff --git a/packages/mosaic/src/components/captcha/captcha.tsx b/packages/mosaic/src/components/captcha/captcha.tsx
new file mode 100644
index 00000000000..36c389fcd44
--- /dev/null
+++ b/packages/mosaic/src/components/captcha/captcha.tsx
@@ -0,0 +1,66 @@
+import { CAPTCHA_ELEMENT_ID } from '@clerk/shared/internal/clerk-js/constants';
+import { useSafeLayoutEffect } from '@clerk/shared/react';
+import * as stylex from '@stylexjs/stylex';
+import React from 'react';
+
+import { useRender } from '../../primitives/utils';
+import { getComputedStyle } from '../../primitives/utils/dom';
+import type { MosaicComponentProps } from '../../props';
+import { mergeStyleProps, themeProps } from '../../props';
+import { reset } from '../../styles/reset.styles';
+import { styles } from './captcha.styles';
+
+export interface CaptchaProps extends Omit, 'id' | 'children'> {
+ /** The Turnstile widget's theme. When unset, it matches the `color-scheme` the element renders in: `dark`, `light` (also when none is set), or the visitor's color scheme when both are allowed. */
+ theme?: 'auto' | 'light' | 'dark';
+ /** The Turnstile widget's size. Turnstile uses `normal` when it is unset. */
+ size?: 'normal' | 'flexible' | 'compact';
+ /** The Turnstile widget's language, an ISO 639-1 code such as `en` or `en-us`. Turnstile follows the visitor's language when it is unset. */
+ language?: string;
+}
+
+/**
+ * The element clerk-js renders Cloudflare Turnstile into when a sign-in or sign-up requires a
+ * CAPTCHA. It stays empty and collapsed unless Turnstile asks the visitor to solve an interactive
+ * challenge, when clerk-js expands it and sets `data-cl-interactive`. Until then it is out of the
+ * layout, so it adds no gap to a flex parent. Render one per page, mounted before the sign-in or
+ * sign-up is created.
+ *
+ * @example
+ *
+ */
+export const Captcha = React.forwardRef(function MosaicCaptcha(
+ { render, xstyle, theme, size, language, ...rest },
+ ref,
+) {
+ const [element, setElement] = React.useState(null);
+ const [schemeTheme, setSchemeTheme] = React.useState();
+
+ useSafeLayoutEffect(() => {
+ if (theme || !element) {
+ return;
+ }
+ setSchemeTheme(themeFromColorScheme(getComputedStyle(element).colorScheme));
+ });
+
+ return useRender({
+ defaultTagName: 'div',
+ render,
+ ref: [ref, setElement],
+ props: {
+ ...mergeStyleProps(themeProps('captcha'), stylex.props(reset.base, styles.root, xstyle), rest),
+ id: CAPTCHA_ELEMENT_ID,
+ 'data-cl-theme': theme ?? schemeTheme,
+ 'data-cl-size': size,
+ 'data-cl-language': language,
+ },
+ });
+});
+
+function themeFromColorScheme(colorScheme: string): CaptchaProps['theme'] {
+ const schemes = colorScheme.split(/\s+/);
+ if (!schemes.includes('dark')) {
+ return 'light';
+ }
+ return schemes.includes('light') ? 'auto' : 'dark';
+}
diff --git a/packages/mosaic/src/components/captcha/index.ts b/packages/mosaic/src/components/captcha/index.ts
new file mode 100644
index 00000000000..7e889064602
--- /dev/null
+++ b/packages/mosaic/src/components/captcha/index.ts
@@ -0,0 +1,2 @@
+export { Captcha } from './captcha';
+export type { CaptchaProps } from './captcha';
diff --git a/packages/mosaic/src/styles/index.ts b/packages/mosaic/src/styles/index.ts
index a0c8d639597..398f9b9c67a 100644
--- a/packages/mosaic/src/styles/index.ts
+++ b/packages/mosaic/src/styles/index.ts
@@ -13,6 +13,8 @@ export type { BrandingProps } from '../components/branding';
export type { BannerDescriptionProps, BannerLabelProps, BannerRootProps } from '../components/banner';
export { Button, SubmitButton } from '../components/button';
export type { ButtonProps, SpinDelayOptions, SubmitButtonProps } from '../components/button';
+export { Captcha } from '../components/captcha';
+export type { CaptchaProps } from '../components/captcha';
export { Card } from '../components/card';
export { Checkbox } from '../components/checkbox';
export type { CheckboxProps } from '../components/checkbox';
diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx
index 76da1ecc6e7..e9b30c87af9 100644
--- a/packages/swingset/src/components/DocsViewer.tsx
+++ b/packages/swingset/src/components/DocsViewer.tsx
@@ -124,6 +124,7 @@ const docModules: Record> = {
tooltip: dynamic(() => import('../stories/tooltip.component.mdx')),
field: dynamic(() => import('../stories/field.component.mdx')),
flow: dynamic(() => import('../stories/flow.component.mdx')),
+ captcha: dynamic(() => import('../stories/captcha.mdx')),
'visually-hidden': dynamic(() => import('../stories/visually-hidden.mdx')),
},
primitives: {
diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts
index 0453bfd0a27..771a31ca3e9 100644
--- a/packages/swingset/src/lib/registry.ts
+++ b/packages/swingset/src/lib/registry.ts
@@ -36,6 +36,7 @@ import {
meta as bannerMeta,
} from '../stories/banner.stories';
import { Disabled, meta as buttonMeta, Primary, Sizes } from '../stories/button.stories';
+import { Default as CaptchaDefault, meta as captchaMeta } from '../stories/captcha.stories';
import { Default as CardDefault, meta as cardComponentMeta } from '../stories/card.component.stories';
import {
Default as CheckboxDefault,
@@ -996,6 +997,11 @@ const fieldModule: StoryModule = {
VisuallyHiddenLabel: FieldVisuallyHiddenLabel,
};
+const captchaModule: StoryModule = {
+ meta: captchaMeta,
+ Default: CaptchaDefault,
+};
+
const visuallyHiddenModule: StoryModule = {
meta: visuallyHiddenMeta,
Default: VisuallyHiddenDefault,
@@ -1442,6 +1448,7 @@ export const registry: StoryModule[] = [
toastModule,
tooltipComponentModule,
fieldModule,
+ captchaModule,
visuallyHiddenModule,
// Primitives
accordionModule,
diff --git a/packages/swingset/src/lib/test-turnstile.ts b/packages/swingset/src/lib/test-turnstile.ts
new file mode 100644
index 00000000000..fdace8593b7
--- /dev/null
+++ b/packages/swingset/src/lib/test-turnstile.ts
@@ -0,0 +1,74 @@
+import { useEffect, useState } from 'react';
+
+const TURNSTILE_SCRIPT = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
+const FORCE_INTERACTIVE_SITEKEY = '3x00000000000000000000FF';
+
+interface Turnstile {
+ render: (container: HTMLElement, options: Record) => string;
+ remove: (id: string) => void;
+}
+
+function loadTurnstile(): Promise {
+ const existing = (window as { turnstile?: Turnstile }).turnstile;
+ if (existing) {
+ return Promise.resolve(existing);
+ }
+ return new Promise((resolve, reject) => {
+ const script = document.createElement('script');
+ script.src = TURNSTILE_SCRIPT;
+ script.async = true;
+ script.onload = () => resolve((window as { turnstile?: Turnstile }).turnstile as Turnstile);
+ script.onerror = reject;
+ document.head.appendChild(script);
+ });
+}
+
+export function useTestTurnstile(getElement: () => HTMLElement | null | undefined): string {
+ const [status, setStatus] = useState('Loading Turnstile');
+
+ useEffect(() => {
+ let id: string | undefined;
+ let turnstile: Turnstile | undefined;
+ let cancelled = false;
+ const element = getElement();
+ if (!element) {
+ return undefined;
+ }
+ element.style.maxHeight = '0';
+ void loadTurnstile()
+ .then(loaded => {
+ if (cancelled) {
+ return;
+ }
+ turnstile = loaded;
+ id = loaded.render(element, {
+ sitekey: FORCE_INTERACTIVE_SITEKEY,
+ appearance: 'interaction-only',
+ theme: element.dataset.clTheme ?? 'auto',
+ size: element.dataset.clSize ?? 'normal',
+ retry: 'never',
+ 'before-interactive-callback': () => {
+ element.style.maxHeight = 'unset';
+ element.style.minHeight = element.dataset.clSize === 'compact' ? '140px' : '68px';
+ element.style.marginBottom = '1.5rem';
+ element.dataset.clInteractive = 'true';
+ setStatus('Interactive challenge shown');
+ },
+ callback: () => {
+ delete element.dataset.clInteractive;
+ setStatus('Solved');
+ },
+ });
+ setStatus('Waiting for Turnstile');
+ })
+ .catch(() => setStatus('Turnstile could not load'));
+ return () => {
+ cancelled = true;
+ if (turnstile && id) {
+ turnstile.remove(id);
+ }
+ };
+ }, []);
+
+ return status;
+}
diff --git a/packages/swingset/src/stories/captcha.mdx b/packages/swingset/src/stories/captcha.mdx
new file mode 100644
index 00000000000..c82bf87e739
--- /dev/null
+++ b/packages/swingset/src/stories/captcha.mdx
@@ -0,0 +1,38 @@
+import * as CaptchaStories from './captcha.stories';
+
+# Captcha
+
+The element clerk-js renders Cloudflare Turnstile into when a sign-in or sign-up requires a CAPTCHA. It renders an empty `div` with the id `clerk-captcha`. clerk-js keeps it collapsed, and expands it and sets `data-cl-interactive` only when Turnstile asks the visitor to solve an interactive challenge.
+
+Render one per page, inside the step that creates the sign-in or sign-up, so it is mounted when clerk-js looks for it. Without it, clerk-js falls back to an invisible widget and has nowhere to show an interactive challenge.
+
+## Playground
+
+The playground stands in for clerk-js: it loads Turnstile with Cloudflare's test sitekey that always asks for an interactive challenge, and renders it into the element the way clerk-js does. It needs network access to `challenges.cloudflare.com`. In an app, the element stays empty unless Turnstile asks for a challenge. Without a `theme`, the widget matches the `color-scheme` it renders in, so it follows Swingset's dark mode toggle.
+
+
+
+## Props
+
+ ReactNode' }]}
+/>
+
+## Usage
+
+
+
+## Styling
+
+| Part | Stable slot class | Description |
+| --------- | ----------------- | ------------------------------------------------------------------------------------------------------------ |
+| `Captcha` | `.cl-captcha` | `div#clerk-captcha`. `data-cl-interactive="true"` is present while Turnstile shows an interactive challenge. |
diff --git a/packages/swingset/src/stories/captcha.stories.tsx b/packages/swingset/src/stories/captcha.stories.tsx
new file mode 100644
index 00000000000..3c088dabe0f
--- /dev/null
+++ b/packages/swingset/src/stories/captcha.stories.tsx
@@ -0,0 +1,52 @@
+import type { CaptchaProps } from '@clerk/mosaic/components/captcha';
+import { Captcha } from '@clerk/mosaic/components/captcha';
+import { useTheme } from 'next-themes';
+import { useRef } from 'react';
+
+import { useTestTurnstile } from '@/lib/test-turnstile';
+import type { StoryMeta } from '@/lib/types';
+
+export { default as __source } from './captcha.stories?raw';
+
+export const meta: StoryMeta = {
+ group: 'Components',
+ status: 'stable',
+ title: 'Captcha',
+ source: 'packages/mosaic/src/components/captcha/captcha.tsx',
+ styles: {
+ _variants: {
+ size: { normal: {}, flexible: {}, compact: {} },
+ },
+ _defaultVariants: { size: 'normal' },
+ },
+};
+
+function knobsAsProps(props: Record) {
+ return props as unknown as CaptchaProps;
+}
+
+function TurnstileCaptcha({ size }: Pick) {
+ const ref = useRef(null);
+ const status = useTestTurnstile(() => ref.current);
+
+ return (
+
+
+ {status}
+
+ );
+}
+
+export function Default(props: Record) {
+ const { size } = knobsAsProps(props);
+ const { resolvedTheme } = useTheme();
+ return (
+
+ );
+}