diff --git a/.changeset/impersonation-drag-gesture.md b/.changeset/impersonation-drag-gesture.md
new file mode 100644
index 00000000000..5cf696816a3
--- /dev/null
+++ b/.changeset/impersonation-drag-gesture.md
@@ -0,0 +1,5 @@
+---
+'@clerk/ui': patch
+---
+
+Fix the impersonation button moving faster than the pointer when dragged. It now starts dragging only after the pointer moves a few pixels, prevents page text selection during dragging, and springs back inside the window when released off-screen instead of jumping to its default position. Its background now follows the theme, so its text is readable in dark mode.
diff --git a/packages/clerk-js/sandbox/scenarios/impersonation-signed-in.ts b/packages/clerk-js/sandbox/scenarios/impersonation-signed-in.ts
new file mode 100644
index 00000000000..1070c71bfcb
--- /dev/null
+++ b/packages/clerk-js/sandbox/scenarios/impersonation-signed-in.ts
@@ -0,0 +1,26 @@
+import {
+ clerkHandlers,
+ EnvironmentService,
+ type MockScenario,
+ SessionService,
+ setClerkState,
+ UserService,
+} from '@clerk/msw';
+
+export function ImpersonationSignedIn(): MockScenario {
+ const user = UserService.create();
+ const session = { ...SessionService.create(user), actor: { sub: 'user_impersonator' } };
+
+ setClerkState({
+ environment: EnvironmentService.MULTI_SESSION,
+ session,
+ user,
+ });
+
+ return {
+ description: 'Signed-in session impersonated by another user, showing the impersonation button',
+ handlers: clerkHandlers,
+ initialState: { session, user },
+ name: 'impersonation-signed-in',
+ };
+}
diff --git a/packages/clerk-js/sandbox/scenarios/index.ts b/packages/clerk-js/sandbox/scenarios/index.ts
index d06dcc56b80..370920327ef 100644
--- a/packages/clerk-js/sandbox/scenarios/index.ts
+++ b/packages/clerk-js/sandbox/scenarios/index.ts
@@ -5,3 +5,4 @@ export { OrgProfileSeatLimit } from './org-profile-seat-limit';
export { PricingTableSBB } from './pricing-table-sbb';
export { AnnualOnlyPlans } from './annual-only-plans';
export { XProviderEnabled } from './x-provider-enabled';
+export { ImpersonationSignedIn } from './impersonation-signed-in';
diff --git a/packages/ui/src/common/__tests__/constants.test.ts b/packages/ui/src/common/__tests__/constants.test.ts
new file mode 100644
index 00000000000..5af18bed446
--- /dev/null
+++ b/packages/ui/src/common/__tests__/constants.test.ts
@@ -0,0 +1,25 @@
+import type { Attribute } from '@clerk/shared/types';
+import { describe, expect, it } from 'vitest';
+
+import { groupIdentifiers } from '../constants';
+
+describe('groupIdentifiers', () => {
+ it.each<{ attributes: Attribute[]; expected: string[] }>([
+ { attributes: [], expected: [] },
+ { attributes: ['passkey'], expected: [] },
+ { attributes: ['email_address'], expected: ['email_address'] },
+ { attributes: ['username', 'phone_number'], expected: ['username', 'phone_number'] },
+ {
+ attributes: ['phone_number', 'username', 'passkey', 'email_address'],
+ expected: ['email_address_username', 'phone_number'],
+ },
+ {
+ attributes: ['email_address', 'username', 'email_address', 'phone_number'],
+ expected: ['email_address_username', 'phone_number'],
+ },
+ ])('groups $attributes without changing the input', ({ attributes, expected }) => {
+ const original = [...attributes];
+ expect(groupIdentifiers(attributes)).toEqual(expected);
+ expect(attributes).toEqual(original);
+ });
+});
diff --git a/packages/ui/src/common/constants.ts b/packages/ui/src/common/constants.ts
index 15ede217b38..7e40112fbab 100644
--- a/packages/ui/src/common/constants.ts
+++ b/packages/ui/src/common/constants.ts
@@ -68,11 +68,11 @@ const FirstFactorConfigs = Object.freeze({
export type SignInStartIdentifier = 'email_address' | 'username' | 'phone_number' | 'email_address_username';
export const groupIdentifiers = (attributes: Attribute[]): SignInStartIdentifier[] => {
// Always skip passkey, while passkey can be considered an identifier we want to exclude it in the UI we are delivering
- let newAttributes: string[] = [...attributes.filter(a => a !== 'passkey')];
+ let newAttributes: string[] = attributes.filter(a => a !== 'passkey');
//merge email_address and username attributes
- if (['email_address', 'username'].every(r => newAttributes.includes(r))) {
- newAttributes = newAttributes.filter(a => !['email_address', 'username'].includes(a));
+ if (newAttributes.includes('email_address') && newAttributes.includes('username')) {
+ newAttributes = newAttributes.filter(a => a !== 'email_address' && a !== 'username');
newAttributes.unshift('email_address_username');
}
diff --git a/packages/ui/src/components/ImpersonationFab/__tests__/ImpersonationFab.test.tsx b/packages/ui/src/components/ImpersonationFab/__tests__/ImpersonationFab.test.tsx
index ee0f058dc8f..61a27f5ba4a 100644
--- a/packages/ui/src/components/ImpersonationFab/__tests__/ImpersonationFab.test.tsx
+++ b/packages/ui/src/components/ImpersonationFab/__tests__/ImpersonationFab.test.tsx
@@ -1,14 +1,110 @@
import React from 'react';
-import { describe, expect, it } from 'vitest';
+import { afterEach, describe, expect, it, vi } from 'vitest';
import { bindCreateFixtures } from '@/test/create-fixtures';
-import { render } from '@/test/utils';
+import { fireEvent, render } from '@/test/utils';
import { ImpersonationFab } from '../';
const { createFixtures } = bindCreateFixtures('UserButton');
describe('ImpersonationFab', () => {
+ const topProperty = '--cl-impersonation-fab-top';
+ const rightProperty = '--cl-impersonation-fab-right';
+ const cssVar = (name: string) => document.documentElement.style.getPropertyValue(name);
+
+ afterEach(() => {
+ pointer(window, 'pointercancel', {});
+ document.documentElement.style.removeProperty(topProperty);
+ document.documentElement.style.removeProperty(rightProperty);
+ vi.restoreAllMocks();
+ });
+
+ const pointer = (
+ target: EventTarget,
+ type: string,
+ init: MouseEventInit & { movementX?: number; movementY?: number },
+ ) => {
+ const event = new MouseEvent(type, { bubbles: true, cancelable: true, ...init });
+ Object.defineProperties(event, {
+ pointerId: { value: 1 },
+ movementX: { value: init.movementX ?? 0 },
+ movementY: { value: init.movementY ?? 0 },
+ });
+ fireEvent(target, event);
+ };
+
+ const renderDraggableFab = async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'], actor: { sub: 'user_impersonated' } });
+ });
+ const result = render(, { wrapper });
+ const fab = document.getElementById('cl-impersonationEye')?.parentElement;
+ if (!fab) {
+ throw new Error('Impersonation button was not rendered');
+ }
+
+ const top = () => parseFloat(cssVar(topProperty)) || 109;
+ const right = () => parseFloat(cssVar(rightProperty)) || 23;
+ let width = 200;
+ Object.defineProperties(fab, {
+ offsetTop: { get: top },
+ offsetLeft: { get: () => window.innerWidth - right() - width },
+ offsetWidth: { get: () => width },
+ offsetHeight: { get: () => 64 },
+ setPointerCapture: { value: vi.fn() },
+ hasPointerCapture: { value: () => false },
+ });
+ vi.spyOn(fab, 'getBoundingClientRect').mockImplementation(() =>
+ DOMRect.fromRect({ x: window.innerWidth - right() - width, y: top(), width, height: 64 }),
+ );
+ return { ...result, fixtures, fab, setWidth: (value: number) => (width = value) };
+ };
+
+ it('follows the pointer position rather than movement values, across hover width changes', async () => {
+ const { fab, setWidth } = await renderDraggableFab();
+ pointer(fab, 'pointerdown', { clientX: 900, clientY: 130 });
+ pointer(window, 'pointermove', { clientX: 880, clientY: 160, movementX: -40, movementY: 60 });
+ expect(cssVar(rightProperty)).toBe('43px');
+ expect(cssVar(topProperty)).toBe('139px');
+
+ setWidth(64);
+ pointer(window, 'pointermove', { clientX: 870, clientY: 165, movementX: -20, movementY: 10 });
+ expect(cssVar(rightProperty)).toBe('53px');
+ expect(cssVar(topProperty)).toBe('144px');
+ });
+
+ it('keeps the sign-out action clickable', async () => {
+ const { fixtures, getByText, userEvent } = await renderDraggableFab();
+ await userEvent.click(getByText('Sign out'));
+ expect(fixtures.clerk.signOut).toHaveBeenCalled();
+ });
+
+ it('settles inside the viewport inset when released off-screen', async () => {
+ const { fab } = await renderDraggableFab();
+ pointer(fab, 'pointerdown', { clientX: 900, clientY: 130 });
+ pointer(window, 'pointermove', { clientX: 1200, clientY: -100 });
+ pointer(window, 'pointerup', {});
+ expect(cssVar(rightProperty)).toBe('20px');
+ expect(cssVar(topProperty)).toBe('20px');
+ });
+
+ it('keeps the position when released inside the viewport inset', async () => {
+ const { fab } = await renderDraggableFab();
+ pointer(fab, 'pointerdown', { clientX: 900, clientY: 130 });
+ pointer(window, 'pointermove', { clientX: 880, clientY: 160 });
+ pointer(window, 'pointerup', {});
+ expect(cssVar(rightProperty)).toBe('43px');
+ expect(cssVar(topProperty)).toBe('139px');
+ });
+
+ it('resets an out-of-viewport position on resize', async () => {
+ await renderDraggableFab();
+ document.documentElement.style.setProperty(topProperty, '1000px');
+ fireEvent(window, new Event('resize'));
+ expect(cssVar(topProperty)).toBe('109px');
+ });
+
it('does not render when user has no actor', async () => {
const { wrapper } = await createFixtures(f => {
f.withUser({ email_addresses: ['test@clerk.com'] });
diff --git a/packages/ui/src/components/ImpersonationFab/index.tsx b/packages/ui/src/components/ImpersonationFab/index.tsx
index b103e04826a..864ba7d2954 100644
--- a/packages/ui/src/components/ImpersonationFab/index.tsx
+++ b/packages/ui/src/components/ImpersonationFab/index.tsx
@@ -1,8 +1,7 @@
import { getFullName, getIdentifier } from '@clerk/shared/internal/clerk-js/user';
import { useClerk, useSession, useUser } from '@clerk/shared/react';
import type { SignedInSessionResource } from '@clerk/shared/types';
-import type { PointerEventHandler } from 'react';
-import React, { useEffect, useRef } from 'react';
+import { useEffect, useRef } from 'react';
import { useSignOutContext, withCoreUserGuard } from '../../contexts';
import type { LocalizationKey } from '../../customizables';
@@ -18,7 +17,9 @@ import {
useLocalizations,
} from '../../customizables';
import { Portal } from '../../elements/Portal';
+import { useDragGesture } from '../../hooks/useDragGesture';
import { useMultipleSessions } from '../../hooks/useMultipleSessions';
+import { usePrefersReducedMotion } from '../../hooks/usePrefersReducedMotion';
import { Eye } from '../../icons';
import type { PropsOfComponent } from '../../styledSystem';
import { InternalThemeProvider, mqu } from '../../styledSystem';
@@ -115,6 +116,7 @@ const ImpersonationFabInternal = () => {
const { t } = useLocalizations();
const { parsedInternalTheme } = useAppearance();
const containerRef = useRef(null);
+ const prefersReducedMotion = usePrefersReducedMotion();
const actor = session?.actor;
const agent = session?.agent;
const isImpersonating = !!actor && !agent;
@@ -126,6 +128,7 @@ const ImpersonationFabInternal = () => {
const rightProperty = '--cl-impersonation-fab-right';
const defaultTop = 109;
const defaultRight = 23;
+ const viewportInset = 20;
const handleResize = () => {
const current = containerRef.current;
@@ -144,31 +147,55 @@ const ImpersonationFabInternal = () => {
}
};
- const onPointerDown: PointerEventHandler = () => {
- window.addEventListener('pointermove', onPointerMove);
- window.addEventListener(
- 'pointerup',
- () => {
- window.removeEventListener('pointermove', onPointerMove);
- handleResize();
- },
- { once: true },
- );
- };
-
- const onPointerMove = React.useCallback((e: PointerEvent) => {
- e.stopPropagation();
- e.preventDefault();
+ const settleIntoViewport = () => {
const current = containerRef.current;
if (!current) {
return;
}
- const rightOffestBasedOnViewportAndContent = `${
- window.innerWidth - current.offsetLeft - current.offsetWidth - e.movementX
- }px`;
- document.documentElement.style.setProperty(rightProperty, rightOffestBasedOnViewportAndContent);
- document.documentElement.style.setProperty(topProperty, `${current.offsetTop - -e.movementY}px`);
- }, []);
+
+ const bounds = current.getBoundingClientRect();
+ const from = { top: bounds.top, offsetRight: window.innerWidth - bounds.right };
+ const to = {
+ top: Math.min(Math.max(from.top, viewportInset), window.innerHeight - bounds.height - viewportInset),
+ offsetRight: Math.min(
+ Math.max(from.offsetRight, viewportInset),
+ window.innerWidth - bounds.width - viewportInset,
+ ),
+ };
+ if (from.top === to.top && from.offsetRight === to.offsetRight) {
+ return;
+ }
+
+ document.documentElement.style.setProperty(rightProperty, `${to.offsetRight}px`);
+ document.documentElement.style.setProperty(topProperty, `${to.top}px`);
+ if (!prefersReducedMotion) {
+ current.animate(
+ [
+ // eslint-disable-next-line custom-rules/no-physical-css-properties -- Complex JS-based positioning via CSS custom properties
+ { top: `${from.top}px`, right: `${from.offsetRight}px` },
+ // eslint-disable-next-line custom-rules/no-physical-css-properties -- Complex JS-based positioning via CSS custom properties
+ { top: `${to.top}px`, right: `${to.offsetRight}px` },
+ ],
+ { duration: 350, easing: 'cubic-bezier(0.34, 1.2, 0.64, 1)' },
+ );
+ }
+ };
+
+ const dragStart = useRef<{ top: number; offsetRight: number } | null>(null);
+ const onPointerDown = useDragGesture({
+ onDragStart: () => {
+ const bounds = containerRef.current?.getBoundingClientRect();
+ dragStart.current = bounds ? { top: bounds.top, offsetRight: window.innerWidth - bounds.right } : null;
+ },
+ onDrag: ({ x, y }) => {
+ if (!dragStart.current) {
+ return;
+ }
+ document.documentElement.style.setProperty(rightProperty, `${dragStart.current.offsetRight - x}px`);
+ document.documentElement.style.setProperty(topProperty, `${dragStart.current.top + y}px`);
+ },
+ onDragEnd: settleIntoViewport,
+ });
const repositionFabOnResize = () => {
window.addEventListener('resize', handleResize);
@@ -205,7 +232,7 @@ const ImpersonationFabInternal = () => {
zIndex: t.zIndices.$fab,
boxShadow: t.shadows.$fabShadow,
borderRadius: t.radii.$halfHeight, //to match the circular eye perfectly
- backgroundColor: t.colors.$white,
+ backgroundColor: t.colors.$colorBackground,
fontFamily: t.fonts.$main,
':hover': {
cursor: 'grab',
diff --git a/packages/ui/src/components/devPrompts/KeylessPrompt/__tests__/use-drag-to-corner.test.tsx b/packages/ui/src/components/devPrompts/KeylessPrompt/__tests__/use-drag-to-corner.test.tsx
new file mode 100644
index 00000000000..d324a3501b4
--- /dev/null
+++ b/packages/ui/src/components/devPrompts/KeylessPrompt/__tests__/use-drag-to-corner.test.tsx
@@ -0,0 +1,77 @@
+import { fireEvent, render } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { STORAGE_KEY, useDragToCorner } from '../use-drag-to-corner';
+
+const DragTarget = () => {
+ const { containerRef, onPointerDown, isDragging, corner } = useDragToCorner();
+ return (
+
+ );
+};
+
+const pointer = (target: EventTarget, type: string, clientX: number, clientY: number): void => {
+ const event = new MouseEvent(type, { bubbles: true, clientX, clientY });
+ Object.defineProperty(event, 'pointerId', { value: 1 });
+ fireEvent(target, event);
+};
+
+const startDrag = () => {
+ localStorage.clear();
+ vi.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(window.innerWidth);
+ const result = render();
+ const target = result.getByTestId('target');
+ Object.defineProperties(target, {
+ offsetWidth: { value: 100 },
+ offsetHeight: { value: 40 },
+ setPointerCapture: { value: vi.fn() },
+ hasPointerCapture: { value: () => false },
+ });
+ const save = vi.spyOn(Storage.prototype, 'setItem');
+ pointer(target, 'pointerdown', 900, 700);
+ pointer(window, 'pointermove', 100, 100);
+ expect(target.dataset.dragging).toBe('true');
+ return { ...result, target, save };
+};
+
+const finishTransition = (target: HTMLElement): void => {
+ const event = new Event('transitionend', { bubbles: true });
+ Object.defineProperty(event, 'propertyName', { value: 'transform' });
+ fireEvent(target, event);
+};
+
+describe('useDragToCorner gestures', () => {
+ afterEach(() => {
+ pointer(window, 'pointercancel', 0, 0);
+ vi.restoreAllMocks();
+ localStorage.clear();
+ });
+
+ it('clears a canceled drag without snapping or saving a corner', () => {
+ const { target, save } = startDrag();
+ const position = target.style.transform;
+ pointer(window, 'pointercancel', 100, 100);
+ expect(target.dataset.dragging).toBe('false');
+ expect(target.style.transform).toBe(position);
+ expect(target.style.transition).toBe('');
+ finishTransition(target);
+ expect(save).not.toHaveBeenCalled();
+ expect(target.dataset.corner).toBe('bottom-right');
+ });
+
+ it('snaps and saves the corner after normal pointer release', () => {
+ const { target, save } = startDrag();
+ pointer(window, 'pointerup', 100, 100);
+ expect(target.dataset.dragging).toBe('false');
+ expect(target.style.transition).toContain('350ms');
+ finishTransition(target);
+ expect(save).toHaveBeenCalledWith(STORAGE_KEY, 'top-left');
+ expect(target.dataset.corner).toBe('top-left');
+ });
+});
diff --git a/packages/ui/src/components/devPrompts/KeylessPrompt/use-drag-to-corner.ts b/packages/ui/src/components/devPrompts/KeylessPrompt/use-drag-to-corner.ts
index 469c6cf0b40..fff743c6b58 100644
--- a/packages/ui/src/components/devPrompts/KeylessPrompt/use-drag-to-corner.ts
+++ b/packages/ui/src/components/devPrompts/KeylessPrompt/use-drag-to-corner.ts
@@ -1,12 +1,13 @@
-import type { PointerEventHandler } from 'react';
+import type { PointerEvent as ReactPointerEvent } from 'react';
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
+import { useDragGesture } from '../../../hooks/useDragGesture';
+
export type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
export const STORAGE_KEY = 'clerk-keyless-prompt-corner';
const CORNER_OFFSET = '1.25rem';
const CORNER_OFFSET_PX = 20; // 1.25rem ≈ 20px
-const DRAG_THRESHOLD = 5;
const VELOCITY_SAMPLE_INTERVAL_MS = 10;
const VELOCITY_HISTORY_SIZE = 5;
const INERTIA_DECELERATION_RATE = 0.999;
@@ -36,7 +37,7 @@ interface UseDragToCornerResult {
isDragging: boolean;
cornerStyle: React.CSSProperties;
containerRef: React.RefObject;
- onPointerDown: PointerEventHandler;
+ onPointerDown: (e: ReactPointerEvent) => void;
preventClick: boolean;
isInitialized: boolean;
}
@@ -138,13 +139,8 @@ export function useDragToCorner(): UseDragToCornerResult {
}, []);
const containerRef = useRef(null);
- const machine = useRef<{ state: 'idle' | 'press' | 'animating' } | { state: 'drag'; pointerId: number }>({
- state: 'idle',
- });
-
- const cleanup = useRef<(() => void) | null>(null);
- const origin = useRef({ x: 0, y: 0 });
const translation = useRef({ x: 0, y: 0 });
+ const dragStartTranslation = useRef(ZERO_POINT);
const lastTimestamp = useRef(0);
const velocities = useRef([]);
const dragStartDimensions = useRef<{ width: number; height: number } | null>(null);
@@ -212,7 +208,6 @@ export function useDragToCorner(): UseDragToCornerResult {
translation.current = ZERO_POINT;
el.style.transition = '';
el.style.transform = ZERO_TRANSFORM;
- machine.current = { state: 'idle' };
setPreventClick(false);
return;
}
@@ -231,11 +226,9 @@ export function useDragToCorner(): UseDragToCornerResult {
translation.current = ZERO_POINT;
el.style.transition = '';
el.style.transform = ZERO_TRANSFORM;
- machine.current = { state: 'idle' };
setPreventClick(false);
} else {
// Different corner — React state + layoutEffect for flash-free repositioning
- machine.current = { state: 'animating' };
pendingCornerUpdate.current = cornerTranslation.corner;
setCorner(cornerTranslation.corner);
}
@@ -248,62 +241,21 @@ export function useDragToCorner(): UseDragToCornerResult {
[setTranslation, corner],
);
- const cancel = useCallback(() => {
- if (machine.current.state === 'drag') {
- containerRef.current?.releasePointerCapture(machine.current.pointerId);
- machine.current = { state: 'animating' };
- } else {
- machine.current = { state: 'idle' };
- }
-
- if (cleanup.current) {
- cleanup.current();
- cleanup.current = null;
- }
-
- velocities.current = [];
- setIsDragging(false);
- dragStartDimensions.current = null; // Clear stored dimensions
- containerRef.current?.classList.remove('dev-tools-grabbing');
- document.body.style.removeProperty('user-select');
- document.body.style.removeProperty('-webkit-user-select');
-
- // Don't reset translation on simple clicks - it should already be correct
- // Only reset if we were actually dragging and need to clean up
- // Translation is reset to zero when snapping to corners, so simple clicks don't need reset
- }, []);
-
useLayoutEffect(() => {
if (pendingCornerUpdate.current === corner) {
const el = containerRef.current;
- if (el && machine.current.state === 'animating') {
+ if (el) {
translation.current = ZERO_POINT;
el.style.transition = '';
el.style.transform = ZERO_TRANSFORM;
- machine.current = { state: 'idle' };
setPreventClick(false);
pendingCornerUpdate.current = null;
}
}
}, [corner]);
- useLayoutEffect(() => {
- return () => {
- cancel();
- };
- }, [cancel]);
-
- const handlePointerDown: PointerEventHandler = useCallback(
- e => {
- const target = e.target as HTMLElement;
- if (target.tagName === 'A' || target.closest('a')) {
- return;
- }
-
- if (e.button !== 0) {
- return;
- }
-
+ const onPointerDown = useDragGesture({
+ onDragStart: () => {
const container = containerRef.current;
if (!container) {
return;
@@ -314,156 +266,59 @@ export function useDragToCorner(): UseDragToCornerResult {
width: container.offsetWidth,
height: container.offsetHeight,
};
-
- origin.current = { x: e.clientX, y: e.clientY };
-
- // Read the current transform from the element's style to sync translation.current
- // This ensures we start from the actual current position, not a stale value
- const currentTransform = container.style.transform;
- if (currentTransform && currentTransform !== 'none' && currentTransform !== ZERO_TRANSFORM) {
- // Parse translate3d(x, y, 0) to extract x and y values
- const match = currentTransform.match(/translate3d\(([^,]+)px,\s*([^,]+)px/);
- if (match) {
- translation.current = {
- x: parseFloat(match[1]) || 0,
- y: parseFloat(match[2]) || 0,
- };
- }
- } else {
- translation.current = ZERO_POINT;
- }
-
- machine.current = { state: 'press' };
+ dragStartTranslation.current = translation.current;
velocities.current = [];
lastTimestamp.current = Date.now();
- const handlePointerMove = (moveEvent: PointerEvent) => {
- if (machine.current.state === 'press') {
- const dx = moveEvent.clientX - origin.current.x;
- const dy = moveEvent.clientY - origin.current.y;
- const distance = Math.sqrt(dx * dx + dy * dy);
-
- if (distance < DRAG_THRESHOLD) {
- return;
- }
-
- machine.current = { state: 'drag', pointerId: moveEvent.pointerId };
- try {
- container.setPointerCapture(moveEvent.pointerId);
- } catch {
- // Pointer capture may fail - drag still works without it
- }
-
- // Disable all transitions during drag
- container.style.transition = 'none';
-
- container.classList.add('dev-tools-grabbing');
- document.body.style.userSelect = 'none';
- document.body.style.webkitUserSelect = 'none';
- setIsDragging(true);
-
- // Apply the initial movement that got us past the threshold
- setTranslation({
- x: translation.current.x + dx,
- y: translation.current.y + dy,
- });
-
- // Update origin for next calculation
- origin.current = { x: moveEvent.clientX, y: moveEvent.clientY };
- return;
- }
-
- if (machine.current.state !== 'drag') {
- return;
- }
-
- const currentPosition = { x: moveEvent.clientX, y: moveEvent.clientY };
- const dx = currentPosition.x - origin.current.x;
- const dy = currentPosition.y - origin.current.y;
-
- // Update origin for next calculation
- origin.current = currentPosition;
-
- // Apply translation relative to current position
- setTranslation({
- x: translation.current.x + dx,
- y: translation.current.y + dy,
- });
-
- const now = Date.now();
- if (now - lastTimestamp.current >= VELOCITY_SAMPLE_INTERVAL_MS) {
- velocities.current = [
- ...velocities.current.slice(-VELOCITY_HISTORY_SIZE + 1),
- { position: currentPosition, timestamp: now },
- ];
- lastTimestamp.current = now;
- }
- };
-
- const handlePointerUp = () => {
- const wasDragging = machine.current.state === 'drag';
-
- if (wasDragging) {
- const velocity = calculateVelocity(velocities.current);
- const allCorners = getCorners();
- cancel();
-
- const container = containerRef.current;
- if (!container) {
- return;
- }
-
- const projectedTranslation = {
- x: translation.current.x + project(velocity.x),
- y: translation.current.y + project(velocity.y),
- };
-
- const newCorner = getNearestCorner(projectedTranslation, allCorners);
- const targetTranslation = allCorners[newCorner];
-
- setPreventClick(true);
- animate({ corner: newCorner, translation: targetTranslation });
- } else {
- cancel();
- }
- };
-
- const handleClick = (clickEvent: MouseEvent) => {
- const target = clickEvent.target as HTMLElement;
- const isButton = target.tagName === 'BUTTON' || target.closest('button');
- const isLink = target.tagName === 'A' || target.closest('a');
-
- if (machine.current.state === 'animating' && !isButton && !isLink) {
- clickEvent.preventDefault();
- clickEvent.stopPropagation();
- }
+ // Disable all transitions during drag
+ container.style.transition = 'none';
+ setIsDragging(true);
+ },
+ onDrag: offset => {
+ const position = {
+ x: dragStartTranslation.current.x + offset.x,
+ y: dragStartTranslation.current.y + offset.y,
};
+ setTranslation(position);
- window.addEventListener('pointermove', handlePointerMove);
- window.addEventListener('pointerup', handlePointerUp, { once: true });
- window.addEventListener('pointercancel', cancel, { once: true });
- container.addEventListener('click', handleClick);
-
- if (cleanup.current) {
- cleanup.current();
+ const now = Date.now();
+ if (now - lastTimestamp.current >= VELOCITY_SAMPLE_INTERVAL_MS) {
+ velocities.current = [...velocities.current.slice(-VELOCITY_HISTORY_SIZE + 1), { position, timestamp: now }];
+ lastTimestamp.current = now;
}
+ },
+ onDragCancel: () => {
+ velocities.current = [];
+ dragStartDimensions.current = null;
+ setIsDragging(false);
+ if (containerRef.current) {
+ containerRef.current.style.transition = '';
+ }
+ },
+ onDragEnd: () => {
+ const velocity = calculateVelocity(velocities.current);
+ const allCorners = getCorners();
+ velocities.current = [];
+ dragStartDimensions.current = null;
+ setIsDragging(false);
- cleanup.current = () => {
- window.removeEventListener('pointermove', handlePointerMove);
- window.removeEventListener('pointerup', handlePointerUp);
- window.removeEventListener('pointercancel', cancel);
- container.removeEventListener('click', handleClick);
+ const projectedTranslation = {
+ x: translation.current.x + project(velocity.x),
+ y: translation.current.y + project(velocity.y),
};
+ const newCorner = getNearestCorner(projectedTranslation, allCorners);
+
+ setPreventClick(true);
+ animate({ corner: newCorner, translation: allCorners[newCorner] });
},
- [cancel, setTranslation, animate, getCorners],
- );
+ });
return {
corner,
isDragging,
cornerStyle: getCornerStyles(corner),
containerRef,
- onPointerDown: handlePointerDown,
+ onPointerDown,
preventClick,
isInitialized,
};
diff --git a/packages/ui/src/hooks/__tests__/useDragGesture.test.tsx b/packages/ui/src/hooks/__tests__/useDragGesture.test.tsx
new file mode 100644
index 00000000000..7aba8becc3d
--- /dev/null
+++ b/packages/ui/src/hooks/__tests__/useDragGesture.test.tsx
@@ -0,0 +1,239 @@
+import { fireEvent, render, within } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import type { Point } from '../useDragGesture';
+import { useDragGesture } from '../useDragGesture';
+
+type Options = Parameters[0];
+
+const DragTarget = (options: Options) => {
+ const onPointerDown = useDragGesture(options);
+ return (
+
+ );
+};
+
+const pointer = (target: EventTarget, type: string, init: MouseEventInit & { pointerId?: number } = {}) => {
+ const event = new MouseEvent(type, { bubbles: true, cancelable: true, ...init });
+ Object.defineProperty(event, 'pointerId', { value: init.pointerId ?? 1 });
+ fireEvent(target, event);
+};
+
+const renderDragTarget = (onDragCancel?: () => void) => {
+ const onDragStart = vi.fn();
+ const onDrag = vi.fn<(offset: Point) => void>();
+ const onDragEnd = vi.fn();
+ const result = render(
+ ,
+ );
+ const target = within(result.container).getByTestId('target');
+ const captured = new Set();
+ Object.assign(target, {
+ setPointerCapture: (id: number) => captured.add(id),
+ hasPointerCapture: (id: number) => captured.has(id),
+ releasePointerCapture: (id: number) => captured.delete(id),
+ });
+ return { ...result, target, captured, onDragStart, onDrag, onDragEnd };
+};
+
+describe('useDragGesture', () => {
+ afterEach(() => {
+ pointer(window, 'pointercancel');
+ pointer(window, 'pointercancel', { pointerId: 2 });
+ document.body.style.removeProperty('user-select');
+ document.body.style.webkitUserSelect = '';
+ });
+
+ it('treats a press that moves less than the threshold as a click', () => {
+ const { target, onDragStart, onDrag, onDragEnd } = renderDragTarget();
+ pointer(target, 'pointerdown', { clientX: 100, clientY: 100 });
+ pointer(window, 'pointermove', { clientX: 103, clientY: 103 });
+ pointer(window, 'pointerup');
+ expect(onDragStart).not.toHaveBeenCalled();
+ expect(onDrag).not.toHaveBeenCalled();
+ expect(onDragEnd).not.toHaveBeenCalled();
+ });
+
+ it('starts dragging past the threshold and reports offsets from the press origin', () => {
+ const { target, captured, onDragStart, onDrag } = renderDragTarget();
+ pointer(target, 'pointerdown', { clientX: 100, clientY: 100 });
+ pointer(window, 'pointermove', { clientX: 106, clientY: 100 });
+ pointer(window, 'pointermove', { clientX: 80.5, clientY: 130.25 });
+ expect(onDragStart).toHaveBeenCalledTimes(1);
+ expect(onDrag.mock.calls).toEqual([[{ x: 6, y: 0 }], [{ x: -19.5, y: 30.25 }]]);
+ expect(captured.has(1)).toBe(true);
+ expect(document.body.style.getPropertyValue('user-select')).toBe('none');
+ expect(document.body.style.webkitUserSelect).toBe('none');
+ });
+
+ it('starts dragging exactly at the diagonal threshold', () => {
+ const { target, onDragStart, onDrag } = renderDragTarget();
+ pointer(target, 'pointerdown', { clientX: 100, clientY: 100 });
+ pointer(window, 'pointermove', { clientX: 103, clientY: 104 });
+ expect(onDragStart).toHaveBeenCalledTimes(1);
+ expect(onDrag).toHaveBeenCalledWith({ x: 3, y: 4 });
+ });
+
+ it.each(['pointerup', 'pointercancel'])('ends the drag on %s and restores selection', type => {
+ const { target, captured, onDrag, onDragEnd } = renderDragTarget();
+ pointer(target, 'pointerdown', { clientX: 100, clientY: 100 });
+ pointer(window, 'pointermove', { clientX: 120, clientY: 100 });
+ pointer(window, type);
+ pointer(window, 'pointermove', { clientX: 200, clientY: 200 });
+ expect(onDragEnd).toHaveBeenCalledTimes(1);
+ expect(onDrag).toHaveBeenCalledTimes(1);
+ expect(captured.size).toBe(0);
+ expect(document.body.style.getPropertyValue('user-select')).toBe('');
+ expect(document.body.style.webkitUserSelect).toBe('');
+ });
+
+ it.each(['pointerup', 'pointercancel', 'unmount', 'replacement press'])(
+ 'restores existing inline selection styles after %s',
+ reason => {
+ const { target, unmount } = renderDragTarget();
+ document.body.style.userSelect = 'text';
+ document.body.style.webkitUserSelect = 'all';
+ pointer(target, 'pointerdown', { clientX: 100, clientY: 100 });
+ pointer(window, 'pointermove', { clientX: 120, clientY: 100 });
+ expect(document.body.style.userSelect).toBe('none');
+ expect(document.body.style.webkitUserSelect).toBe('none');
+ if (reason === 'unmount') {
+ unmount();
+ } else if (reason === 'replacement press') {
+ pointer(target, 'pointerdown', { clientX: 120, clientY: 100 });
+ } else {
+ pointer(window, reason);
+ }
+ expect(document.body.style.userSelect).toBe('text');
+ expect(document.body.style.webkitUserSelect).toBe('all');
+ },
+ );
+
+ it('captures selection styles when dragging starts rather than on pointer down', () => {
+ const { target } = renderDragTarget();
+ pointer(target, 'pointerdown', { clientX: 100, clientY: 100 });
+ document.body.style.userSelect = 'all';
+ document.body.style.webkitUserSelect = 'text';
+ pointer(window, 'pointermove', { clientX: 120, clientY: 100 });
+ pointer(window, 'pointerup');
+ expect(document.body.style.userSelect).toBe('all');
+ expect(document.body.style.webkitUserSelect).toBe('text');
+ });
+
+ it.each([
+ [0, 'pointerup'],
+ [1, 'pointerup'],
+ [0, 'pointercancel'],
+ [1, 'pointercancel'],
+ [0, 'unmount'],
+ [1, 'unmount'],
+ [0, 'replacement press'],
+ [1, 'replacement press'],
+ ] as const)('keeps selection locked until both drags end (%s, %s)', (firstToEnd, reason) => {
+ const targets = [renderDragTarget(), renderDragTarget()];
+ document.body.style.userSelect = 'text';
+ document.body.style.webkitUserSelect = 'all';
+ targets.forEach(({ target }, index) => {
+ pointer(target, 'pointerdown', { pointerId: index + 1, clientX: 100, clientY: 100 });
+ pointer(window, 'pointermove', { pointerId: index + 1, clientX: 120, clientY: 100 });
+ });
+ if (reason === 'unmount') {
+ targets[firstToEnd].unmount();
+ } else if (reason === 'replacement press') {
+ pointer(targets[firstToEnd].target, 'pointerdown', { pointerId: firstToEnd + 1 });
+ } else {
+ pointer(window, reason, { pointerId: firstToEnd + 1 });
+ }
+ expect(document.body.style.userSelect).toBe('none');
+ expect(document.body.style.webkitUserSelect).toBe('none');
+ pointer(window, 'pointerup', { pointerId: 2 - firstToEnd });
+ expect(document.body.style.userSelect).toBe('text');
+ expect(document.body.style.webkitUserSelect).toBe('all');
+ });
+
+ it('does not unlock an active drag when another target is only clicked', () => {
+ const first = renderDragTarget();
+ const second = renderDragTarget();
+ document.body.style.userSelect = 'text';
+ document.body.style.webkitUserSelect = 'all';
+ pointer(first.target, 'pointerdown', { pointerId: 1, clientX: 100, clientY: 100 });
+ pointer(window, 'pointermove', { pointerId: 1, clientX: 120, clientY: 100 });
+ pointer(second.target, 'pointerdown', { pointerId: 2 });
+ pointer(window, 'pointerup', { pointerId: 2 });
+ expect(document.body.style.userSelect).toBe('none');
+ expect(document.body.style.webkitUserSelect).toBe('none');
+ pointer(window, 'pointerup', { pointerId: 1 });
+ expect(document.body.style.userSelect).toBe('text');
+ expect(document.body.style.webkitUserSelect).toBe('all');
+ });
+
+ it('ignores other pointers', () => {
+ const { target, onDrag, onDragEnd } = renderDragTarget();
+ pointer(target, 'pointerdown', { clientX: 100, clientY: 100 });
+ pointer(window, 'pointermove', { clientX: 200, clientY: 200, pointerId: 2 });
+ pointer(window, 'pointerup', { pointerId: 2 });
+ expect(onDrag).not.toHaveBeenCalled();
+ pointer(window, 'pointermove', { clientX: 120, clientY: 100 });
+ expect(onDrag).toHaveBeenCalledWith({ x: 20, y: 0 });
+ expect(onDragEnd).not.toHaveBeenCalled();
+ });
+
+ it('does not start from a secondary button or a link', () => {
+ const { target, getByText, onDrag } = renderDragTarget();
+ pointer(target, 'pointerdown', { button: 2, clientX: 100, clientY: 100 });
+ pointer(window, 'pointermove', { clientX: 200, clientY: 200 });
+ pointer(getByText('link'), 'pointerdown', { clientX: 100, clientY: 100 });
+ pointer(window, 'pointermove', { clientX: 200, clientY: 200 });
+ expect(onDrag).not.toHaveBeenCalled();
+ });
+
+ it('ends the drag when unmounted', () => {
+ const { target, unmount, onDrag, onDragEnd } = renderDragTarget();
+ pointer(target, 'pointerdown', { clientX: 100, clientY: 100 });
+ pointer(window, 'pointermove', { clientX: 120, clientY: 100 });
+ unmount();
+ pointer(window, 'pointermove', { clientX: 200, clientY: 200 });
+ expect(onDragEnd).toHaveBeenCalledTimes(1);
+ expect(onDrag).toHaveBeenCalledTimes(1);
+ expect(document.body.style.getPropertyValue('user-select')).toBe('');
+ });
+
+ it.each(['pointercancel', 'unmount', 'replacement press'])('cancels without committing on %s', reason => {
+ const onDragCancel = vi.fn();
+ const { target, captured, unmount, onDragEnd } = renderDragTarget(onDragCancel);
+ pointer(target, 'pointerdown', { clientX: 100, clientY: 100 });
+ pointer(window, 'pointermove', { clientX: 120, clientY: 100 });
+ if (reason === 'unmount') {
+ unmount();
+ } else if (reason === 'replacement press') {
+ pointer(target, 'pointerdown', { clientX: 120, clientY: 100 });
+ } else {
+ pointer(window, 'pointercancel');
+ }
+ expect(onDragCancel).toHaveBeenCalledTimes(1);
+ expect(onDragEnd).not.toHaveBeenCalled();
+ expect(captured.size).toBe(0);
+ expect(document.body.style.getPropertyValue('user-select')).toBe('');
+ });
+
+ it('commits a normal release when a cancellation callback is provided', () => {
+ const onDragCancel = vi.fn();
+ const { target, onDragEnd } = renderDragTarget(onDragCancel);
+ pointer(target, 'pointerdown', { clientX: 100, clientY: 100 });
+ pointer(window, 'pointermove', { clientX: 120, clientY: 100 });
+ pointer(window, 'pointerup');
+ expect(onDragEnd).toHaveBeenCalledTimes(1);
+ expect(onDragCancel).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/ui/src/hooks/__tests__/usePrefersReducedMotion.test.ts b/packages/ui/src/hooks/__tests__/usePrefersReducedMotion.test.ts
new file mode 100644
index 00000000000..fff9e8d0c76
--- /dev/null
+++ b/packages/ui/src/hooks/__tests__/usePrefersReducedMotion.test.ts
@@ -0,0 +1,43 @@
+import { act, renderHook } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { usePrefersReducedMotion } from '../usePrefersReducedMotion';
+
+describe('usePrefersReducedMotion', () => {
+ afterEach(() => vi.restoreAllMocks());
+
+ it.each(['modern', 'legacy'])('tracks preference changes and removes its %s listener on unmount', mode => {
+ const events = new EventTarget();
+ const query = {
+ matches: true,
+ ...(mode === 'modern'
+ ? {
+ addEventListener: vi.fn(events.addEventListener.bind(events)),
+ removeEventListener: vi.fn(events.removeEventListener.bind(events)),
+ }
+ : {
+ addListener: vi.fn((listener: EventListener) => events.addEventListener('change', listener)),
+ removeListener: vi.fn((listener: EventListener) => events.removeEventListener('change', listener)),
+ }),
+ };
+ vi.spyOn(window, 'matchMedia').mockReturnValue(query as unknown as MediaQueryList);
+ const { result, unmount } = renderHook(usePrefersReducedMotion);
+ expect(result.current).toBe(false);
+
+ act(() => {
+ query.matches = false;
+ events.dispatchEvent(new Event('change'));
+ });
+ expect(result.current).toBe(true);
+
+ act(() => {
+ query.matches = true;
+ events.dispatchEvent(new Event('change'));
+ });
+ expect(result.current).toBe(false);
+
+ unmount();
+ const removeListener = mode === 'modern' ? query.removeEventListener : query.removeListener;
+ expect(removeListener).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/ui/src/hooks/useDragGesture.ts b/packages/ui/src/hooks/useDragGesture.ts
new file mode 100644
index 00000000000..32dc2cb5aff
--- /dev/null
+++ b/packages/ui/src/hooks/useDragGesture.ts
@@ -0,0 +1,91 @@
+import { noop } from '@clerk/shared/utils';
+import type { PointerEvent as ReactPointerEvent } from 'react';
+import { useEffect, useRef } from 'react';
+
+const DRAG_THRESHOLD = 5;
+let activeDrags = 0;
+let originalSelection: Pick;
+
+export type Point = { x: number; y: number };
+
+type UseDragGestureOptions = {
+ onDragStart?: () => void;
+ onDrag: (offset: Point) => void;
+ onDragEnd?: () => void;
+ onDragCancel?: () => void;
+};
+
+export const useDragGesture = ({
+ onDragStart,
+ onDrag,
+ onDragEnd,
+ onDragCancel,
+}: UseDragGestureOptions): ((e: ReactPointerEvent) => void) => {
+ const endGesture = useRef<() => void>(noop);
+
+ useEffect(() => () => endGesture.current(), []);
+
+ const onPointerDown = (e: ReactPointerEvent): void => {
+ if (e.button !== 0 || (e.target instanceof Element && e.target.closest('a'))) {
+ return;
+ }
+ endGesture.current();
+
+ const element = e.currentTarget;
+ const bodyStyle = document.body.style;
+ const { pointerId } = e;
+ const originX = e.clientX;
+ const originY = e.clientY;
+ let dragging = false;
+
+ const onPointerMove = (moveEvent: PointerEvent): void => {
+ if (moveEvent.pointerId !== pointerId) {
+ return;
+ }
+ const x = moveEvent.clientX - originX;
+ const y = moveEvent.clientY - originY;
+ if (!dragging) {
+ if (x * x + y * y < DRAG_THRESHOLD * DRAG_THRESHOLD) {
+ return;
+ }
+ element.setPointerCapture(pointerId);
+ dragging = true;
+ if (++activeDrags === 1) {
+ originalSelection = { userSelect: bodyStyle.userSelect, webkitUserSelect: bodyStyle.webkitUserSelect };
+ bodyStyle.userSelect = bodyStyle.webkitUserSelect = 'none';
+ }
+ onDragStart?.();
+ }
+ onDrag({ x, y });
+ };
+
+ const end = (endEvent?: PointerEvent): void => {
+ if (endEvent && endEvent.pointerId !== pointerId) {
+ return;
+ }
+ window.removeEventListener('pointermove', onPointerMove);
+ window.removeEventListener('pointerup', end);
+ window.removeEventListener('pointercancel', end);
+ endGesture.current = noop;
+ if (!dragging) {
+ return;
+ }
+ dragging = false;
+ if (element.hasPointerCapture(pointerId)) {
+ element.releasePointerCapture(pointerId);
+ }
+ if (--activeDrags === 0) {
+ Object.assign(bodyStyle, originalSelection);
+ }
+ const onEnd = endEvent && endEvent.type === 'pointerup' ? onDragEnd : onDragCancel || onDragEnd;
+ onEnd?.();
+ };
+
+ window.addEventListener('pointermove', onPointerMove);
+ window.addEventListener('pointerup', end);
+ window.addEventListener('pointercancel', end);
+ endGesture.current = end;
+ };
+
+ return onPointerDown;
+};
diff --git a/packages/ui/src/hooks/usePrefersReducedMotion.ts b/packages/ui/src/hooks/usePrefersReducedMotion.ts
index c7fa058a2b8..8316375c071 100644
--- a/packages/ui/src/hooks/usePrefersReducedMotion.ts
+++ b/packages/ui/src/hooks/usePrefersReducedMotion.ts
@@ -2,47 +2,25 @@ import { useEffect, useState } from 'react';
const mediaQueryNoPreference = '(prefers-reduced-motion: no-preference)';
-// Get the correct initial value instead of defaulting to true
-const getInitialValue = () => {
- if (typeof window === 'undefined') {
- return true;
- } // SSR fallback
- return !window.matchMedia(mediaQueryNoPreference).matches;
-};
-
-export function usePrefersReducedMotion() {
- const [prefersReducedMotion, setPrefersReducedMotion] = useState(getInitialValue);
+export function usePrefersReducedMotion(): boolean {
+ const [prefersReducedMotion, setPrefersReducedMotion] = useState(
+ () => typeof window === 'undefined' || !window.matchMedia(mediaQueryNoPreference).matches,
+ );
useEffect(() => {
const mediaQueryList = window.matchMedia(mediaQueryNoPreference);
- setPrefersReducedMotion(!window.matchMedia(mediaQueryNoPreference).matches);
-
- const listener = (event: MediaQueryListEvent) => {
- setPrefersReducedMotion(!event.matches);
+ const listener = () => {
+ setPrefersReducedMotion(!mediaQueryList.matches);
};
+ listener();
- const addListenerCompat = (event: any, handler: any) => {
- if ('addEventListener' in mediaQueryList) {
- return mediaQueryList.addEventListener(event, handler);
- } else {
- // @ts-expect-error This is a fix for Safari iOS 13.4
- // This version only supports the deprecated addListener method
- // addListener accepts a single argument, which is the handler
- return mediaQueryList.addListener(handler);
- }
- };
-
- const removeListenerCompat = (event: any, handler: any) => {
- if ('addEventListener' in mediaQueryList) {
- return mediaQueryList.removeEventListener(event, handler);
- } else {
- // @ts-ignore same as above
- return mediaQueryList.removeListener(handler);
- }
- };
+ if ('addEventListener' in mediaQueryList) {
+ mediaQueryList.addEventListener('change', listener);
+ return () => mediaQueryList.removeEventListener('change', listener);
+ }
- addListenerCompat('change', listener);
- return () => removeListenerCompat('change', listener);
+ (mediaQueryList as MediaQueryList).addListener(listener);
+ return () => (mediaQueryList as MediaQueryList).removeListener(listener);
}, []);
return prefersReducedMotion;