Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/captcha-interactive-spotlight.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': minor
---

Spotlight interactive bot-protection (Cloudflare Turnstile) challenges during sign-in and sign-up. When a challenge escalates to an interactive "Verify you are human" check, the start card now brings it to the foreground — collapsing and `inert`-ing the rest of the form until the challenge is solved — while keeping the header, footer, and passkey action reachable. Invisible challenges are unaffected.
37 changes: 25 additions & 12 deletions packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -155,6 +155,9 @@ function SignInStartInternal(): JSX.Element {
const hasSocialOrWeb3Buttons =
!!authenticatableSocialStrategies.length || !!web3FirstFactors.length || !!alternativePhoneCodeChannels.length;
const [shouldAutofocus, setShouldAutofocus] = useState(!isMobileDevice() && !hasSocialOrWeb3Buttons);
// When the captcha escalates to an interactive challenge, spotlight it by collapsing/inerting the
// rest of the card (see the descriptors.main column below).
const [captchaIsInteractive, setCaptchaIsInteractive] = useState(false);
const textIdentifierField = useFormControl('identifier', initialValues[identifierAttribute] || '', {
...currentIdentifier,
isRequired: true,
Expand DownExpand Up@@ -574,6 +577,13 @@ function SignInStartInternal(): JSX.Element {
<Col
elementDescriptor={descriptors.main}
gap={6}
// @ts-ignore - `inert` is not yet in the installed React types
inert={captchaIsInteractive ? '' : undefined}
// `display:none` (not `visibility:hidden`) so the collapsed column leaves flex flow and
// contributes no `gap` gutter to `Card.Content` — otherwise it injects empty space above
// the spotlighted captcha. Subtree stays mounted (form state preserved); `inert` is then
// redundant-but-harmless.
sx={captchaIsInteractive ? { display: 'none' } : undefined}
>
<SocialButtonsReversibleContainerWithDivider>
{hasSocialOrWeb3Buttons && (
Expand DownExpand Up@@ -603,24 +613,27 @@ function SignInStartInternal(): JSX.Element {
<InstantPasswordRow field={passwordBasedInstance ? instantPasswordField : undefined} />
</Col>
<Col center>
<CaptchaElement />
<Form.SubmitButton hasArrow />
</Col>
</Form.Root>
) : null}
</SocialButtonsReversibleContainerWithDivider>
{!standardFormAttributes.length && <CaptchaElement />}
{userSettings.attributes.passkey?.enabled &&
userSettings.passkeySettings.show_sign_in_button &&
isWebSupported && (
<Card.Action elementId={'usePasskey'}>
<Card.ActionLink
localizationKey={localizationKeys('signIn.start.actionLink__use_passkey')}
onClick={() => authenticateWithPasskey({ flow: 'discoverable' })}
/>
</Card.Action>
)}
</Col>
<CaptchaElement
gapless
onInteractiveChange={setCaptchaIsInteractive}
/>
{/* Kept outside descriptors.main so the spotlight's `inert` leaves this alternative action reachable. */}
{userSettings.attributes.passkey?.enabled &&
userSettings.passkeySettings.show_sign_in_button &&
isWebSupported && (
<Card.Action elementId={'usePasskey'}>
<Card.ActionLink
localizationKey={localizationKeys('signIn.start.actionLink__use_passkey')}
onClick={() => authenticateWithPasskey({ flow: 'discoverable' })}
/>
</Card.Action>
)}
</Card.Content>
<Card.Footer>
{userSettings.signUp.mode === SIGN_UP_MODES.PUBLIC && !isCombinedFlow && (
Expand Down
2 changes: 0 additions & 2 deletions packages/clerk-js/src/ui/components/SignUp/SignUpForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,6 @@ import { LegalCheckbox } from '@/ui/elements/LegalConsentCheckbox';
import type { FormControlState } from '@/ui/utils/useFormControl';

import { Col, localizationKeys, useAppearance } from '../../customizables';
import { CaptchaElement } from '../../elements/CaptchaElement';
import { mqu } from '../../styledSystem';
import type { ActiveIdentifier, Fields } from './signUpFormHelpers';

Expand DownExpand Up@@ -115,7 +114,6 @@ export const SignUpForm = (props: SignUpFormProps) => {
</Col>
)}
<Col center>
<CaptchaElement />
<Col
gap={6}
sx={{
Expand Down
15 changes: 14 additions & 1 deletion packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -65,6 +65,9 @@ function SignUpStartInternal(): JSX.Element {
);

const [missingRequirementsWithTicket, setMissingRequirementsWithTicket] = React.useState(false);
// When the captcha escalates to an interactive challenge, spotlight it by collapsing/inerting the
// rest of the card (see the descriptors.main column below).
const [captchaIsInteractive, setCaptchaIsInteractive] = React.useState(false);

const {
userSettings: { passwordSettings, usernameSettings },
Expand DownExpand Up@@ -428,6 +431,13 @@ function SignUpStartInternal(): JSX.Element {
direction='col'
elementDescriptor={descriptors.main}
gap={6}
// @ts-ignore - `inert` is not yet in the installed React types
inert={captchaIsInteractive ? '' : undefined}
// `display:none` (not `visibility:hidden`) so the collapsed column leaves flex flow and
// contributes no `gap` gutter to `Card.Content` — otherwise it injects empty space above
// the spotlighted captcha. Subtree stays mounted (form state preserved); `inert` is then
// redundant-but-harmless.
sx={captchaIsInteractive ? { display: 'none' } : undefined}
>
<SocialButtonsReversibleContainerWithDivider>
{(showOauthProviders || showWeb3Providers || showAlternativePhoneCodeProviders) && (
Expand All@@ -450,8 +460,11 @@ function SignUpStartInternal(): JSX.Element {
/>
)}
</SocialButtonsReversibleContainerWithDivider>
{!shouldShowForm && <CaptchaElement />}
</Flex>
<CaptchaElement
gapless
onInteractiveChange={setCaptchaIsInteractive}
/>
</Card.Content>

<Card.Footer>
Expand Down
66 changes: 60 additions & 6 deletions packages/clerk-js/src/ui/elements/CaptchaElement.tsx
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,41 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';

import { CAPTCHA_ELEMENT_ID } from '../../utils/captcha/constants';
import { Box, useAppearance, useLocalizations } from '../customizables';

/**
* This component uses a MutationObserver to listen for DOM changes made by our Turnstile logic,
* which operates outside the React lifecycle. It stores the observed state in ref to ensure that
* which operates outside the React lifecycle. It stores the observed state in refs to ensure that
* any external style changes, such as updates to max-height, min-height, or margin-bottom persist across re-renders,
* preventing unwanted layout resets.
*
* When Turnstile escalates to an interactive "Verify you are human" challenge it sets
* `data-cl-interactive="true"` on the element (removed on resolve/error). `onInteractiveChange`
* surfaces that signal so a parent can react (e.g. spotlight the challenge); it never fires on mount.
*/
export const CaptchaElement = () => {
const elementRef = useRef(null);
export const CaptchaElement = ({
onInteractiveChange,
gapless,
}: {
onInteractiveChange?: (interactive: boolean) => void;
/**
* When true, the element is removed from flow (`position:absolute`) while collapsed so it adds no
* gap gutter to a flex parent, switching to `position:static` while interactive. Opt-in so the
* other (non-spotlight) render sites keep their current positioning.
*/
gapless?: boolean;
}) => {
const elementRef = useRef<HTMLDivElement>(null);
const maxHeightValueRef = useRef('0');
const minHeightValueRef = useRef('unset');
const marginBottomValueRef = useRef('unset');
// State forces a re-render on the interactive transition, which re-applies the ref-held styles
// above (preserving Turnstile's injected values) and drives the `gapless` position toggle.
const [isInteractive, setIsInteractive] = useState(false);
// The observer is set up once (`[]` deps), so it reads the latest callback through a ref.
const onInteractiveChangeRef = useRef(onInteractiveChange);
onInteractiveChangeRef.current = onInteractiveChange;
const isInteractiveRef = useRef(false);
const { parsedCaptcha } = useAppearance();
const { locale } = useLocalizations();
const captchaTheme = parsedCaptcha?.theme;
Expand All@@ -30,17 +52,46 @@ export const CaptchaElement = () => {
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
const target = mutation.target as HTMLDivElement;
if (mutation.type === 'attributes' && mutation.attributeName === 'style' && elementRef.current) {
if (mutation.type !== 'attributes' || !elementRef.current) {
return;
}
if (mutation.attributeName === 'style') {
// Keep refs in sync so Turnstile's injected styles survive React re-renders.
maxHeightValueRef.current = target.style.maxHeight || '0';
minHeightValueRef.current = target.style.minHeight || 'unset';
marginBottomValueRef.current = target.style.marginBottom || 'unset';
// Fallback for old clerk-js that never writes data-cl-interactive: infer
// interactive state from maxHeight. When the MutationObserver callback fires,
// the DOM already reflects all mutations from the same microtask, so
// `target.dataset.clInteractive` is up-to-date — new clerk-js (which sets
// the attribute alongside the style) passes the guard and is handled below.
if (!('clInteractive' in target.dataset)) {
const mh = target.style.maxHeight;
const nowInteractive = mh !== '' && mh !== '0' && mh !== '0px';
if (nowInteractive !== isInteractiveRef.current) {
isInteractiveRef.current = nowInteractive;
setIsInteractive(nowInteractive);
onInteractiveChangeRef.current?.(nowInteractive);
}
}
}
if (mutation.attributeName === 'data-cl-interactive') {
// ORDERING IS LOAD-BEARING: style mutations from the same turnstile.ts call are
// delivered before this one (DOM mutations are batched and replayed in order), so
// the refs above are already up-to-date when the re-render triggered below runs.
const nowInteractive = target.dataset.clInteractive === 'true';
if (nowInteractive !== isInteractiveRef.current) {
isInteractiveRef.current = nowInteractive;
setIsInteractive(nowInteractive);
onInteractiveChangeRef.current?.(nowInteractive);
}
}
});
});

observer.observe(elementRef.current, {
attributes: true,
attributeFilter: ['style'],
attributeFilter: ['style', 'data-cl-interactive'],
});

return () => observer.disconnect();
Expand All@@ -56,6 +107,9 @@ export const CaptchaElement = () => {
maxHeight: maxHeightValueRef.current,
minHeight: minHeightValueRef.current,
marginBottom: marginBottomValueRef.current,
// When `gapless`, drop out of flow while collapsed so the element contributes no gap gutter
// to its flex parent; rejoin flow once the interactive challenge expands it.
position: gapless ? (isInteractive ? 'static' : 'absolute') : undefined,
}}
data-cl-theme={captchaTheme}
data-cl-size={captchaSize}
Expand Down
2 changes: 2 additions & 0 deletions packages/clerk-js/src/utils/captcha/turnstile.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -194,6 +194,7 @@ export const getTurnstileToken = async (opts: CaptchaOptions) => {
// and then expands to the correct height
visibleWidget.style.minHeight = captchaSize === 'compact' ? '140px' : '68px';
visibleWidget.style.marginBottom = '1.5rem';
visibleWidget.dataset.clInteractive = 'true';
}
}
},
Expand DownExpand Up@@ -291,6 +292,7 @@ export const getTurnstileToken = async (opts: CaptchaOptions) => {
if (captchaTypeUsed === 'smart') {
const visibleWidget = document.getElementById(CAPTCHA_ELEMENT_ID);
if (visibleWidget) {
delete visibleWidget.dataset.clInteractive;
visibleWidget.style.maxHeight = '0';
visibleWidget.style.minHeight = 'unset';
visibleWidget.style.marginBottom = 'unset';
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
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;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} 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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/captcha-interactive-spotlight.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': minor
---

Spotlight interactive bot-protection (Cloudflare Turnstile) challenges during sign-in and sign-up. When a challenge escalates to an interactive "Verify you are human" check, the start card now brings it to the foreground — collapsing and `inert`-ing the rest of the form until the challenge is solved — while keeping the header, footer, and passkey action reachable. Invisible challenges are unaffected.
37 changes: 25 additions & 12 deletions packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -155,6 +155,9 @@ function SignInStartInternal(): JSX.Element {
const hasSocialOrWeb3Buttons =
!!authenticatableSocialStrategies.length || !!web3FirstFactors.length || !!alternativePhoneCodeChannels.length;
const [shouldAutofocus, setShouldAutofocus] = useState(!isMobileDevice() && !hasSocialOrWeb3Buttons);
// When the captcha escalates to an interactive challenge, spotlight it by collapsing/inerting the
// rest of the card (see the descriptors.main column below).
const [captchaIsInteractive, setCaptchaIsInteractive] = useState(false);
const textIdentifierField = useFormControl('identifier', initialValues[identifierAttribute] || '', {
...currentIdentifier,
isRequired: true,
Expand DownExpand Up@@ -574,6 +577,13 @@ function SignInStartInternal(): JSX.Element {
<Col
elementDescriptor={descriptors.main}
gap={6}
// @ts-ignore - `inert` is not yet in the installed React types
inert={captchaIsInteractive ? '' : undefined}
// `display:none` (not `visibility:hidden`) so the collapsed column leaves flex flow and
// contributes no `gap` gutter to `Card.Content` — otherwise it injects empty space above
// the spotlighted captcha. Subtree stays mounted (form state preserved); `inert` is then
// redundant-but-harmless.
sx={captchaIsInteractive ? { display: 'none' } : undefined}
>
<SocialButtonsReversibleContainerWithDivider>
{hasSocialOrWeb3Buttons && (
Expand DownExpand Up@@ -603,24 +613,27 @@ function SignInStartInternal(): JSX.Element {
<InstantPasswordRow field={passwordBasedInstance ? instantPasswordField : undefined} />
</Col>
<Col center>
<CaptchaElement />
<Form.SubmitButton hasArrow />
</Col>
</Form.Root>
) : null}
</SocialButtonsReversibleContainerWithDivider>
{!standardFormAttributes.length && <CaptchaElement />}
{userSettings.attributes.passkey?.enabled &&
userSettings.passkeySettings.show_sign_in_button &&
isWebSupported && (
<Card.Action elementId={'usePasskey'}>
<Card.ActionLink
localizationKey={localizationKeys('signIn.start.actionLink__use_passkey')}
onClick={() => authenticateWithPasskey({ flow: 'discoverable' })}
/>
</Card.Action>
)}
</Col>
<CaptchaElement
gapless
onInteractiveChange={setCaptchaIsInteractive}
/>
{/* Kept outside descriptors.main so the spotlight's `inert` leaves this alternative action reachable. */}
{userSettings.attributes.passkey?.enabled &&
userSettings.passkeySettings.show_sign_in_button &&
isWebSupported && (
<Card.Action elementId={'usePasskey'}>
<Card.ActionLink
localizationKey={localizationKeys('signIn.start.actionLink__use_passkey')}
onClick={() => authenticateWithPasskey({ flow: 'discoverable' })}
/>
</Card.Action>
)}
</Card.Content>
<Card.Footer>
{userSettings.signUp.mode === SIGN_UP_MODES.PUBLIC && !isCombinedFlow && (
Expand Down
2 changes: 0 additions & 2 deletions packages/clerk-js/src/ui/components/SignUp/SignUpForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,6 @@ import { LegalCheckbox } from '@/ui/elements/LegalConsentCheckbox';
import type { FormControlState } from '@/ui/utils/useFormControl';

import { Col, localizationKeys, useAppearance } from '../../customizables';
import { CaptchaElement } from '../../elements/CaptchaElement';
import { mqu } from '../../styledSystem';
import type { ActiveIdentifier, Fields } from './signUpFormHelpers';

Expand DownExpand Up@@ -115,7 +114,6 @@ export const SignUpForm = (props: SignUpFormProps) => {
</Col>
)}
<Col center>
<CaptchaElement />
<Col
gap={6}
sx={{
Expand Down
15 changes: 14 additions & 1 deletion packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -65,6 +65,9 @@ function SignUpStartInternal(): JSX.Element {
);

const [missingRequirementsWithTicket, setMissingRequirementsWithTicket] = React.useState(false);
// When the captcha escalates to an interactive challenge, spotlight it by collapsing/inerting the
// rest of the card (see the descriptors.main column below).
const [captchaIsInteractive, setCaptchaIsInteractive] = React.useState(false);

const {
userSettings: { passwordSettings, usernameSettings },
Expand DownExpand Up@@ -428,6 +431,13 @@ function SignUpStartInternal(): JSX.Element {
direction='col'
elementDescriptor={descriptors.main}
gap={6}
// @ts-ignore - `inert` is not yet in the installed React types
inert={captchaIsInteractive ? '' : undefined}
// `display:none` (not `visibility:hidden`) so the collapsed column leaves flex flow and
// contributes no `gap` gutter to `Card.Content` — otherwise it injects empty space above
// the spotlighted captcha. Subtree stays mounted (form state preserved); `inert` is then
// redundant-but-harmless.
sx={captchaIsInteractive ? { display: 'none' } : undefined}
>
<SocialButtonsReversibleContainerWithDivider>
{(showOauthProviders || showWeb3Providers || showAlternativePhoneCodeProviders) && (
Expand All@@ -450,8 +460,11 @@ function SignUpStartInternal(): JSX.Element {
/>
)}
</SocialButtonsReversibleContainerWithDivider>
{!shouldShowForm && <CaptchaElement />}
</Flex>
<CaptchaElement
gapless
onInteractiveChange={setCaptchaIsInteractive}
/>
</Card.Content>

<Card.Footer>
Expand Down
66 changes: 60 additions & 6 deletions packages/clerk-js/src/ui/elements/CaptchaElement.tsx
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,41 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';

import { CAPTCHA_ELEMENT_ID } from '../../utils/captcha/constants';
import { Box, useAppearance, useLocalizations } from '../customizables';

/**
* This component uses a MutationObserver to listen for DOM changes made by our Turnstile logic,
* which operates outside the React lifecycle. It stores the observed state in ref to ensure that
* which operates outside the React lifecycle. It stores the observed state in refs to ensure that
* any external style changes, such as updates to max-height, min-height, or margin-bottom persist across re-renders,
* preventing unwanted layout resets.
*
* When Turnstile escalates to an interactive "Verify you are human" challenge it sets
* `data-cl-interactive="true"` on the element (removed on resolve/error). `onInteractiveChange`
* surfaces that signal so a parent can react (e.g. spotlight the challenge); it never fires on mount.
*/
export const CaptchaElement = () => {
const elementRef = useRef(null);
export const CaptchaElement = ({
onInteractiveChange,
gapless,
}: {
onInteractiveChange?: (interactive: boolean) => void;
/**
* When true, the element is removed from flow (`position:absolute`) while collapsed so it adds no
* gap gutter to a flex parent, switching to `position:static` while interactive. Opt-in so the
* other (non-spotlight) render sites keep their current positioning.
*/
gapless?: boolean;
}) => {
const elementRef = useRef<HTMLDivElement>(null);
const maxHeightValueRef = useRef('0');
const minHeightValueRef = useRef('unset');
const marginBottomValueRef = useRef('unset');
// State forces a re-render on the interactive transition, which re-applies the ref-held styles
// above (preserving Turnstile's injected values) and drives the `gapless` position toggle.
const [isInteractive, setIsInteractive] = useState(false);
// The observer is set up once (`[]` deps), so it reads the latest callback through a ref.
const onInteractiveChangeRef = useRef(onInteractiveChange);
onInteractiveChangeRef.current = onInteractiveChange;
const isInteractiveRef = useRef(false);
const { parsedCaptcha } = useAppearance();
const { locale } = useLocalizations();
const captchaTheme = parsedCaptcha?.theme;
Expand All@@ -30,17 +52,46 @@ export const CaptchaElement = () => {
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
const target = mutation.target as HTMLDivElement;
if (mutation.type === 'attributes' && mutation.attributeName === 'style' && elementRef.current) {
if (mutation.type !== 'attributes' || !elementRef.current) {
return;
}
if (mutation.attributeName === 'style') {
// Keep refs in sync so Turnstile's injected styles survive React re-renders.
maxHeightValueRef.current = target.style.maxHeight || '0';
minHeightValueRef.current = target.style.minHeight || 'unset';
marginBottomValueRef.current = target.style.marginBottom || 'unset';
// Fallback for old clerk-js that never writes data-cl-interactive: infer
// interactive state from maxHeight. When the MutationObserver callback fires,
// the DOM already reflects all mutations from the same microtask, so
// `target.dataset.clInteractive` is up-to-date — new clerk-js (which sets
// the attribute alongside the style) passes the guard and is handled below.
if (!('clInteractive' in target.dataset)) {
const mh = target.style.maxHeight;
const nowInteractive = mh !== '' && mh !== '0' && mh !== '0px';
if (nowInteractive !== isInteractiveRef.current) {
isInteractiveRef.current = nowInteractive;
setIsInteractive(nowInteractive);
onInteractiveChangeRef.current?.(nowInteractive);
}
}
}
if (mutation.attributeName === 'data-cl-interactive') {
// ORDERING IS LOAD-BEARING: style mutations from the same turnstile.ts call are
// delivered before this one (DOM mutations are batched and replayed in order), so
// the refs above are already up-to-date when the re-render triggered below runs.
const nowInteractive = target.dataset.clInteractive === 'true';
if (nowInteractive !== isInteractiveRef.current) {
isInteractiveRef.current = nowInteractive;
setIsInteractive(nowInteractive);
onInteractiveChangeRef.current?.(nowInteractive);
}
}
});
});

observer.observe(elementRef.current, {
attributes: true,
attributeFilter: ['style'],
attributeFilter: ['style', 'data-cl-interactive'],
});

return () => observer.disconnect();
Expand All@@ -56,6 +107,9 @@ export const CaptchaElement = () => {
maxHeight: maxHeightValueRef.current,
minHeight: minHeightValueRef.current,
marginBottom: marginBottomValueRef.current,
// When `gapless`, drop out of flow while collapsed so the element contributes no gap gutter
// to its flex parent; rejoin flow once the interactive challenge expands it.
position: gapless ? (isInteractive ? 'static' : 'absolute') : undefined,
}}
data-cl-theme={captchaTheme}
data-cl-size={captchaSize}
Expand Down
2 changes: 2 additions & 0 deletions packages/clerk-js/src/utils/captcha/turnstile.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -194,6 +194,7 @@ export const getTurnstileToken = async (opts: CaptchaOptions) => {
// and then expands to the correct height
visibleWidget.style.minHeight = captchaSize === 'compact' ? '140px' : '68px';
visibleWidget.style.marginBottom = '1.5rem';
visibleWidget.dataset.clInteractive = 'true';
}
}
},
Expand DownExpand Up@@ -291,6 +292,7 @@ export const getTurnstileToken = async (opts: CaptchaOptions) => {
if (captchaTypeUsed === 'smart') {
const visibleWidget = document.getElementById(CAPTCHA_ELEMENT_ID);
if (visibleWidget) {
delete visibleWidget.dataset.clInteractive;
visibleWidget.style.maxHeight = '0';
visibleWidget.style.minHeight = 'unset';
visibleWidget.style.marginBottom = 'unset';
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/captcha-interactive-spotlight.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': minor
---

Spotlight interactive bot-protection (Cloudflare Turnstile) challenges during sign-in and sign-up. When a challenge escalates to an interactive "Verify you are human" check, the start card now brings it to the foreground — collapsing and `inert`-ing the rest of the form until the challenge is solved — while keeping the header, footer, and passkey action reachable. Invisible challenges are unaffected.
37 changes: 25 additions & 12 deletions packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -155,6 +155,9 @@ function SignInStartInternal(): JSX.Element {
const hasSocialOrWeb3Buttons =
!!authenticatableSocialStrategies.length || !!web3FirstFactors.length || !!alternativePhoneCodeChannels.length;
const [shouldAutofocus, setShouldAutofocus] = useState(!isMobileDevice() && !hasSocialOrWeb3Buttons);
// When the captcha escalates to an interactive challenge, spotlight it by collapsing/inerting the
// rest of the card (see the descriptors.main column below).
const [captchaIsInteractive, setCaptchaIsInteractive] = useState(false);
const textIdentifierField = useFormControl('identifier', initialValues[identifierAttribute] || '', {
...currentIdentifier,
isRequired: true,
Expand DownExpand Up@@ -574,6 +577,13 @@ function SignInStartInternal(): JSX.Element {
<Col
elementDescriptor={descriptors.main}
gap={6}
// @ts-ignore - `inert` is not yet in the installed React types
inert={captchaIsInteractive ? '' : undefined}
// `display:none` (not `visibility:hidden`) so the collapsed column leaves flex flow and
// contributes no `gap` gutter to `Card.Content` — otherwise it injects empty space above
// the spotlighted captcha. Subtree stays mounted (form state preserved); `inert` is then
// redundant-but-harmless.
sx={captchaIsInteractive ? { display: 'none' } : undefined}
>
<SocialButtonsReversibleContainerWithDivider>
{hasSocialOrWeb3Buttons && (
Expand DownExpand Up@@ -603,24 +613,27 @@ function SignInStartInternal(): JSX.Element {
<InstantPasswordRow field={passwordBasedInstance ? instantPasswordField : undefined} />
</Col>
<Col center>
<CaptchaElement />
<Form.SubmitButton hasArrow />
</Col>
</Form.Root>
) : null}
</SocialButtonsReversibleContainerWithDivider>
{!standardFormAttributes.length && <CaptchaElement />}
{userSettings.attributes.passkey?.enabled &&
userSettings.passkeySettings.show_sign_in_button &&
isWebSupported && (
<Card.Action elementId={'usePasskey'}>
<Card.ActionLink
localizationKey={localizationKeys('signIn.start.actionLink__use_passkey')}
onClick={() => authenticateWithPasskey({ flow: 'discoverable' })}
/>
</Card.Action>
)}
</Col>
<CaptchaElement
gapless
onInteractiveChange={setCaptchaIsInteractive}
/>
{/* Kept outside descriptors.main so the spotlight's `inert` leaves this alternative action reachable. */}
{userSettings.attributes.passkey?.enabled &&
userSettings.passkeySettings.show_sign_in_button &&
isWebSupported && (
<Card.Action elementId={'usePasskey'}>
<Card.ActionLink
localizationKey={localizationKeys('signIn.start.actionLink__use_passkey')}
onClick={() => authenticateWithPasskey({ flow: 'discoverable' })}
/>
</Card.Action>
)}
</Card.Content>
<Card.Footer>
{userSettings.signUp.mode === SIGN_UP_MODES.PUBLIC && !isCombinedFlow && (
Expand Down
2 changes: 0 additions & 2 deletions packages/clerk-js/src/ui/components/SignUp/SignUpForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,6 @@ import { LegalCheckbox } from '@/ui/elements/LegalConsentCheckbox';
import type { FormControlState } from '@/ui/utils/useFormControl';

import { Col, localizationKeys, useAppearance } from '../../customizables';
import { CaptchaElement } from '../../elements/CaptchaElement';
import { mqu } from '../../styledSystem';
import type { ActiveIdentifier, Fields } from './signUpFormHelpers';

Expand DownExpand Up@@ -115,7 +114,6 @@ export const SignUpForm = (props: SignUpFormProps) => {
</Col>
)}
<Col center>
<CaptchaElement />
<Col
gap={6}
sx={{
Expand Down
15 changes: 14 additions & 1 deletion packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -65,6 +65,9 @@ function SignUpStartInternal(): JSX.Element {
);

const [missingRequirementsWithTicket, setMissingRequirementsWithTicket] = React.useState(false);
// When the captcha escalates to an interactive challenge, spotlight it by collapsing/inerting the
// rest of the card (see the descriptors.main column below).
const [captchaIsInteractive, setCaptchaIsInteractive] = React.useState(false);

const {
userSettings: { passwordSettings, usernameSettings },
Expand DownExpand Up@@ -428,6 +431,13 @@ function SignUpStartInternal(): JSX.Element {
direction='col'
elementDescriptor={descriptors.main}
gap={6}
// @ts-ignore - `inert` is not yet in the installed React types
inert={captchaIsInteractive ? '' : undefined}
// `display:none` (not `visibility:hidden`) so the collapsed column leaves flex flow and
// contributes no `gap` gutter to `Card.Content` — otherwise it injects empty space above
// the spotlighted captcha. Subtree stays mounted (form state preserved); `inert` is then
// redundant-but-harmless.
sx={captchaIsInteractive ? { display: 'none' } : undefined}
>
<SocialButtonsReversibleContainerWithDivider>
{(showOauthProviders || showWeb3Providers || showAlternativePhoneCodeProviders) && (
Expand All@@ -450,8 +460,11 @@ function SignUpStartInternal(): JSX.Element {
/>
)}
</SocialButtonsReversibleContainerWithDivider>
{!shouldShowForm && <CaptchaElement />}
</Flex>
<CaptchaElement
gapless
onInteractiveChange={setCaptchaIsInteractive}
/>
</Card.Content>

<Card.Footer>
Expand Down
66 changes: 60 additions & 6 deletions packages/clerk-js/src/ui/elements/CaptchaElement.tsx
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,41 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';

import { CAPTCHA_ELEMENT_ID } from '../../utils/captcha/constants';
import { Box, useAppearance, useLocalizations } from '../customizables';

/**
* This component uses a MutationObserver to listen for DOM changes made by our Turnstile logic,
* which operates outside the React lifecycle. It stores the observed state in ref to ensure that
* which operates outside the React lifecycle. It stores the observed state in refs to ensure that
* any external style changes, such as updates to max-height, min-height, or margin-bottom persist across re-renders,
* preventing unwanted layout resets.
*
* When Turnstile escalates to an interactive "Verify you are human" challenge it sets
* `data-cl-interactive="true"` on the element (removed on resolve/error). `onInteractiveChange`
* surfaces that signal so a parent can react (e.g. spotlight the challenge); it never fires on mount.
*/
export const CaptchaElement = () => {
const elementRef = useRef(null);
export const CaptchaElement = ({
onInteractiveChange,
gapless,
}: {
onInteractiveChange?: (interactive: boolean) => void;
/**
* When true, the element is removed from flow (`position:absolute`) while collapsed so it adds no
* gap gutter to a flex parent, switching to `position:static` while interactive. Opt-in so the
* other (non-spotlight) render sites keep their current positioning.
*/
gapless?: boolean;
}) => {
const elementRef = useRef<HTMLDivElement>(null);
const maxHeightValueRef = useRef('0');
const minHeightValueRef = useRef('unset');
const marginBottomValueRef = useRef('unset');
// State forces a re-render on the interactive transition, which re-applies the ref-held styles
// above (preserving Turnstile's injected values) and drives the `gapless` position toggle.
const [isInteractive, setIsInteractive] = useState(false);
// The observer is set up once (`[]` deps), so it reads the latest callback through a ref.
const onInteractiveChangeRef = useRef(onInteractiveChange);
onInteractiveChangeRef.current = onInteractiveChange;
const isInteractiveRef = useRef(false);
const { parsedCaptcha } = useAppearance();
const { locale } = useLocalizations();
const captchaTheme = parsedCaptcha?.theme;
Expand All@@ -30,17 +52,46 @@ export const CaptchaElement = () => {
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
const target = mutation.target as HTMLDivElement;
if (mutation.type === 'attributes' && mutation.attributeName === 'style' && elementRef.current) {
if (mutation.type !== 'attributes' || !elementRef.current) {
return;
}
if (mutation.attributeName === 'style') {
// Keep refs in sync so Turnstile's injected styles survive React re-renders.
maxHeightValueRef.current = target.style.maxHeight || '0';
minHeightValueRef.current = target.style.minHeight || 'unset';
marginBottomValueRef.current = target.style.marginBottom || 'unset';
// Fallback for old clerk-js that never writes data-cl-interactive: infer
// interactive state from maxHeight. When the MutationObserver callback fires,
// the DOM already reflects all mutations from the same microtask, so
// `target.dataset.clInteractive` is up-to-date — new clerk-js (which sets
// the attribute alongside the style) passes the guard and is handled below.
if (!('clInteractive' in target.dataset)) {
const mh = target.style.maxHeight;
const nowInteractive = mh !== '' && mh !== '0' && mh !== '0px';
if (nowInteractive !== isInteractiveRef.current) {
isInteractiveRef.current = nowInteractive;
setIsInteractive(nowInteractive);
onInteractiveChangeRef.current?.(nowInteractive);
}
}
}
if (mutation.attributeName === 'data-cl-interactive') {
// ORDERING IS LOAD-BEARING: style mutations from the same turnstile.ts call are
// delivered before this one (DOM mutations are batched and replayed in order), so
// the refs above are already up-to-date when the re-render triggered below runs.
const nowInteractive = target.dataset.clInteractive === 'true';
if (nowInteractive !== isInteractiveRef.current) {
isInteractiveRef.current = nowInteractive;
setIsInteractive(nowInteractive);
onInteractiveChangeRef.current?.(nowInteractive);
}
}
});
});

observer.observe(elementRef.current, {
attributes: true,
attributeFilter: ['style'],
attributeFilter: ['style', 'data-cl-interactive'],
});

return () => observer.disconnect();
Expand All@@ -56,6 +107,9 @@ export const CaptchaElement = () => {
maxHeight: maxHeightValueRef.current,
minHeight: minHeightValueRef.current,
marginBottom: marginBottomValueRef.current,
// When `gapless`, drop out of flow while collapsed so the element contributes no gap gutter
// to its flex parent; rejoin flow once the interactive challenge expands it.
position: gapless ? (isInteractive ? 'static' : 'absolute') : undefined,
}}
data-cl-theme={captchaTheme}
data-cl-size={captchaSize}
Expand Down
2 changes: 2 additions & 0 deletions packages/clerk-js/src/utils/captcha/turnstile.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -194,6 +194,7 @@ export const getTurnstileToken = async (opts: CaptchaOptions) => {
// and then expands to the correct height
visibleWidget.style.minHeight = captchaSize === 'compact' ? '140px' : '68px';
visibleWidget.style.marginBottom = '1.5rem';
visibleWidget.dataset.clInteractive = 'true';
}
}
},
Expand DownExpand Up@@ -291,6 +292,7 @@ export const getTurnstileToken = async (opts: CaptchaOptions) => {
if (captchaTypeUsed === 'smart') {
const visibleWidget = document.getElementById(CAPTCHA_ELEMENT_ID);
if (visibleWidget) {
delete visibleWidget.dataset.clInteractive;
visibleWidget.style.maxHeight = '0';
visibleWidget.style.minHeight = 'unset';
visibleWidget.style.marginBottom = 'unset';
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/captcha-interactive-spotlight.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': minor
---

Spotlight interactive bot-protection (Cloudflare Turnstile) challenges during sign-in and sign-up. When a challenge escalates to an interactive "Verify you are human" check, the start card now brings it to the foreground — collapsing and `inert`-ing the rest of the form until the challenge is solved — while keeping the header, footer, and passkey action reachable. Invisible challenges are unaffected.
37 changes: 25 additions & 12 deletions packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -155,6 +155,9 @@ function SignInStartInternal(): JSX.Element {
const hasSocialOrWeb3Buttons =
!!authenticatableSocialStrategies.length || !!web3FirstFactors.length || !!alternativePhoneCodeChannels.length;
const [shouldAutofocus, setShouldAutofocus] = useState(!isMobileDevice() && !hasSocialOrWeb3Buttons);
// When the captcha escalates to an interactive challenge, spotlight it by collapsing/inerting the
// rest of the card (see the descriptors.main column below).
const [captchaIsInteractive, setCaptchaIsInteractive] = useState(false);
const textIdentifierField = useFormControl('identifier', initialValues[identifierAttribute] || '', {
...currentIdentifier,
isRequired: true,
Expand DownExpand Up@@ -574,6 +577,13 @@ function SignInStartInternal(): JSX.Element {
<Col
elementDescriptor={descriptors.main}
gap={6}
// @ts-ignore - `inert` is not yet in the installed React types
inert={captchaIsInteractive ? '' : undefined}
// `display:none` (not `visibility:hidden`) so the collapsed column leaves flex flow and
// contributes no `gap` gutter to `Card.Content` — otherwise it injects empty space above
// the spotlighted captcha. Subtree stays mounted (form state preserved); `inert` is then
// redundant-but-harmless.
sx={captchaIsInteractive ? { display: 'none' } : undefined}
>
<SocialButtonsReversibleContainerWithDivider>
{hasSocialOrWeb3Buttons && (
Expand DownExpand Up@@ -603,24 +613,27 @@ function SignInStartInternal(): JSX.Element {
<InstantPasswordRow field={passwordBasedInstance ? instantPasswordField : undefined} />
</Col>
<Col center>
<CaptchaElement />
<Form.SubmitButton hasArrow />
</Col>
</Form.Root>
) : null}
</SocialButtonsReversibleContainerWithDivider>
{!standardFormAttributes.length && <CaptchaElement />}
{userSettings.attributes.passkey?.enabled &&
userSettings.passkeySettings.show_sign_in_button &&
isWebSupported && (
<Card.Action elementId={'usePasskey'}>
<Card.ActionLink
localizationKey={localizationKeys('signIn.start.actionLink__use_passkey')}
onClick={() => authenticateWithPasskey({ flow: 'discoverable' })}
/>
</Card.Action>
)}
</Col>
<CaptchaElement
gapless
onInteractiveChange={setCaptchaIsInteractive}
/>
{/* Kept outside descriptors.main so the spotlight's `inert` leaves this alternative action reachable. */}
{userSettings.attributes.passkey?.enabled &&
userSettings.passkeySettings.show_sign_in_button &&
isWebSupported && (
<Card.Action elementId={'usePasskey'}>
<Card.ActionLink
localizationKey={localizationKeys('signIn.start.actionLink__use_passkey')}
onClick={() => authenticateWithPasskey({ flow: 'discoverable' })}
/>
</Card.Action>
)}
</Card.Content>
<Card.Footer>
{userSettings.signUp.mode === SIGN_UP_MODES.PUBLIC && !isCombinedFlow && (
Expand Down
2 changes: 0 additions & 2 deletions packages/clerk-js/src/ui/components/SignUp/SignUpForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,6 @@ import { LegalCheckbox } from '@/ui/elements/LegalConsentCheckbox';
import type { FormControlState } from '@/ui/utils/useFormControl';

import { Col, localizationKeys, useAppearance } from '../../customizables';
import { CaptchaElement } from '../../elements/CaptchaElement';
import { mqu } from '../../styledSystem';
import type { ActiveIdentifier, Fields } from './signUpFormHelpers';

Expand DownExpand Up@@ -115,7 +114,6 @@ export const SignUpForm = (props: SignUpFormProps) => {
</Col>
)}
<Col center>
<CaptchaElement />
<Col
gap={6}
sx={{
Expand Down
15 changes: 14 additions & 1 deletion packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -65,6 +65,9 @@ function SignUpStartInternal(): JSX.Element {
);

const [missingRequirementsWithTicket, setMissingRequirementsWithTicket] = React.useState(false);
// When the captcha escalates to an interactive challenge, spotlight it by collapsing/inerting the
// rest of the card (see the descriptors.main column below).
const [captchaIsInteractive, setCaptchaIsInteractive] = React.useState(false);

const {
userSettings: { passwordSettings, usernameSettings },
Expand DownExpand Up@@ -428,6 +431,13 @@ function SignUpStartInternal(): JSX.Element {
direction='col'
elementDescriptor={descriptors.main}
gap={6}
// @ts-ignore - `inert` is not yet in the installed React types
inert={captchaIsInteractive ? '' : undefined}
// `display:none` (not `visibility:hidden`) so the collapsed column leaves flex flow and
// contributes no `gap` gutter to `Card.Content` — otherwise it injects empty space above
// the spotlighted captcha. Subtree stays mounted (form state preserved); `inert` is then
// redundant-but-harmless.
sx={captchaIsInteractive ? { display: 'none' } : undefined}
>
<SocialButtonsReversibleContainerWithDivider>
{(showOauthProviders || showWeb3Providers || showAlternativePhoneCodeProviders) && (
Expand All@@ -450,8 +460,11 @@ function SignUpStartInternal(): JSX.Element {
/>
)}
</SocialButtonsReversibleContainerWithDivider>
{!shouldShowForm && <CaptchaElement />}
</Flex>
<CaptchaElement
gapless
onInteractiveChange={setCaptchaIsInteractive}
/>
</Card.Content>

<Card.Footer>
Expand Down
66 changes: 60 additions & 6 deletions packages/clerk-js/src/ui/elements/CaptchaElement.tsx
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,41 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';

import { CAPTCHA_ELEMENT_ID } from '../../utils/captcha/constants';
import { Box, useAppearance, useLocalizations } from '../customizables';

/**
* This component uses a MutationObserver to listen for DOM changes made by our Turnstile logic,
* which operates outside the React lifecycle. It stores the observed state in ref to ensure that
* which operates outside the React lifecycle. It stores the observed state in refs to ensure that
* any external style changes, such as updates to max-height, min-height, or margin-bottom persist across re-renders,
* preventing unwanted layout resets.
*
* When Turnstile escalates to an interactive "Verify you are human" challenge it sets
* `data-cl-interactive="true"` on the element (removed on resolve/error). `onInteractiveChange`
* surfaces that signal so a parent can react (e.g. spotlight the challenge); it never fires on mount.
*/
export const CaptchaElement = () => {
const elementRef = useRef(null);
export const CaptchaElement = ({
onInteractiveChange,
gapless,
}: {
onInteractiveChange?: (interactive: boolean) => void;
/**
* When true, the element is removed from flow (`position:absolute`) while collapsed so it adds no
* gap gutter to a flex parent, switching to `position:static` while interactive. Opt-in so the
* other (non-spotlight) render sites keep their current positioning.
*/
gapless?: boolean;
}) => {
const elementRef = useRef<HTMLDivElement>(null);
const maxHeightValueRef = useRef('0');
const minHeightValueRef = useRef('unset');
const marginBottomValueRef = useRef('unset');
// State forces a re-render on the interactive transition, which re-applies the ref-held styles
// above (preserving Turnstile's injected values) and drives the `gapless` position toggle.
const [isInteractive, setIsInteractive] = useState(false);
// The observer is set up once (`[]` deps), so it reads the latest callback through a ref.
const onInteractiveChangeRef = useRef(onInteractiveChange);
onInteractiveChangeRef.current = onInteractiveChange;
const isInteractiveRef = useRef(false);
const { parsedCaptcha } = useAppearance();
const { locale } = useLocalizations();
const captchaTheme = parsedCaptcha?.theme;
Expand All@@ -30,17 +52,46 @@ export const CaptchaElement = () => {
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
const target = mutation.target as HTMLDivElement;
if (mutation.type === 'attributes' && mutation.attributeName === 'style' && elementRef.current) {
if (mutation.type !== 'attributes' || !elementRef.current) {
return;
}
if (mutation.attributeName === 'style') {
// Keep refs in sync so Turnstile's injected styles survive React re-renders.
maxHeightValueRef.current = target.style.maxHeight || '0';
minHeightValueRef.current = target.style.minHeight || 'unset';
marginBottomValueRef.current = target.style.marginBottom || 'unset';
// Fallback for old clerk-js that never writes data-cl-interactive: infer
// interactive state from maxHeight. When the MutationObserver callback fires,
// the DOM already reflects all mutations from the same microtask, so
// `target.dataset.clInteractive` is up-to-date — new clerk-js (which sets
// the attribute alongside the style) passes the guard and is handled below.
if (!('clInteractive' in target.dataset)) {
const mh = target.style.maxHeight;
const nowInteractive = mh !== '' && mh !== '0' && mh !== '0px';
if (nowInteractive !== isInteractiveRef.current) {
isInteractiveRef.current = nowInteractive;
setIsInteractive(nowInteractive);
onInteractiveChangeRef.current?.(nowInteractive);
}
}
}
if (mutation.attributeName === 'data-cl-interactive') {
// ORDERING IS LOAD-BEARING: style mutations from the same turnstile.ts call are
// delivered before this one (DOM mutations are batched and replayed in order), so
// the refs above are already up-to-date when the re-render triggered below runs.
const nowInteractive = target.dataset.clInteractive === 'true';
if (nowInteractive !== isInteractiveRef.current) {
isInteractiveRef.current = nowInteractive;
setIsInteractive(nowInteractive);
onInteractiveChangeRef.current?.(nowInteractive);
}
}
});
});

observer.observe(elementRef.current, {
attributes: true,
attributeFilter: ['style'],
attributeFilter: ['style', 'data-cl-interactive'],
});

return () => observer.disconnect();
Expand All@@ -56,6 +107,9 @@ export const CaptchaElement = () => {
maxHeight: maxHeightValueRef.current,
minHeight: minHeightValueRef.current,
marginBottom: marginBottomValueRef.current,
// When `gapless`, drop out of flow while collapsed so the element contributes no gap gutter
// to its flex parent; rejoin flow once the interactive challenge expands it.
position: gapless ? (isInteractive ? 'static' : 'absolute') : undefined,
}}
data-cl-theme={captchaTheme}
data-cl-size={captchaSize}
Expand Down
2 changes: 2 additions & 0 deletions packages/clerk-js/src/utils/captcha/turnstile.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -194,6 +194,7 @@ export const getTurnstileToken = async (opts: CaptchaOptions) => {
// and then expands to the correct height
visibleWidget.style.minHeight = captchaSize === 'compact' ? '140px' : '68px';
visibleWidget.style.marginBottom = '1.5rem';
visibleWidget.dataset.clInteractive = 'true';
}
}
},
Expand DownExpand Up@@ -291,6 +292,7 @@ export const getTurnstileToken = async (opts: CaptchaOptions) => {
if (captchaTypeUsed === 'smart') {
const visibleWidget = document.getElementById(CAPTCHA_ELEMENT_ID);
if (visibleWidget) {
delete visibleWidget.dataset.clInteractive;
visibleWidget.style.maxHeight = '0';
visibleWidget.style.minHeight = 'unset';
visibleWidget.style.marginBottom = 'unset';
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } 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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/captcha-interactive-spotlight.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': minor
---

Spotlight interactive bot-protection (Cloudflare Turnstile) challenges during sign-in and sign-up. When a challenge escalates to an interactive "Verify you are human" check, the start card now brings it to the foreground — collapsing and `inert`-ing the rest of the form until the challenge is solved — while keeping the header, footer, and passkey action reachable. Invisible challenges are unaffected.
37 changes: 25 additions & 12 deletions packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -155,6 +155,9 @@ function SignInStartInternal(): JSX.Element {
const hasSocialOrWeb3Buttons =
!!authenticatableSocialStrategies.length || !!web3FirstFactors.length || !!alternativePhoneCodeChannels.length;
const [shouldAutofocus, setShouldAutofocus] = useState(!isMobileDevice() && !hasSocialOrWeb3Buttons);
// When the captcha escalates to an interactive challenge, spotlight it by collapsing/inerting the
// rest of the card (see the descriptors.main column below).
const [captchaIsInteractive, setCaptchaIsInteractive] = useState(false);
const textIdentifierField = useFormControl('identifier', initialValues[identifierAttribute] || '', {
...currentIdentifier,
isRequired: true,
Expand DownExpand Up@@ -574,6 +577,13 @@ function SignInStartInternal(): JSX.Element {
<Col
elementDescriptor={descriptors.main}
gap={6}
// @ts-ignore - `inert` is not yet in the installed React types
inert={captchaIsInteractive ? '' : undefined}
// `display:none` (not `visibility:hidden`) so the collapsed column leaves flex flow and
// contributes no `gap` gutter to `Card.Content` — otherwise it injects empty space above
// the spotlighted captcha. Subtree stays mounted (form state preserved); `inert` is then
// redundant-but-harmless.
sx={captchaIsInteractive ? { display: 'none' } : undefined}
>
<SocialButtonsReversibleContainerWithDivider>
{hasSocialOrWeb3Buttons && (
Expand DownExpand Up@@ -603,24 +613,27 @@ function SignInStartInternal(): JSX.Element {
<InstantPasswordRow field={passwordBasedInstance ? instantPasswordField : undefined} />
</Col>
<Col center>
<CaptchaElement />
<Form.SubmitButton hasArrow />
</Col>
</Form.Root>
) : null}
</SocialButtonsReversibleContainerWithDivider>
{!standardFormAttributes.length && <CaptchaElement />}
{userSettings.attributes.passkey?.enabled &&
userSettings.passkeySettings.show_sign_in_button &&
isWebSupported && (
<Card.Action elementId={'usePasskey'}>
<Card.ActionLink
localizationKey={localizationKeys('signIn.start.actionLink__use_passkey')}
onClick={() => authenticateWithPasskey({ flow: 'discoverable' })}
/>
</Card.Action>
)}
</Col>
<CaptchaElement
gapless
onInteractiveChange={setCaptchaIsInteractive}
/>
{/* Kept outside descriptors.main so the spotlight's `inert` leaves this alternative action reachable. */}
{userSettings.attributes.passkey?.enabled &&
userSettings.passkeySettings.show_sign_in_button &&
isWebSupported && (
<Card.Action elementId={'usePasskey'}>
<Card.ActionLink
localizationKey={localizationKeys('signIn.start.actionLink__use_passkey')}
onClick={() => authenticateWithPasskey({ flow: 'discoverable' })}
/>
</Card.Action>
)}
</Card.Content>
<Card.Footer>
{userSettings.signUp.mode === SIGN_UP_MODES.PUBLIC && !isCombinedFlow && (
Expand Down
2 changes: 0 additions & 2 deletions packages/clerk-js/src/ui/components/SignUp/SignUpForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,6 @@ import { LegalCheckbox } from '@/ui/elements/LegalConsentCheckbox';
import type { FormControlState } from '@/ui/utils/useFormControl';

import { Col, localizationKeys, useAppearance } from '../../customizables';
import { CaptchaElement } from '../../elements/CaptchaElement';
import { mqu } from '../../styledSystem';
import type { ActiveIdentifier, Fields } from './signUpFormHelpers';

Expand DownExpand Up@@ -115,7 +114,6 @@ export const SignUpForm = (props: SignUpFormProps) => {
</Col>
)}
<Col center>
<CaptchaElement />
<Col
gap={6}
sx={{
Expand Down
15 changes: 14 additions & 1 deletion packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -65,6 +65,9 @@ function SignUpStartInternal(): JSX.Element {
);

const [missingRequirementsWithTicket, setMissingRequirementsWithTicket] = React.useState(false);
// When the captcha escalates to an interactive challenge, spotlight it by collapsing/inerting the
// rest of the card (see the descriptors.main column below).
const [captchaIsInteractive, setCaptchaIsInteractive] = React.useState(false);

const {
userSettings: { passwordSettings, usernameSettings },
Expand DownExpand Up@@ -428,6 +431,13 @@ function SignUpStartInternal(): JSX.Element {
direction='col'
elementDescriptor={descriptors.main}
gap={6}
// @ts-ignore - `inert` is not yet in the installed React types
inert={captchaIsInteractive ? '' : undefined}
// `display:none` (not `visibility:hidden`) so the collapsed column leaves flex flow and
// contributes no `gap` gutter to `Card.Content` — otherwise it injects empty space above
// the spotlighted captcha. Subtree stays mounted (form state preserved); `inert` is then
// redundant-but-harmless.
sx={captchaIsInteractive ? { display: 'none' } : undefined}
>
<SocialButtonsReversibleContainerWithDivider>
{(showOauthProviders || showWeb3Providers || showAlternativePhoneCodeProviders) && (
Expand All@@ -450,8 +460,11 @@ function SignUpStartInternal(): JSX.Element {
/>
)}
</SocialButtonsReversibleContainerWithDivider>
{!shouldShowForm && <CaptchaElement />}
</Flex>
<CaptchaElement
gapless
onInteractiveChange={setCaptchaIsInteractive}
/>
</Card.Content>

<Card.Footer>
Expand Down
66 changes: 60 additions & 6 deletions packages/clerk-js/src/ui/elements/CaptchaElement.tsx
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,41 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';

import { CAPTCHA_ELEMENT_ID } from '../../utils/captcha/constants';
import { Box, useAppearance, useLocalizations } from '../customizables';

/**
* This component uses a MutationObserver to listen for DOM changes made by our Turnstile logic,
* which operates outside the React lifecycle. It stores the observed state in ref to ensure that
* which operates outside the React lifecycle. It stores the observed state in refs to ensure that
* any external style changes, such as updates to max-height, min-height, or margin-bottom persist across re-renders,
* preventing unwanted layout resets.
*
* When Turnstile escalates to an interactive "Verify you are human" challenge it sets
* `data-cl-interactive="true"` on the element (removed on resolve/error). `onInteractiveChange`
* surfaces that signal so a parent can react (e.g. spotlight the challenge); it never fires on mount.
*/
export const CaptchaElement = () => {
const elementRef = useRef(null);
export const CaptchaElement = ({
onInteractiveChange,
gapless,
}: {
onInteractiveChange?: (interactive: boolean) => void;
/**
* When true, the element is removed from flow (`position:absolute`) while collapsed so it adds no
* gap gutter to a flex parent, switching to `position:static` while interactive. Opt-in so the
* other (non-spotlight) render sites keep their current positioning.
*/
gapless?: boolean;
}) => {
const elementRef = useRef<HTMLDivElement>(null);
const maxHeightValueRef = useRef('0');
const minHeightValueRef = useRef('unset');
const marginBottomValueRef = useRef('unset');
// State forces a re-render on the interactive transition, which re-applies the ref-held styles
// above (preserving Turnstile's injected values) and drives the `gapless` position toggle.
const [isInteractive, setIsInteractive] = useState(false);
// The observer is set up once (`[]` deps), so it reads the latest callback through a ref.
const onInteractiveChangeRef = useRef(onInteractiveChange);
onInteractiveChangeRef.current = onInteractiveChange;
const isInteractiveRef = useRef(false);
const { parsedCaptcha } = useAppearance();
const { locale } = useLocalizations();
const captchaTheme = parsedCaptcha?.theme;
Expand All@@ -30,17 +52,46 @@ export const CaptchaElement = () => {
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
const target = mutation.target as HTMLDivElement;
if (mutation.type === 'attributes' && mutation.attributeName === 'style' && elementRef.current) {
if (mutation.type !== 'attributes' || !elementRef.current) {
return;
}
if (mutation.attributeName === 'style') {
// Keep refs in sync so Turnstile's injected styles survive React re-renders.
maxHeightValueRef.current = target.style.maxHeight || '0';
minHeightValueRef.current = target.style.minHeight || 'unset';
marginBottomValueRef.current = target.style.marginBottom || 'unset';
// Fallback for old clerk-js that never writes data-cl-interactive: infer
// interactive state from maxHeight. When the MutationObserver callback fires,
// the DOM already reflects all mutations from the same microtask, so
// `target.dataset.clInteractive` is up-to-date — new clerk-js (which sets
// the attribute alongside the style) passes the guard and is handled below.
if (!('clInteractive' in target.dataset)) {
const mh = target.style.maxHeight;
const nowInteractive = mh !== '' && mh !== '0' && mh !== '0px';
if (nowInteractive !== isInteractiveRef.current) {
isInteractiveRef.current = nowInteractive;
setIsInteractive(nowInteractive);
onInteractiveChangeRef.current?.(nowInteractive);
}
}
}
if (mutation.attributeName === 'data-cl-interactive') {
// ORDERING IS LOAD-BEARING: style mutations from the same turnstile.ts call are
// delivered before this one (DOM mutations are batched and replayed in order), so
// the refs above are already up-to-date when the re-render triggered below runs.
const nowInteractive = target.dataset.clInteractive === 'true';
if (nowInteractive !== isInteractiveRef.current) {
isInteractiveRef.current = nowInteractive;
setIsInteractive(nowInteractive);
onInteractiveChangeRef.current?.(nowInteractive);
}
}
});
});

observer.observe(elementRef.current, {
attributes: true,
attributeFilter: ['style'],
attributeFilter: ['style', 'data-cl-interactive'],
});

return () => observer.disconnect();
Expand All@@ -56,6 +107,9 @@ export const CaptchaElement = () => {
maxHeight: maxHeightValueRef.current,
minHeight: minHeightValueRef.current,
marginBottom: marginBottomValueRef.current,
// When `gapless`, drop out of flow while collapsed so the element contributes no gap gutter
// to its flex parent; rejoin flow once the interactive challenge expands it.
position: gapless ? (isInteractive ? 'static' : 'absolute') : undefined,
}}
data-cl-theme={captchaTheme}
data-cl-size={captchaSize}
Expand Down
2 changes: 2 additions & 0 deletions packages/clerk-js/src/utils/captcha/turnstile.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -194,6 +194,7 @@ export const getTurnstileToken = async (opts: CaptchaOptions) => {
// and then expands to the correct height
visibleWidget.style.minHeight = captchaSize === 'compact' ? '140px' : '68px';
visibleWidget.style.marginBottom = '1.5rem';
visibleWidget.dataset.clInteractive = 'true';
}
}
},
Expand DownExpand Up@@ -291,6 +292,7 @@ export const getTurnstileToken = async (opts: CaptchaOptions) => {
if (captchaTypeUsed === 'smart') {
const visibleWidget = document.getElementById(CAPTCHA_ELEMENT_ID);
if (visibleWidget) {
delete visibleWidget.dataset.clInteractive;
visibleWidget.style.maxHeight = '0';
visibleWidget.style.minHeight = 'unset';
visibleWidget.style.marginBottom = 'unset';
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/captcha-interactive-spotlight.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': minor
---

Spotlight interactive bot-protection (Cloudflare Turnstile) challenges during sign-in and sign-up. When a challenge escalates to an interactive "Verify you are human" check, the start card now brings it to the foreground — collapsing and `inert`-ing the rest of the form until the challenge is solved — while keeping the header, footer, and passkey action reachable. Invisible challenges are unaffected.
37 changes: 25 additions & 12 deletions packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -155,6 +155,9 @@ function SignInStartInternal(): JSX.Element {
const hasSocialOrWeb3Buttons =
!!authenticatableSocialStrategies.length || !!web3FirstFactors.length || !!alternativePhoneCodeChannels.length;
const [shouldAutofocus, setShouldAutofocus] = useState(!isMobileDevice() && !hasSocialOrWeb3Buttons);
// When the captcha escalates to an interactive challenge, spotlight it by collapsing/inerting the
// rest of the card (see the descriptors.main column below).
const [captchaIsInteractive, setCaptchaIsInteractive] = useState(false);
const textIdentifierField = useFormControl('identifier', initialValues[identifierAttribute] || '', {
...currentIdentifier,
isRequired: true,
Expand DownExpand Up@@ -574,6 +577,13 @@ function SignInStartInternal(): JSX.Element {
<Col
elementDescriptor={descriptors.main}
gap={6}
// @ts-ignore - `inert` is not yet in the installed React types
inert={captchaIsInteractive ? '' : undefined}
// `display:none` (not `visibility:hidden`) so the collapsed column leaves flex flow and
// contributes no `gap` gutter to `Card.Content` — otherwise it injects empty space above
// the spotlighted captcha. Subtree stays mounted (form state preserved); `inert` is then
// redundant-but-harmless.
sx={captchaIsInteractive ? { display: 'none' } : undefined}
>
<SocialButtonsReversibleContainerWithDivider>
{hasSocialOrWeb3Buttons && (
Expand DownExpand Up@@ -603,24 +613,27 @@ function SignInStartInternal(): JSX.Element {
<InstantPasswordRow field={passwordBasedInstance ? instantPasswordField : undefined} />
</Col>
<Col center>
<CaptchaElement />
<Form.SubmitButton hasArrow />
</Col>
</Form.Root>
) : null}
</SocialButtonsReversibleContainerWithDivider>
{!standardFormAttributes.length && <CaptchaElement />}
{userSettings.attributes.passkey?.enabled &&
userSettings.passkeySettings.show_sign_in_button &&
isWebSupported && (
<Card.Action elementId={'usePasskey'}>
<Card.ActionLink
localizationKey={localizationKeys('signIn.start.actionLink__use_passkey')}
onClick={() => authenticateWithPasskey({ flow: 'discoverable' })}
/>
</Card.Action>
)}
</Col>
<CaptchaElement
gapless
onInteractiveChange={setCaptchaIsInteractive}
/>
{/* Kept outside descriptors.main so the spotlight's `inert` leaves this alternative action reachable. */}
{userSettings.attributes.passkey?.enabled &&
userSettings.passkeySettings.show_sign_in_button &&
isWebSupported && (
<Card.Action elementId={'usePasskey'}>
<Card.ActionLink
localizationKey={localizationKeys('signIn.start.actionLink__use_passkey')}
onClick={() => authenticateWithPasskey({ flow: 'discoverable' })}
/>
</Card.Action>
)}
</Card.Content>
<Card.Footer>
{userSettings.signUp.mode === SIGN_UP_MODES.PUBLIC && !isCombinedFlow && (
Expand Down
2 changes: 0 additions & 2 deletions packages/clerk-js/src/ui/components/SignUp/SignUpForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,6 @@ import { LegalCheckbox } from '@/ui/elements/LegalConsentCheckbox';
import type { FormControlState } from '@/ui/utils/useFormControl';

import { Col, localizationKeys, useAppearance } from '../../customizables';
import { CaptchaElement } from '../../elements/CaptchaElement';
import { mqu } from '../../styledSystem';
import type { ActiveIdentifier, Fields } from './signUpFormHelpers';

Expand DownExpand Up@@ -115,7 +114,6 @@ export const SignUpForm = (props: SignUpFormProps) => {
</Col>
)}
<Col center>
<CaptchaElement />
<Col
gap={6}
sx={{
Expand Down
15 changes: 14 additions & 1 deletion packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -65,6 +65,9 @@ function SignUpStartInternal(): JSX.Element {
);

const [missingRequirementsWithTicket, setMissingRequirementsWithTicket] = React.useState(false);
// When the captcha escalates to an interactive challenge, spotlight it by collapsing/inerting the
// rest of the card (see the descriptors.main column below).
const [captchaIsInteractive, setCaptchaIsInteractive] = React.useState(false);

const {
userSettings: { passwordSettings, usernameSettings },
Expand DownExpand Up@@ -428,6 +431,13 @@ function SignUpStartInternal(): JSX.Element {
direction='col'
elementDescriptor={descriptors.main}
gap={6}
// @ts-ignore - `inert` is not yet in the installed React types
inert={captchaIsInteractive ? '' : undefined}
// `display:none` (not `visibility:hidden`) so the collapsed column leaves flex flow and
// contributes no `gap` gutter to `Card.Content` — otherwise it injects empty space above
// the spotlighted captcha. Subtree stays mounted (form state preserved); `inert` is then
// redundant-but-harmless.
sx={captchaIsInteractive ? { display: 'none' } : undefined}
>
<SocialButtonsReversibleContainerWithDivider>
{(showOauthProviders || showWeb3Providers || showAlternativePhoneCodeProviders) && (
Expand All@@ -450,8 +460,11 @@ function SignUpStartInternal(): JSX.Element {
/>
)}
</SocialButtonsReversibleContainerWithDivider>
{!shouldShowForm && <CaptchaElement />}
</Flex>
<CaptchaElement
gapless
onInteractiveChange={setCaptchaIsInteractive}
/>
</Card.Content>

<Card.Footer>
Expand Down
66 changes: 60 additions & 6 deletions packages/clerk-js/src/ui/elements/CaptchaElement.tsx
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,41 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';

import { CAPTCHA_ELEMENT_ID } from '../../utils/captcha/constants';
import { Box, useAppearance, useLocalizations } from '../customizables';

/**
* This component uses a MutationObserver to listen for DOM changes made by our Turnstile logic,
* which operates outside the React lifecycle. It stores the observed state in ref to ensure that
* which operates outside the React lifecycle. It stores the observed state in refs to ensure that
* any external style changes, such as updates to max-height, min-height, or margin-bottom persist across re-renders,
* preventing unwanted layout resets.
*
* When Turnstile escalates to an interactive "Verify you are human" challenge it sets
* `data-cl-interactive="true"` on the element (removed on resolve/error). `onInteractiveChange`
* surfaces that signal so a parent can react (e.g. spotlight the challenge); it never fires on mount.
*/
export const CaptchaElement = () => {
const elementRef = useRef(null);
export const CaptchaElement = ({
onInteractiveChange,
gapless,
}: {
onInteractiveChange?: (interactive: boolean) => void;
/**
* When true, the element is removed from flow (`position:absolute`) while collapsed so it adds no
* gap gutter to a flex parent, switching to `position:static` while interactive. Opt-in so the
* other (non-spotlight) render sites keep their current positioning.
*/
gapless?: boolean;
}) => {
const elementRef = useRef<HTMLDivElement>(null);
const maxHeightValueRef = useRef('0');
const minHeightValueRef = useRef('unset');
const marginBottomValueRef = useRef('unset');
// State forces a re-render on the interactive transition, which re-applies the ref-held styles
// above (preserving Turnstile's injected values) and drives the `gapless` position toggle.
const [isInteractive, setIsInteractive] = useState(false);
// The observer is set up once (`[]` deps), so it reads the latest callback through a ref.
const onInteractiveChangeRef = useRef(onInteractiveChange);
onInteractiveChangeRef.current = onInteractiveChange;
const isInteractiveRef = useRef(false);
const { parsedCaptcha } = useAppearance();
const { locale } = useLocalizations();
const captchaTheme = parsedCaptcha?.theme;
Expand All@@ -30,17 +52,46 @@ export const CaptchaElement = () => {
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
const target = mutation.target as HTMLDivElement;
if (mutation.type === 'attributes' && mutation.attributeName === 'style' && elementRef.current) {
if (mutation.type !== 'attributes' || !elementRef.current) {
return;
}
if (mutation.attributeName === 'style') {
// Keep refs in sync so Turnstile's injected styles survive React re-renders.
maxHeightValueRef.current = target.style.maxHeight || '0';
minHeightValueRef.current = target.style.minHeight || 'unset';
marginBottomValueRef.current = target.style.marginBottom || 'unset';
// Fallback for old clerk-js that never writes data-cl-interactive: infer
// interactive state from maxHeight. When the MutationObserver callback fires,
// the DOM already reflects all mutations from the same microtask, so
// `target.dataset.clInteractive` is up-to-date — new clerk-js (which sets
// the attribute alongside the style) passes the guard and is handled below.
if (!('clInteractive' in target.dataset)) {
const mh = target.style.maxHeight;
const nowInteractive = mh !== '' && mh !== '0' && mh !== '0px';
if (nowInteractive !== isInteractiveRef.current) {
isInteractiveRef.current = nowInteractive;
setIsInteractive(nowInteractive);
onInteractiveChangeRef.current?.(nowInteractive);
}
}
}
if (mutation.attributeName === 'data-cl-interactive') {
// ORDERING IS LOAD-BEARING: style mutations from the same turnstile.ts call are
// delivered before this one (DOM mutations are batched and replayed in order), so
// the refs above are already up-to-date when the re-render triggered below runs.
const nowInteractive = target.dataset.clInteractive === 'true';
if (nowInteractive !== isInteractiveRef.current) {
isInteractiveRef.current = nowInteractive;
setIsInteractive(nowInteractive);
onInteractiveChangeRef.current?.(nowInteractive);
}
}
});
});

observer.observe(elementRef.current, {
attributes: true,
attributeFilter: ['style'],
attributeFilter: ['style', 'data-cl-interactive'],
});

return () => observer.disconnect();
Expand All@@ -56,6 +107,9 @@ export const CaptchaElement = () => {
maxHeight: maxHeightValueRef.current,
minHeight: minHeightValueRef.current,
marginBottom: marginBottomValueRef.current,
// When `gapless`, drop out of flow while collapsed so the element contributes no gap gutter
// to its flex parent; rejoin flow once the interactive challenge expands it.
position: gapless ? (isInteractive ? 'static' : 'absolute') : undefined,
}}
data-cl-theme={captchaTheme}
data-cl-size={captchaSize}
Expand Down
2 changes: 2 additions & 0 deletions packages/clerk-js/src/utils/captcha/turnstile.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -194,6 +194,7 @@ export const getTurnstileToken = async (opts: CaptchaOptions) => {
// and then expands to the correct height
visibleWidget.style.minHeight = captchaSize === 'compact' ? '140px' : '68px';
visibleWidget.style.marginBottom = '1.5rem';
visibleWidget.dataset.clInteractive = 'true';
}
}
},
Expand DownExpand Up@@ -291,6 +292,7 @@ export const getTurnstileToken = async (opts: CaptchaOptions) => {
if (captchaTypeUsed === 'smart') {
const visibleWidget = document.getElementById(CAPTCHA_ELEMENT_ID);
if (visibleWidget) {
delete visibleWidget.dataset.clInteractive;
visibleWidget.style.maxHeight = '0';
visibleWidget.style.minHeight = 'unset';
visibleWidget.style.marginBottom = 'unset';
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/captcha-interactive-spotlight.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': minor
---

Spotlight interactive bot-protection (Cloudflare Turnstile) challenges during sign-in and sign-up. When a challenge escalates to an interactive "Verify you are human" check, the start card now brings it to the foreground — collapsing and `inert`-ing the rest of the form until the challenge is solved — while keeping the header, footer, and passkey action reachable. Invisible challenges are unaffected.
37 changes: 25 additions & 12 deletions packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -155,6 +155,9 @@ function SignInStartInternal(): JSX.Element {
const hasSocialOrWeb3Buttons =
!!authenticatableSocialStrategies.length || !!web3FirstFactors.length || !!alternativePhoneCodeChannels.length;
const [shouldAutofocus, setShouldAutofocus] = useState(!isMobileDevice() && !hasSocialOrWeb3Buttons);
// When the captcha escalates to an interactive challenge, spotlight it by collapsing/inerting the
// rest of the card (see the descriptors.main column below).
const [captchaIsInteractive, setCaptchaIsInteractive] = useState(false);
const textIdentifierField = useFormControl('identifier', initialValues[identifierAttribute] || '', {
...currentIdentifier,
isRequired: true,
Expand DownExpand Up@@ -574,6 +577,13 @@ function SignInStartInternal(): JSX.Element {
<Col
elementDescriptor={descriptors.main}
gap={6}
// @ts-ignore - `inert` is not yet in the installed React types
inert={captchaIsInteractive ? '' : undefined}
// `display:none` (not `visibility:hidden`) so the collapsed column leaves flex flow and
// contributes no `gap` gutter to `Card.Content` — otherwise it injects empty space above
// the spotlighted captcha. Subtree stays mounted (form state preserved); `inert` is then
// redundant-but-harmless.
sx={captchaIsInteractive ? { display: 'none' } : undefined}
>
<SocialButtonsReversibleContainerWithDivider>
{hasSocialOrWeb3Buttons && (
Expand DownExpand Up@@ -603,24 +613,27 @@ function SignInStartInternal(): JSX.Element {
<InstantPasswordRow field={passwordBasedInstance ? instantPasswordField : undefined} />
</Col>
<Col center>
<CaptchaElement />
<Form.SubmitButton hasArrow />
</Col>
</Form.Root>
) : null}
</SocialButtonsReversibleContainerWithDivider>
{!standardFormAttributes.length && <CaptchaElement />}
{userSettings.attributes.passkey?.enabled &&
userSettings.passkeySettings.show_sign_in_button &&
isWebSupported && (
<Card.Action elementId={'usePasskey'}>
<Card.ActionLink
localizationKey={localizationKeys('signIn.start.actionLink__use_passkey')}
onClick={() => authenticateWithPasskey({ flow: 'discoverable' })}
/>
</Card.Action>
)}
</Col>
<CaptchaElement
gapless
onInteractiveChange={setCaptchaIsInteractive}
/>
{/* Kept outside descriptors.main so the spotlight's `inert` leaves this alternative action reachable. */}
{userSettings.attributes.passkey?.enabled &&
userSettings.passkeySettings.show_sign_in_button &&
isWebSupported && (
<Card.Action elementId={'usePasskey'}>
<Card.ActionLink
localizationKey={localizationKeys('signIn.start.actionLink__use_passkey')}
onClick={() => authenticateWithPasskey({ flow: 'discoverable' })}
/>
</Card.Action>
)}
</Card.Content>
<Card.Footer>
{userSettings.signUp.mode === SIGN_UP_MODES.PUBLIC && !isCombinedFlow && (
Expand Down
2 changes: 0 additions & 2 deletions packages/clerk-js/src/ui/components/SignUp/SignUpForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,6 @@ import { LegalCheckbox } from '@/ui/elements/LegalConsentCheckbox';
import type { FormControlState } from '@/ui/utils/useFormControl';

import { Col, localizationKeys, useAppearance } from '../../customizables';
import { CaptchaElement } from '../../elements/CaptchaElement';
import { mqu } from '../../styledSystem';
import type { ActiveIdentifier, Fields } from './signUpFormHelpers';

Expand DownExpand Up@@ -115,7 +114,6 @@ export const SignUpForm = (props: SignUpFormProps) => {
</Col>
)}
<Col center>
<CaptchaElement />
<Col
gap={6}
sx={{
Expand Down
15 changes: 14 additions & 1 deletion packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -65,6 +65,9 @@ function SignUpStartInternal(): JSX.Element {
);

const [missingRequirementsWithTicket, setMissingRequirementsWithTicket] = React.useState(false);
// When the captcha escalates to an interactive challenge, spotlight it by collapsing/inerting the
// rest of the card (see the descriptors.main column below).
const [captchaIsInteractive, setCaptchaIsInteractive] = React.useState(false);

const {
userSettings: { passwordSettings, usernameSettings },
Expand DownExpand Up@@ -428,6 +431,13 @@ function SignUpStartInternal(): JSX.Element {
direction='col'
elementDescriptor={descriptors.main}
gap={6}
// @ts-ignore - `inert` is not yet in the installed React types
inert={captchaIsInteractive ? '' : undefined}
// `display:none` (not `visibility:hidden`) so the collapsed column leaves flex flow and
// contributes no `gap` gutter to `Card.Content` — otherwise it injects empty space above
// the spotlighted captcha. Subtree stays mounted (form state preserved); `inert` is then
// redundant-but-harmless.
sx={captchaIsInteractive ? { display: 'none' } : undefined}
>
<SocialButtonsReversibleContainerWithDivider>
{(showOauthProviders || showWeb3Providers || showAlternativePhoneCodeProviders) && (
Expand All@@ -450,8 +460,11 @@ function SignUpStartInternal(): JSX.Element {
/>
)}
</SocialButtonsReversibleContainerWithDivider>
{!shouldShowForm && <CaptchaElement />}
</Flex>
<CaptchaElement
gapless
onInteractiveChange={setCaptchaIsInteractive}
/>
</Card.Content>

<Card.Footer>
Expand Down
66 changes: 60 additions & 6 deletions packages/clerk-js/src/ui/elements/CaptchaElement.tsx
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,41 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';

import { CAPTCHA_ELEMENT_ID } from '../../utils/captcha/constants';
import { Box, useAppearance, useLocalizations } from '../customizables';

/**
* This component uses a MutationObserver to listen for DOM changes made by our Turnstile logic,
* which operates outside the React lifecycle. It stores the observed state in ref to ensure that
* which operates outside the React lifecycle. It stores the observed state in refs to ensure that
* any external style changes, such as updates to max-height, min-height, or margin-bottom persist across re-renders,
* preventing unwanted layout resets.
*
* When Turnstile escalates to an interactive "Verify you are human" challenge it sets
* `data-cl-interactive="true"` on the element (removed on resolve/error). `onInteractiveChange`
* surfaces that signal so a parent can react (e.g. spotlight the challenge); it never fires on mount.
*/
export const CaptchaElement = () => {
const elementRef = useRef(null);
export const CaptchaElement = ({
onInteractiveChange,
gapless,
}: {
onInteractiveChange?: (interactive: boolean) => void;
/**
* When true, the element is removed from flow (`position:absolute`) while collapsed so it adds no
* gap gutter to a flex parent, switching to `position:static` while interactive. Opt-in so the
* other (non-spotlight) render sites keep their current positioning.
*/
gapless?: boolean;
}) => {
const elementRef = useRef<HTMLDivElement>(null);
const maxHeightValueRef = useRef('0');
const minHeightValueRef = useRef('unset');
const marginBottomValueRef = useRef('unset');
// State forces a re-render on the interactive transition, which re-applies the ref-held styles
// above (preserving Turnstile's injected values) and drives the `gapless` position toggle.
const [isInteractive, setIsInteractive] = useState(false);
// The observer is set up once (`[]` deps), so it reads the latest callback through a ref.
const onInteractiveChangeRef = useRef(onInteractiveChange);
onInteractiveChangeRef.current = onInteractiveChange;
const isInteractiveRef = useRef(false);
const { parsedCaptcha } = useAppearance();
const { locale } = useLocalizations();
const captchaTheme = parsedCaptcha?.theme;
Expand All@@ -30,17 +52,46 @@ export const CaptchaElement = () => {
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
const target = mutation.target as HTMLDivElement;
if (mutation.type === 'attributes' && mutation.attributeName === 'style' && elementRef.current) {
if (mutation.type !== 'attributes' || !elementRef.current) {
return;
}
if (mutation.attributeName === 'style') {
// Keep refs in sync so Turnstile's injected styles survive React re-renders.
maxHeightValueRef.current = target.style.maxHeight || '0';
minHeightValueRef.current = target.style.minHeight || 'unset';
marginBottomValueRef.current = target.style.marginBottom || 'unset';
// Fallback for old clerk-js that never writes data-cl-interactive: infer
// interactive state from maxHeight. When the MutationObserver callback fires,
// the DOM already reflects all mutations from the same microtask, so
// `target.dataset.clInteractive` is up-to-date — new clerk-js (which sets
// the attribute alongside the style) passes the guard and is handled below.
if (!('clInteractive' in target.dataset)) {
const mh = target.style.maxHeight;
const nowInteractive = mh !== '' && mh !== '0' && mh !== '0px';
if (nowInteractive !== isInteractiveRef.current) {
isInteractiveRef.current = nowInteractive;
setIsInteractive(nowInteractive);
onInteractiveChangeRef.current?.(nowInteractive);
}
}
}
if (mutation.attributeName === 'data-cl-interactive') {
// ORDERING IS LOAD-BEARING: style mutations from the same turnstile.ts call are
// delivered before this one (DOM mutations are batched and replayed in order), so
// the refs above are already up-to-date when the re-render triggered below runs.
const nowInteractive = target.dataset.clInteractive === 'true';
if (nowInteractive !== isInteractiveRef.current) {
isInteractiveRef.current = nowInteractive;
setIsInteractive(nowInteractive);
onInteractiveChangeRef.current?.(nowInteractive);
}
}
});
});

observer.observe(elementRef.current, {
attributes: true,
attributeFilter: ['style'],
attributeFilter: ['style', 'data-cl-interactive'],
});

return () => observer.disconnect();
Expand All@@ -56,6 +107,9 @@ export const CaptchaElement = () => {
maxHeight: maxHeightValueRef.current,
minHeight: minHeightValueRef.current,
marginBottom: marginBottomValueRef.current,
// When `gapless`, drop out of flow while collapsed so the element contributes no gap gutter
// to its flex parent; rejoin flow once the interactive challenge expands it.
position: gapless ? (isInteractive ? 'static' : 'absolute') : undefined,
}}
data-cl-theme={captchaTheme}
data-cl-size={captchaSize}
Expand Down
2 changes: 2 additions & 0 deletions packages/clerk-js/src/utils/captcha/turnstile.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -194,6 +194,7 @@ export const getTurnstileToken = async (opts: CaptchaOptions) => {
// and then expands to the correct height
visibleWidget.style.minHeight = captchaSize === 'compact' ? '140px' : '68px';
visibleWidget.style.marginBottom = '1.5rem';
visibleWidget.dataset.clInteractive = 'true';
}
}
},
Expand DownExpand Up@@ -291,6 +292,7 @@ export const getTurnstileToken = async (opts: CaptchaOptions) => {
if (captchaTypeUsed === 'smart') {
const visibleWidget = document.getElementById(CAPTCHA_ELEMENT_ID);
if (visibleWidget) {
delete visibleWidget.dataset.clInteractive;
visibleWidget.style.maxHeight = '0';
visibleWidget.style.minHeight = 'unset';
visibleWidget.style.marginBottom = 'unset';
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/captcha-interactive-spotlight.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': minor
---

Spotlight interactive bot-protection (Cloudflare Turnstile) challenges during sign-in and sign-up. When a challenge escalates to an interactive "Verify you are human" check, the start card now brings it to the foreground — collapsing and `inert`-ing the rest of the form until the challenge is solved — while keeping the header, footer, and passkey action reachable. Invisible challenges are unaffected.
37 changes: 25 additions & 12 deletions packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -155,6 +155,9 @@ function SignInStartInternal(): JSX.Element {
const hasSocialOrWeb3Buttons =
!!authenticatableSocialStrategies.length || !!web3FirstFactors.length || !!alternativePhoneCodeChannels.length;
const [shouldAutofocus, setShouldAutofocus] = useState(!isMobileDevice() && !hasSocialOrWeb3Buttons);
// When the captcha escalates to an interactive challenge, spotlight it by collapsing/inerting the
// rest of the card (see the descriptors.main column below).
const [captchaIsInteractive, setCaptchaIsInteractive] = useState(false);
const textIdentifierField = useFormControl('identifier', initialValues[identifierAttribute] || '', {
...currentIdentifier,
isRequired: true,
Expand DownExpand Up@@ -574,6 +577,13 @@ function SignInStartInternal(): JSX.Element {
<Col
elementDescriptor={descriptors.main}
gap={6}
// @ts-ignore - `inert` is not yet in the installed React types
inert={captchaIsInteractive ? '' : undefined}
// `display:none` (not `visibility:hidden`) so the collapsed column leaves flex flow and
// contributes no `gap` gutter to `Card.Content` — otherwise it injects empty space above
// the spotlighted captcha. Subtree stays mounted (form state preserved); `inert` is then
// redundant-but-harmless.
sx={captchaIsInteractive ? { display: 'none' } : undefined}
>
<SocialButtonsReversibleContainerWithDivider>
{hasSocialOrWeb3Buttons && (
Expand DownExpand Up@@ -603,24 +613,27 @@ function SignInStartInternal(): JSX.Element {
<InstantPasswordRow field={passwordBasedInstance ? instantPasswordField : undefined} />
</Col>
<Col center>
<CaptchaElement />
<Form.SubmitButton hasArrow />
</Col>
</Form.Root>
) : null}
</SocialButtonsReversibleContainerWithDivider>
{!standardFormAttributes.length && <CaptchaElement />}
{userSettings.attributes.passkey?.enabled &&
userSettings.passkeySettings.show_sign_in_button &&
isWebSupported && (
<Card.Action elementId={'usePasskey'}>
<Card.ActionLink
localizationKey={localizationKeys('signIn.start.actionLink__use_passkey')}
onClick={() => authenticateWithPasskey({ flow: 'discoverable' })}
/>
</Card.Action>
)}
</Col>
<CaptchaElement
gapless
onInteractiveChange={setCaptchaIsInteractive}
/>
{/* Kept outside descriptors.main so the spotlight's `inert` leaves this alternative action reachable. */}
{userSettings.attributes.passkey?.enabled &&
userSettings.passkeySettings.show_sign_in_button &&
isWebSupported && (
<Card.Action elementId={'usePasskey'}>
<Card.ActionLink
localizationKey={localizationKeys('signIn.start.actionLink__use_passkey')}
onClick={() => authenticateWithPasskey({ flow: 'discoverable' })}
/>
</Card.Action>
)}
</Card.Content>
<Card.Footer>
{userSettings.signUp.mode === SIGN_UP_MODES.PUBLIC && !isCombinedFlow && (
Expand Down
2 changes: 0 additions & 2 deletions packages/clerk-js/src/ui/components/SignUp/SignUpForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,6 @@ import { LegalCheckbox } from '@/ui/elements/LegalConsentCheckbox';
import type { FormControlState } from '@/ui/utils/useFormControl';

import { Col, localizationKeys, useAppearance } from '../../customizables';
import { CaptchaElement } from '../../elements/CaptchaElement';
import { mqu } from '../../styledSystem';
import type { ActiveIdentifier, Fields } from './signUpFormHelpers';

Expand DownExpand Up@@ -115,7 +114,6 @@ export const SignUpForm = (props: SignUpFormProps) => {
</Col>
)}
<Col center>
<CaptchaElement />
<Col
gap={6}
sx={{
Expand Down
15 changes: 14 additions & 1 deletion packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -65,6 +65,9 @@ function SignUpStartInternal(): JSX.Element {
);

const [missingRequirementsWithTicket, setMissingRequirementsWithTicket] = React.useState(false);
// When the captcha escalates to an interactive challenge, spotlight it by collapsing/inerting the
// rest of the card (see the descriptors.main column below).
const [captchaIsInteractive, setCaptchaIsInteractive] = React.useState(false);

const {
userSettings: { passwordSettings, usernameSettings },
Expand DownExpand Up@@ -428,6 +431,13 @@ function SignUpStartInternal(): JSX.Element {
direction='col'
elementDescriptor={descriptors.main}
gap={6}
// @ts-ignore - `inert` is not yet in the installed React types
inert={captchaIsInteractive ? '' : undefined}
// `display:none` (not `visibility:hidden`) so the collapsed column leaves flex flow and
// contributes no `gap` gutter to `Card.Content` — otherwise it injects empty space above
// the spotlighted captcha. Subtree stays mounted (form state preserved); `inert` is then
// redundant-but-harmless.
sx={captchaIsInteractive ? { display: 'none' } : undefined}
>
<SocialButtonsReversibleContainerWithDivider>
{(showOauthProviders || showWeb3Providers || showAlternativePhoneCodeProviders) && (
Expand All@@ -450,8 +460,11 @@ function SignUpStartInternal(): JSX.Element {
/>
)}
</SocialButtonsReversibleContainerWithDivider>
{!shouldShowForm && <CaptchaElement />}
</Flex>
<CaptchaElement
gapless
onInteractiveChange={setCaptchaIsInteractive}
/>
</Card.Content>

<Card.Footer>
Expand Down
66 changes: 60 additions & 6 deletions packages/clerk-js/src/ui/elements/CaptchaElement.tsx
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,41 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';

import { CAPTCHA_ELEMENT_ID } from '../../utils/captcha/constants';
import { Box, useAppearance, useLocalizations } from '../customizables';

/**
* This component uses a MutationObserver to listen for DOM changes made by our Turnstile logic,
* which operates outside the React lifecycle. It stores the observed state in ref to ensure that
* which operates outside the React lifecycle. It stores the observed state in refs to ensure that
* any external style changes, such as updates to max-height, min-height, or margin-bottom persist across re-renders,
* preventing unwanted layout resets.
*
* When Turnstile escalates to an interactive "Verify you are human" challenge it sets
* `data-cl-interactive="true"` on the element (removed on resolve/error). `onInteractiveChange`
* surfaces that signal so a parent can react (e.g. spotlight the challenge); it never fires on mount.
*/
export const CaptchaElement = () => {
const elementRef = useRef(null);
export const CaptchaElement = ({
onInteractiveChange,
gapless,
}: {
onInteractiveChange?: (interactive: boolean) => void;
/**
* When true, the element is removed from flow (`position:absolute`) while collapsed so it adds no
* gap gutter to a flex parent, switching to `position:static` while interactive. Opt-in so the
* other (non-spotlight) render sites keep their current positioning.
*/
gapless?: boolean;
}) => {
const elementRef = useRef<HTMLDivElement>(null);
const maxHeightValueRef = useRef('0');
const minHeightValueRef = useRef('unset');
const marginBottomValueRef = useRef('unset');
// State forces a re-render on the interactive transition, which re-applies the ref-held styles
// above (preserving Turnstile's injected values) and drives the `gapless` position toggle.
const [isInteractive, setIsInteractive] = useState(false);
// The observer is set up once (`[]` deps), so it reads the latest callback through a ref.
const onInteractiveChangeRef = useRef(onInteractiveChange);
onInteractiveChangeRef.current = onInteractiveChange;
const isInteractiveRef = useRef(false);
const { parsedCaptcha } = useAppearance();
const { locale } = useLocalizations();
const captchaTheme = parsedCaptcha?.theme;
Expand All@@ -30,17 +52,46 @@ export const CaptchaElement = () => {
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
const target = mutation.target as HTMLDivElement;
if (mutation.type === 'attributes' && mutation.attributeName === 'style' && elementRef.current) {
if (mutation.type !== 'attributes' || !elementRef.current) {
return;
}
if (mutation.attributeName === 'style') {
// Keep refs in sync so Turnstile's injected styles survive React re-renders.
maxHeightValueRef.current = target.style.maxHeight || '0';
minHeightValueRef.current = target.style.minHeight || 'unset';
marginBottomValueRef.current = target.style.marginBottom || 'unset';
// Fallback for old clerk-js that never writes data-cl-interactive: infer
// interactive state from maxHeight. When the MutationObserver callback fires,
// the DOM already reflects all mutations from the same microtask, so
// `target.dataset.clInteractive` is up-to-date — new clerk-js (which sets
// the attribute alongside the style) passes the guard and is handled below.
if (!('clInteractive' in target.dataset)) {
const mh = target.style.maxHeight;
const nowInteractive = mh !== '' && mh !== '0' && mh !== '0px';
if (nowInteractive !== isInteractiveRef.current) {
isInteractiveRef.current = nowInteractive;
setIsInteractive(nowInteractive);
onInteractiveChangeRef.current?.(nowInteractive);
}
}
}
if (mutation.attributeName === 'data-cl-interactive') {
// ORDERING IS LOAD-BEARING: style mutations from the same turnstile.ts call are
// delivered before this one (DOM mutations are batched and replayed in order), so
// the refs above are already up-to-date when the re-render triggered below runs.
const nowInteractive = target.dataset.clInteractive === 'true';
if (nowInteractive !== isInteractiveRef.current) {
isInteractiveRef.current = nowInteractive;
setIsInteractive(nowInteractive);
onInteractiveChangeRef.current?.(nowInteractive);
}
}
});
});

observer.observe(elementRef.current, {
attributes: true,
attributeFilter: ['style'],
attributeFilter: ['style', 'data-cl-interactive'],
});

return () => observer.disconnect();
Expand All@@ -56,6 +107,9 @@ export const CaptchaElement = () => {
maxHeight: maxHeightValueRef.current,
minHeight: minHeightValueRef.current,
marginBottom: marginBottomValueRef.current,
// When `gapless`, drop out of flow while collapsed so the element contributes no gap gutter
// to its flex parent; rejoin flow once the interactive challenge expands it.
position: gapless ? (isInteractive ? 'static' : 'absolute') : undefined,
}}
data-cl-theme={captchaTheme}
data-cl-size={captchaSize}
Expand Down
2 changes: 2 additions & 0 deletions packages/clerk-js/src/utils/captcha/turnstile.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -194,6 +194,7 @@ export const getTurnstileToken = async (opts: CaptchaOptions) => {
// and then expands to the correct height
visibleWidget.style.minHeight = captchaSize === 'compact' ? '140px' : '68px';
visibleWidget.style.marginBottom = '1.5rem';
visibleWidget.dataset.clInteractive = 'true';
}
}
},
Expand DownExpand Up@@ -291,6 +292,7 @@ export const getTurnstileToken = async (opts: CaptchaOptions) => {
if (captchaTypeUsed === 'smart') {
const visibleWidget = document.getElementById(CAPTCHA_ELEMENT_ID);
if (visibleWidget) {
delete visibleWidget.dataset.clInteractive;
visibleWidget.style.maxHeight = '0';
visibleWidget.style.minHeight = 'unset';
visibleWidget.style.marginBottom = 'unset';
Expand Down
Loading