diff --git a/.changeset/fast-kiwis-visit.md b/.changeset/fast-kiwis-visit.md
new file mode 100644
index 00000000000..a09c3e74212
--- /dev/null
+++ b/.changeset/fast-kiwis-visit.md
@@ -0,0 +1,5 @@
+---
+'@clerk/clerk-js': patch
+---
+
+Fix captcha layout shift on transfer flow, custom flows and invisible
diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUpForm.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUpForm.tsx
index 6cb45a6426b..2bd85057e71 100644
--- a/packages/clerk-js/src/ui/components/SignUp/SignUpForm.tsx
+++ b/packages/clerk-js/src/ui/components/SignUp/SignUpForm.tsx
@@ -113,7 +113,7 @@ export const SignUpForm = (props: SignUpFormProps) => {
)}
-
+
)}
- {!shouldShowForm && }
+ {!shouldShowForm && }
diff --git a/packages/clerk-js/src/ui/elements/CaptchaElement.tsx b/packages/clerk-js/src/ui/elements/CaptchaElement.tsx
index 26edf2c5a8c..7a5a5408f20 100644
--- a/packages/clerk-js/src/ui/elements/CaptchaElement.tsx
+++ b/packages/clerk-js/src/ui/elements/CaptchaElement.tsx
@@ -1,13 +1,53 @@
+import { useEffect, useRef } from 'react';
+
import { CAPTCHA_ELEMENT_ID } from '../../utils/captcha';
import { Box } from '../customizables';
-type CaptchaElementProps = {
- maxHeight?: string;
-};
+/**
+ * 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
+ * any external style changes, such as updates to max-height, min-height, or margin-bottom persist across re-renders,
+ * preventing unwanted layout resets.
+ */
+export const CaptchaElement = () => {
+ const elementRef = useRef(null);
+ const maxHeightValueRef = useRef('0');
+ const minHeightValueRef = useRef('unset');
+ const marginBottomValueRef = useRef('unset');
+
+ useEffect(() => {
+ if (!elementRef.current) return;
+
+ const observer = new MutationObserver(mutations => {
+ mutations.forEach(mutation => {
+ const target = mutation.target as HTMLDivElement;
+ if (mutation.type === 'attributes' && mutation.attributeName === 'style' && elementRef.current) {
+ maxHeightValueRef.current = target.style.maxHeight || '0';
+ minHeightValueRef.current = target.style.minHeight || 'unset';
+ marginBottomValueRef.current = target.style.marginBottom || 'unset';
+ }
+ });
+ });
-export const CaptchaElement = ({ maxHeight }: CaptchaElementProps) => (
-
-);
+ observer.observe(elementRef.current, {
+ attributes: true,
+ attributeFilter: ['style'],
+ });
+
+ return () => observer.disconnect();
+ }, []);
+
+ return (
+
+ );
+};
diff --git a/packages/clerk-js/src/utils/captcha/turnstile.ts b/packages/clerk-js/src/utils/captcha/turnstile.ts
index b160a8e9bf9..1d2724cb07d 100644
--- a/packages/clerk-js/src/utils/captcha/turnstile.ts
+++ b/packages/clerk-js/src/utils/captcha/turnstile.ts
@@ -141,6 +141,7 @@ export const getTurnstileToken = async (opts: CaptchaOptions) => {
if (visibleDiv) {
captchaWidgetType = 'smart';
widgetContainerQuerySelector = `#${CAPTCHA_ELEMENT_ID}`;
+ visibleDiv.style.maxHeight = '0'; // This is to prevent the layout shift when the render method is called
} else {
console.error(
'Cannot initialize Smart CAPTCHA widget because the `clerk-captcha` DOM element was not found; falling back to Invisible CAPTCHA widget. If you are using a custom flow, visit https://clerk.com/docs/custom-flows/bot-sign-up-protection for instructions',
@@ -155,6 +156,7 @@ export const getTurnstileToken = async (opts: CaptchaOptions) => {
widgetContainerQuerySelector = `.${CAPTCHA_INVISIBLE_CLASSNAME}`;
const div = document.createElement('div');
div.classList.add(CAPTCHA_INVISIBLE_CLASSNAME);
+ div.style.maxHeight = '0'; // This is to prevent the layout shift when the render method is called
document.body.appendChild(div);
}
@@ -178,8 +180,12 @@ export const getTurnstileToken = async (opts: CaptchaOptions) => {
} else {
const visibleWidget = document.getElementById(CAPTCHA_ELEMENT_ID);
if (visibleWidget) {
+ // We unset the max-height to allow the widget to expand
visibleWidget.style.maxHeight = 'unset';
- visibleWidget.style.minHeight = '68px'; // this is the height of the Turnstile widget
+ // We set the min-height to the height of the Turnstile widget
+ // because the widget initially does a small layout shift
+ // and then expands to the correct height
+ visibleWidget.style.minHeight = '68px';
visibleWidget.style.marginBottom = '1.5rem';
}
}