diff --git a/.changeset/mosaic-flow-autofocus.md b/.changeset/mosaic-flow-autofocus.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/mosaic-flow-autofocus.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/headless/src/primitives/flow/README.md b/packages/headless/src/primitives/flow/README.md
index d445061ba94..9f42f4cc494 100644
--- a/packages/headless/src/primitives/flow/README.md
+++ b/packages/headless/src/primitives/flow/README.md
@@ -48,6 +48,25 @@ 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 when it enters:
+
+```tsx
+import { useFlowAutoFocus } from '@clerk/headless/flow';
+
+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` as soon as the step enters, and only when focus is currently on the body or inside `Flow.Root`, so it never steals from elsewhere on the page. Style `Flow.Root` with `overflow: clip` rather than `hidden` so focusing an element that is still sliding in cannot scroll the viewport. When several mounted elements are marked, the first in DOM order is focused. 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..c4dff817fee
--- /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 when the enclosing `Flow.Step` enters.
+ * 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..ecb18570b79 100644
--- a/packages/headless/src/primitives/flow/flow-step.tsx
+++ b/packages/headless/src/primitives/flow/flow-step.tsx
@@ -1,23 +1,41 @@
'use client';
import { inertProps } from '@clerk/shared/inert';
-import React, { useLayoutEffect, useRef } from 'react';
+import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
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;
@@ -37,6 +55,31 @@ export const FlowStep = React.forwardRef(function
return () => unregisterActiveStep(element);
}, [open, registerActiveStep, unregisterActiveStep]);
+ useEffect(() => {
+ const entering = open && !wasOpenRef.current;
+ wasOpenRef.current = open;
+ if (!entering) {
+ return;
+ }
+
+ const target = firstInDocumentOrder(focusTargetsRef.current);
+ const root = rootRef.current;
+ if (target && root && root.contains(target) && focusIsWithin(root)) {
+ target.focus({ preventScroll: true });
+ }
+ }, [open, rootRef]);
+
+ 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 +95,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..ac0672a2a48 100644
--- a/packages/headless/src/primitives/flow/flow.test.tsx
+++ b/packages/headless/src/primitives/flow/flow.test.tsx
@@ -1,8 +1,18 @@
import { act, cleanup, render, screen } from '@testing-library/react';
-import { createRef } from 'react';
+import React, { createRef } from 'react';
+import { createPortal } from 'react-dom';
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 +32,14 @@ function TestFlow({ value, direction = 1, passwordContent = 'Password' }: TestFl
data-testid='password-step'
>
{passwordContent}
+
OTP
+
);
@@ -233,4 +245,145 @@ describe('Flow', () => {
expect(root).not.toHaveAttribute('data-initial');
offsetHeight.mockRestore();
});
+ describe('useFlowAutoFocus', () => {
+ it('does not focus the initially active step', () => {
+ render();
+
+ expect(screen.getByTestId('password-input')).not.toHaveFocus();
+ });
+
+ it('focuses the registered element without scrolling when the step enters', () => {
+ const focus = vi.spyOn(HTMLElement.prototype, 'focus');
+ const { rerender } = render();
+
+ rerender();
+
+ expect(screen.getByTestId('otp-input')).toHaveFocus();
+ expect(focus).toHaveBeenCalledWith({ preventScroll: true });
+ focus.mockRestore();
+ });
+
+ it('leaves focus alone when it is outside the flow', () => {
+ const { rerender } = render(
+ <>
+
+
+ >,
+ );
+ screen.getByTestId('outside').focus();
+
+ rerender(
+ <>
+
+
+ >,
+ );
+
+ expect(screen.getByTestId('outside')).toHaveFocus();
+ expect(screen.getByTestId('otp-input')).not.toHaveFocus();
+ });
+
+ it('focuses the returning step when navigation reverses mid-transition', () => {
+ const { rerender } = render();
+
+ rerender();
+ const otpInput = screen.getByTestId('otp-input');
+ expect(otpInput).toHaveFocus();
+
+ rerender(
+ ,
+ );
+
+ expect(otpInput).not.toHaveFocus();
+ expect(screen.getByTestId('password-input')).toHaveFocus();
+ });
+
+ it('focuses the first marked element in DOM order when several are marked', () => {
+ const otpStep = (
+
+
+
+
+ );
+ const { rerender } = render(
+
+ Password
+ {otpStep}
+ ,
+ );
+
+ rerender(
+
+ Password
+ {otpStep}
+ ,
+ );
+
+ expect(screen.getByTestId('first')).toHaveFocus();
+ });
+
+ it('skips a marked element that is not rendered', () => {
+ const otpStep = (showFirst: boolean) => (
+
+ {showFirst ? : null}
+
+
+ );
+ const { rerender } = render(
+
+ Password
+ {otpStep(false)}
+ ,
+ );
+
+ rerender(
+
+ Password
+ {otpStep(false)}
+ ,
+ );
+
+ expect(screen.queryByTestId('first')).not.toBeInTheDocument();
+ expect(screen.getByTestId('second')).toHaveFocus();
+ });
+
+ it('ignores a marked element portaled outside the root', () => {
+ const otpStep = (
+ {createPortal(, document.body)}
+ );
+ const { rerender } = render(
+
+ Password
+ {otpStep}
+ ,
+ );
+
+ rerender(
+
+ Password
+ {otpStep}
+ ,
+ );
+
+ expect(screen.getByTestId('portaled')).not.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/components/flow/flow.styles.ts b/packages/ui/src/mosaic/components/flow/flow.styles.ts
index a7e202b8217..60e634d6dd0 100644
--- a/packages/ui/src/mosaic/components/flow/flow.styles.ts
+++ b/packages/ui/src/mosaic/components/flow/flow.styles.ts
@@ -4,7 +4,7 @@ import { durationVars, easingVars } from '../../tokens.stylex';
export const styles = stylex.create({
root: {
- overflow: 'hidden',
+ overflow: 'clip',
position: 'relative',
transitionDuration: {
default: durationVars['--cl-duration-slow'],
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..a5fdc5b1a1d 100644
--- a/packages/ui/src/mosaic/components/otp/otp.tsx
+++ b/packages/ui/src/mosaic/components/otp/otp.tsx
@@ -12,20 +12,21 @@ import { styles } from './otp.styles';
/** How the entered code currently reads back to the user. */
export type OtpStatus = 'neutral' | 'success' | 'error';
-export interface OtpProps extends Omit {
+export interface OtpProps extends Omit {
/** The number of boxes in the code. @default 6 */
length?: number;
/** Colours every slot for the verification outcome. Defaults to the enclosing `Field`'s validity. */
status?: OtpStatus;
}
-function OtpSlots({ status }: { status: OtpStatus }) {
+function OtpSlots({ status, firstSlotRef }: { status: OtpStatus; firstSlotRef: React.ForwardedRef }) {
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/features/reverification/__tests__/reverification.view.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.view.test.tsx
index 17f1b01bf8a..d9439dedfc7 100644
--- a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.view.test.tsx
+++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.view.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 { MosaicProvider } from '../../../MosaicProvider';
@@ -68,6 +68,36 @@ describe('ReverificationView', () => {
expect(otpStep?.style.getPropertyValue('--cl-flow-transition-direction')).toBe('-1');
});
+ it('moves focus to the entering step once it settles', async () => {
+ const { rerender } = renderView();
+
+ expect(screen.getByLabelText('Password')).not.toHaveFocus();
+
+ rerender(
+
+
+ ,
+ );
+ await act(async () => {});
+
+ expect(screen.getAllByRole('textbox')[0]).toHaveFocus();
+
+ rerender(
+
+
+ ,
+ );
+ await act(async () => {});
+
+ expect(screen.getByRole('button', { name: 'Continue with your password' })).toHaveFocus();
+ });
+
it('keeps a disabled resend control during the cooldown', () => {
renderView({
step: 'otp',
diff --git a/packages/ui/src/mosaic/features/reverification/panels/reverification-backup-code.tsx b/packages/ui/src/mosaic/features/reverification/panels/reverification-backup-code.tsx
index 3bf52f7a921..2b0206cd32e 100644
--- a/packages/ui/src/mosaic/features/reverification/panels/reverification-backup-code.tsx
+++ b/packages/ui/src/mosaic/features/reverification/panels/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/features/reverification/panels/reverification-help.tsx b/packages/ui/src/mosaic/features/reverification/panels/reverification-help.tsx
index 7d4a2577fb3..94c2347f47b 100644
--- a/packages/ui/src/mosaic/features/reverification/panels/reverification-help.tsx
+++ b/packages/ui/src/mosaic/features/reverification/panels/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/features/reverification/panels/reverification-password.tsx b/packages/ui/src/mosaic/features/reverification/panels/reverification-password.tsx
index 2a569903e45..0a31af1a225 100644
--- a/packages/ui/src/mosaic/features/reverification/panels/reverification-password.tsx
+++ b/packages/ui/src/mosaic/features/reverification/panels/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/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';