diff --git a/.changeset/mosaic-flow-autofocus.md b/.changeset/mosaic-flow-autofocus.md
new file mode 100644
index 00000000000..02fe3519450
--- /dev/null
+++ b/.changeset/mosaic-flow-autofocus.md
@@ -0,0 +1,5 @@
+---
+'@clerk/ui': patch
+---
+
+Reverification now moves focus to the entering step's primary control after the step transition completes, so the next input or action is ready for keyboard and screen reader users without disrupting the slide animation.
diff --git a/packages/headless/src/primitives/flow/README.md b/packages/headless/src/primitives/flow/README.md
index d445061ba94..fe02bcdf529 100644
--- a/packages/headless/src/primitives/flow/README.md
+++ b/packages/headless/src/primitives/flow/README.md
@@ -48,6 +48,23 @@ Multiple ids can select the same step. Moving between those ids updates the exis
`Flow.Step` also accepts standard `
` attributes and the package's `render` prop.
+## Focus
+
+`useFlowAutoFocus()` returns a ref. Attach it to the element a step should focus once its enter transition settles:
+
+```tsx
+function PasswordView() {
+ return (
+
+ );
+}
+```
+
+Focus moves only for a step that transitions in; the initially active step is left to whatever container opened it. Focus is applied with `preventScroll` after the step's animations finish, and only when focus is currently on the body or inside `Flow.Root`, so it never steals from elsewhere on the page. When several mounted elements are marked, the first in DOM order is focused, and an element that unmounts before the step settles is skipped. A step that closes before it settles drops its pending focus. Outside a `Flow.Step` the hook returns a no-op ref.
+
## Transition attributes
| Attribute | Description |
diff --git a/packages/headless/src/primitives/flow/flow-context.ts b/packages/headless/src/primitives/flow/flow-context.ts
index 016f0421696..e01ce527be8 100644
--- a/packages/headless/src/primitives/flow/flow-context.ts
+++ b/packages/headless/src/primitives/flow/flow-context.ts
@@ -1,10 +1,11 @@
-import { createContext, useContext } from 'react';
+import { createContext, type RefObject, useContext } from 'react';
export type FlowDirection = -1 | 1;
export interface FlowContextValue {
value: string;
direction: FlowDirection;
+ rootRef: RefObject;
registerActiveStep: (element: HTMLElement) => void;
unregisterActiveStep: (element: HTMLElement) => void;
}
diff --git a/packages/headless/src/primitives/flow/flow-root.tsx b/packages/headless/src/primitives/flow/flow-root.tsx
index 40cf3cace92..5c6c6e78224 100644
--- a/packages/headless/src/primitives/flow/flow-root.tsx
+++ b/packages/headless/src/primitives/flow/flow-root.tsx
@@ -55,7 +55,7 @@ export const FlowRoot = React.forwardRef(function
}, [activeStepHeight, initial]);
const contextValue = useMemo(
- () => ({ value, direction, registerActiveStep, unregisterActiveStep }),
+ () => ({ value, direction, rootRef, registerActiveStep, unregisterActiveStep }),
[value, direction, registerActiveStep, unregisterActiveStep],
);
diff --git a/packages/headless/src/primitives/flow/flow-step-context.ts b/packages/headless/src/primitives/flow/flow-step-context.ts
new file mode 100644
index 00000000000..33e794cd1db
--- /dev/null
+++ b/packages/headless/src/primitives/flow/flow-step-context.ts
@@ -0,0 +1,35 @@
+'use client';
+
+import { createContext, type RefCallback, useCallback, useContext, useRef } from 'react';
+
+export interface FlowStepContextValue {
+ registerFocusTarget: (element: HTMLElement) => void;
+ unregisterFocusTarget: (element: HTMLElement) => void;
+}
+
+export const FlowStepContext = createContext(null);
+
+/**
+ * Marks an element as the one to focus after the enclosing `Flow.Step` finishes entering.
+ * When several mounted elements are marked, the first in DOM order is focused. Outside a
+ * step the ref is a no-op, so a view can render standalone without a wrapper.
+ */
+export function useFlowAutoFocus(): RefCallback {
+ const context = useContext(FlowStepContext);
+ const elementRef = useRef(null);
+
+ return useCallback(
+ (element: T | null) => {
+ if (element) {
+ elementRef.current = element;
+ context?.registerFocusTarget(element);
+ return;
+ }
+ if (elementRef.current) {
+ context?.unregisterFocusTarget(elementRef.current);
+ elementRef.current = null;
+ }
+ },
+ [context],
+ );
+}
diff --git a/packages/headless/src/primitives/flow/flow-step.tsx b/packages/headless/src/primitives/flow/flow-step.tsx
index 608e97e21b8..51f4d651d83 100644
--- a/packages/headless/src/primitives/flow/flow-step.tsx
+++ b/packages/headless/src/primitives/flow/flow-step.tsx
@@ -1,23 +1,42 @@
'use client';
import { inertProps } from '@clerk/shared/inert';
-import React, { useLayoutEffect, useRef } from 'react';
+import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
+import { useAnimationsFinished } from '../../hooks/use-animations-finished';
import { useTransition } from '../../hooks/use-transition';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { useFlowContext } from './flow-context';
+import { FlowStepContext, type FlowStepContextValue } from './flow-step-context';
export interface FlowStepProps extends ComponentProps<'div'> {
ids: readonly string[];
}
+function focusIsWithin(root: HTMLElement): boolean {
+ const active = root.ownerDocument.activeElement;
+ return active === null || active === root.ownerDocument.body || root.contains(active);
+}
+
+function firstInDocumentOrder(elements: Iterable): HTMLElement | null {
+ let first: HTMLElement | null = null;
+ for (const element of elements) {
+ if (!first || first.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_PRECEDING) {
+ first = element;
+ }
+ }
+ return first;
+}
+
export const FlowStep = React.forwardRef(function FlowStep(props, forwardedRef) {
const { render, ids, children, ...otherProps } = props;
- const { value, direction, registerActiveStep, unregisterActiveStep } = useFlowContext();
+ const { value, direction, rootRef, registerActiveStep, unregisterActiveStep } = useFlowContext();
const open = ids.includes(value);
const stepRef = useRef(null);
const activeChildrenRef = useRef(children);
const hasBeenClosed = useRef(false);
+ const focusTargetsRef = useRef(new Set());
+ const wasOpenRef = useRef(open);
if (open) {
activeChildrenRef.current = children;
@@ -26,6 +45,7 @@ export const FlowStep = React.forwardRef(function
}
const { mounted, transitionProps } = useTransition({ open, ref: stepRef });
+ const runOnEntered = useAnimationsFinished(stepRef, open);
useLayoutEffect(() => {
const element = stepRef.current;
@@ -37,6 +57,33 @@ export const FlowStep = React.forwardRef(function
return () => unregisterActiveStep(element);
}, [open, registerActiveStep, unregisterActiveStep]);
+ useEffect(() => {
+ const entering = open && !wasOpenRef.current;
+ wasOpenRef.current = open;
+ if (!entering) {
+ return;
+ }
+
+ return runOnEntered(() => {
+ const target = firstInDocumentOrder(focusTargetsRef.current);
+ const root = rootRef.current;
+ if (target && root && focusIsWithin(root)) {
+ target.focus({ preventScroll: true });
+ }
+ });
+ }, [open, rootRef, runOnEntered]);
+
+ const registerFocusTarget = useCallback((element: HTMLElement) => {
+ focusTargetsRef.current.add(element);
+ }, []);
+ const unregisterFocusTarget = useCallback((element: HTMLElement) => {
+ focusTargetsRef.current.delete(element);
+ }, []);
+ const stepContext = useMemo(
+ () => ({ registerFocusTarget, unregisterFocusTarget }),
+ [registerFocusTarget, unregisterFocusTarget],
+ );
+
const effectiveTransitionProps = !hasBeenClosed.current
? { ...transitionProps, 'data-starting-style': undefined, style: undefined }
: transitionProps;
@@ -52,11 +99,13 @@ export const FlowStep = React.forwardRef(function
children: open ? children : activeChildrenRef.current,
};
- return useRender({
+ const element = useRender({
defaultTagName: 'div',
enabled: mounted,
render,
ref: [stepRef, forwardedRef],
props: mergeProps<'div'>(defaultProps, otherProps),
});
+
+ return {element};
});
diff --git a/packages/headless/src/primitives/flow/flow.test.tsx b/packages/headless/src/primitives/flow/flow.test.tsx
index 4e042aa2b60..29dadadd0d0 100644
--- a/packages/headless/src/primitives/flow/flow.test.tsx
+++ b/packages/headless/src/primitives/flow/flow.test.tsx
@@ -1,8 +1,17 @@
import { act, cleanup, render, screen } from '@testing-library/react';
-import { createRef } from 'react';
+import React, { createRef } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { Flow } from './index';
+import { Flow, useFlowAutoFocus } from './index';
+
+function AutoFocusInput(props: React.ComponentProps<'input'>) {
+ return (
+
+ );
+}
interface TestFlowProps {
value: string;
@@ -22,12 +31,14 @@ function TestFlow({ value, direction = 1, passwordContent = 'Password' }: TestFl
data-testid='password-step'
>
{passwordContent}
+
OTP
+
);
@@ -233,4 +244,203 @@ describe('Flow', () => {
expect(root).not.toHaveAttribute('data-initial');
offsetHeight.mockRestore();
});
+ describe('useFlowAutoFocus', () => {
+ let originalGetAnimations: HTMLElement['getAnimations'] | undefined;
+
+ beforeEach(() => {
+ originalGetAnimations = HTMLElement.prototype.getAnimations;
+ });
+
+ afterEach(() => {
+ if (originalGetAnimations) {
+ HTMLElement.prototype.getAnimations = originalGetAnimations;
+ } else {
+ Reflect.deleteProperty(HTMLElement.prototype, 'getAnimations');
+ }
+ });
+
+ function pendingAnimationOn(testid: string) {
+ let finishAnimation!: () => void;
+ const animationFinished = new Promise(resolve => {
+ finishAnimation = resolve;
+ });
+ let finished = false;
+ HTMLElement.prototype.getAnimations = function (this: HTMLElement) {
+ if (finished || this.dataset.testid !== testid) {
+ return [];
+ }
+ return [{ finished: animationFinished }] as unknown as Animation[];
+ };
+ return async () => {
+ finished = true;
+ await act(async () => {
+ finishAnimation();
+ await animationFinished;
+ });
+ };
+ }
+
+ it('does not focus the initially active step', () => {
+ render();
+
+ expect(screen.getByTestId('password-input')).not.toHaveFocus();
+ });
+
+ it('focuses the registered element once the entering step settles', async () => {
+ const focus = vi.spyOn(HTMLElement.prototype, 'focus');
+ const finish = pendingAnimationOn('otp-step');
+ const { rerender } = render();
+
+ rerender();
+ const input = screen.getByTestId('otp-input');
+ expect(input).not.toHaveFocus();
+
+ act(() => flushRaf());
+ await act(async () => {});
+ expect(input).not.toHaveFocus();
+
+ await finish();
+
+ expect(input).toHaveFocus();
+ expect(focus).toHaveBeenCalledWith({ preventScroll: true });
+ focus.mockRestore();
+ });
+
+ it('focuses immediately after the starting frame when the step has no animations', async () => {
+ HTMLElement.prototype.getAnimations = () => [];
+ const { rerender } = render();
+
+ rerender();
+ expect(screen.getByTestId('otp-input')).not.toHaveFocus();
+
+ act(() => flushRaf());
+ await act(async () => {});
+
+ expect(screen.getByTestId('otp-input')).toHaveFocus();
+ });
+
+ it('leaves focus alone when it is outside the flow', async () => {
+ HTMLElement.prototype.getAnimations = () => [];
+ const { rerender } = render(
+ <>
+
+
+ >,
+ );
+ screen.getByTestId('outside').focus();
+
+ rerender(
+ <>
+
+
+ >,
+ );
+ act(() => flushRaf());
+ await act(async () => {});
+
+ expect(screen.getByTestId('outside')).toHaveFocus();
+ expect(screen.getByTestId('otp-input')).not.toHaveFocus();
+ });
+
+ it('abandons a pending focus when the entering step closes before it settles', async () => {
+ const finish = pendingAnimationOn('otp-step');
+ const { rerender } = render();
+
+ rerender();
+ const otpInput = screen.getByTestId('otp-input');
+
+ rerender(
+ ,
+ );
+ act(() => flushRaf());
+ await act(async () => {});
+ await finish();
+
+ expect(otpInput).not.toHaveFocus();
+ expect(screen.getByTestId('password-input')).toHaveFocus();
+ });
+
+ it('focuses the first marked element in DOM order when several are marked', async () => {
+ HTMLElement.prototype.getAnimations = () => [];
+ const otpStep = (
+
+
+
+
+ );
+ const { rerender } = render(
+
+ Password
+ {otpStep}
+ ,
+ );
+
+ rerender(
+
+ Password
+ {otpStep}
+ ,
+ );
+ act(() => flushRaf());
+ await act(async () => {});
+
+ expect(screen.getByTestId('first')).toHaveFocus();
+ });
+
+ it('focuses whichever marked element is still mounted when the step settles', async () => {
+ const finish = pendingAnimationOn('otp-step');
+ const otpStep = (showFirst: boolean) => (
+
+ {showFirst ? : null}
+
+
+ );
+ const { rerender } = render(
+
+ Password
+ {otpStep(true)}
+ ,
+ );
+
+ rerender(
+
+ Password
+ {otpStep(true)}
+ ,
+ );
+ rerender(
+
+ Password
+ {otpStep(false)}
+ ,
+ );
+ act(() => flushRaf());
+ await act(async () => {});
+ await finish();
+
+ expect(screen.queryByTestId('first')).not.toBeInTheDocument();
+ expect(screen.getByTestId('second')).toHaveFocus();
+ });
+
+ it('returns a no-op ref outside a step', () => {
+ expect(() => render()).not.toThrow();
+ expect(screen.getByTestId('lone-input')).toBeInTheDocument();
+ });
+ });
});
diff --git a/packages/headless/src/primitives/flow/index.ts b/packages/headless/src/primitives/flow/index.ts
index 53346ced68d..15479cf7891 100644
--- a/packages/headless/src/primitives/flow/index.ts
+++ b/packages/headless/src/primitives/flow/index.ts
@@ -1,3 +1,4 @@
export * as Flow from './parts';
+export { useFlowAutoFocus } from './flow-step-context';
export type { FlowDirection, FlowRootProps, FlowStepProps } from './parts';
diff --git a/packages/headless/src/primitives/flow/parts.ts b/packages/headless/src/primitives/flow/parts.ts
index f79aa92bcb2..abac8842513 100644
--- a/packages/headless/src/primitives/flow/parts.ts
+++ b/packages/headless/src/primitives/flow/parts.ts
@@ -1,3 +1,4 @@
export { type FlowRootProps, FlowRoot as Root } from './flow-root';
export { type FlowStepProps, FlowStep as Step } from './flow-step';
+export { useFlowAutoFocus } from './flow-step-context';
export type { FlowDirection } from './flow-context';
diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification-backup-code.tsx b/packages/ui/src/mosaic/blocks/reverification/reverification-backup-code.tsx
index d18e8d51ee3..7c033b88b37 100644
--- a/packages/ui/src/mosaic/blocks/reverification/reverification-backup-code.tsx
+++ b/packages/ui/src/mosaic/blocks/reverification/reverification-backup-code.tsx
@@ -4,6 +4,7 @@ import { useId } from 'react';
import { Button, SubmitButton } from '../../components/button';
import { Card } from '../../components/card';
import { Field } from '../../components/field';
+import { useFlowAutoFocus } from '../../components/flow';
import { Input } from '../../components/input';
export interface ReverificationBackupCodeMessages {
@@ -61,6 +62,7 @@ export function ReverificationBackupCode({
>
{messages.fieldLabel}
()}
name='backupCode'
type='text'
autoComplete='off'
diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification-help.tsx b/packages/ui/src/mosaic/blocks/reverification/reverification-help.tsx
index 11a883fcff6..0e1a59ea7f0 100644
--- a/packages/ui/src/mosaic/blocks/reverification/reverification-help.tsx
+++ b/packages/ui/src/mosaic/blocks/reverification/reverification-help.tsx
@@ -1,5 +1,6 @@
import { Button } from '../../components/button';
import { Card } from '../../components/card';
+import { useFlowAutoFocus } from '../../components/flow';
export interface ReverificationHelpMessages {
title: string;
@@ -23,6 +24,7 @@ export function ReverificationHelp({ messages, onEmailSupport, onBack }: Reverif
) : null}
()}
type='button'
fullWidth
isPending={isPending}
diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification-password.tsx b/packages/ui/src/mosaic/blocks/reverification/reverification-password.tsx
index f229fe2e44d..9d5e153bb6f 100644
--- a/packages/ui/src/mosaic/blocks/reverification/reverification-password.tsx
+++ b/packages/ui/src/mosaic/blocks/reverification/reverification-password.tsx
@@ -4,6 +4,7 @@ import { useId } from 'react';
import { Button, SubmitButton } from '../../components/button';
import { Card } from '../../components/card';
import { Field } from '../../components/field';
+import { useFlowAutoFocus } from '../../components/flow';
import { Input } from '../../components/input';
export interface ReverificationPasswordMessages {
@@ -62,6 +63,7 @@ export function ReverificationPassword({
>
{messages.fieldLabel}
()}
name='password'
type='password'
autoComplete='current-password'
diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification.test.tsx b/packages/ui/src/mosaic/blocks/reverification/reverification.test.tsx
index 9d173802cd7..315dd633ba4 100644
--- a/packages/ui/src/mosaic/blocks/reverification/reverification.test.tsx
+++ b/packages/ui/src/mosaic/blocks/reverification/reverification.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from '@testing-library/react';
+import { act, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { ReverificationModel } from './reverification';
@@ -134,4 +134,21 @@ describe('Reverification', () => {
expect(banner).toHaveAttribute('data-color', 'negative');
expect(banner).toHaveTextContent('We couldn’t verify that passkey. Try again.');
});
+
+ it('moves focus to the entering step once it settles', async () => {
+ const { rerender } = render();
+ expect(screen.getByLabelText('Password')).not.toHaveFocus();
+
+ rerender();
+ await act(async () => {});
+
+ expect(screen.getAllByRole('textbox')[0]).toHaveFocus();
+
+ const picker = model('method-picker', -1);
+ picker.methodPicker.methods = [{ id: 'password', label: 'Password', icon: 'chevron-right' }];
+ rerender();
+ await act(async () => {});
+
+ expect(screen.getByRole('button', { name: 'Password' })).toHaveFocus();
+ });
});
diff --git a/packages/ui/src/mosaic/components/flow/index.ts b/packages/ui/src/mosaic/components/flow/index.ts
index 299af5dcfe4..ddac11c0ef0 100644
--- a/packages/ui/src/mosaic/components/flow/index.ts
+++ b/packages/ui/src/mosaic/components/flow/index.ts
@@ -1,2 +1,3 @@
+export { useFlowAutoFocus } from '@clerk/headless/flow';
export { Flow } from './flow';
export type { FlowDirection, FlowRootProps, FlowStepProps } from './flow';
diff --git a/packages/ui/src/mosaic/components/otp/otp.test.tsx b/packages/ui/src/mosaic/components/otp/otp.test.tsx
index b2a8fd1464c..1530e1797a4 100644
--- a/packages/ui/src/mosaic/components/otp/otp.test.tsx
+++ b/packages/ui/src/mosaic/components/otp/otp.test.tsx
@@ -200,4 +200,18 @@ describe('Mosaic Otp', () => {
);
expect(document.querySelector('input[name="code"]')).toHaveValue('123');
});
+
+ it('forwards its ref to the first slot', () => {
+ const ref = React.createRef();
+
+ render(
+ ,
+ );
+
+ expect(ref.current).toBe(slots()[0]);
+ });
});
diff --git a/packages/ui/src/mosaic/components/otp/otp.tsx b/packages/ui/src/mosaic/components/otp/otp.tsx
index 02b82c4b412..cca403d5ef0 100644
--- a/packages/ui/src/mosaic/components/otp/otp.tsx
+++ b/packages/ui/src/mosaic/components/otp/otp.tsx
@@ -19,13 +19,14 @@ export interface OtpProps extends Omit }) {
const { slots, disabled } = Primitive.useOtp();
return slots.map(slot => (
(function MosaicOtp(
+ {
+ length = 6,
+ status: statusProp,
+ disabled: disabledProp,
+ required: requiredProp,
+ id,
+ 'aria-invalid': ariaInvalidProp,
+ 'aria-labelledby': ariaLabelledBy,
+ 'aria-describedby': ariaDescribedBy,
+ ...rest
+ },
+ ref,
+) {
const fieldProps = useOptionalFieldControlProps({
id,
disabled: disabledProp,
@@ -81,7 +86,10 @@ export function Otp({
aria-labelledby={fieldProps?.['aria-labelledby'] ?? ariaLabelledBy}
aria-describedby={fieldProps?.['aria-describedby'] ?? ariaDescribedBy}
>
-
+
);
-}
+});
diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts
index 6182d413050..78a2448221c 100644
--- a/packages/ui/src/mosaic/styles/index.ts
+++ b/packages/ui/src/mosaic/styles/index.ts
@@ -54,7 +54,7 @@ export type {
} from '../components/dialog';
export { Field } from '../components/field';
export type { FieldDescriptionProps, FieldErrorProps, FieldLabelProps, FieldRootProps } from '../components/field';
-export { Flow } from '../components/flow';
+export { Flow, useFlowAutoFocus } from '../components/flow';
export type { FlowRootProps, FlowStepProps } from '../components/flow';
export { Heading, HeadingContext } from '../components/heading';
export type { HeadingProps } from '../components/heading';