diff --git a/.changeset/mighty-pigs-help.md b/.changeset/mighty-pigs-help.md
new file mode 100644
index 00000000000..b522d86a3f3
--- /dev/null
+++ b/.changeset/mighty-pigs-help.md
@@ -0,0 +1,5 @@
+---
+'@clerk/ui': patch
+---
+
+Add drag to corner functionality to the KeylessPrompt
diff --git a/packages/clerk-js/sandbox/template.html b/packages/clerk-js/sandbox/template.html
index 7e35aa1331b..d8ff8041392 100644
--- a/packages/clerk-js/sandbox/template.html
+++ b/packages/clerk-js/sandbox/template.html
@@ -1,8 +1,5 @@
-
+
clerk-js Sandbox
diff --git a/packages/ui/src/components/devPrompts/KeylessPrompt/__tests__/KeylessPrompt.test.tsx b/packages/ui/src/components/devPrompts/KeylessPrompt/__tests__/KeylessPrompt.test.tsx
index d0ab9733be0..dc7da670125 100644
--- a/packages/ui/src/components/devPrompts/KeylessPrompt/__tests__/KeylessPrompt.test.tsx
+++ b/packages/ui/src/components/devPrompts/KeylessPrompt/__tests__/KeylessPrompt.test.tsx
@@ -1,10 +1,18 @@
import React from 'react';
-import { describe, expect, it, vi } from 'vitest';
+import { afterEach, describe, expect, it, vi } from 'vitest';
import { bindCreateFixtures } from '@/test/create-fixtures';
import { render } from '@/test/utils';
import { getCurrentState, getResolvedContent, KeylessPrompt } from '../index';
+import {
+ calculateVelocity,
+ getCornerStyles,
+ getNearestCorner,
+ project,
+ saveCornerPreference,
+ STORAGE_KEY,
+} from '../use-drag-to-corner';
const { createFixtures } = bindCreateFixtures('KeylessPrompt' as any);
@@ -333,3 +341,118 @@ describe('KeylessPrompt component', () => {
expect(getByText(/Add SSO connections/i)).toBeInTheDocument();
});
});
+
+describe('useDragToCorner utilities', () => {
+ describe('getNearestCorner', () => {
+ const corners = {
+ 'top-left': { x: -500, y: -500 },
+ 'top-right': { x: 500, y: -500 },
+ 'bottom-left': { x: -500, y: 500 },
+ 'bottom-right': { x: 0, y: 0 },
+ } as const;
+
+ it('returns the nearest corner to a given translation', () => {
+ expect(getNearestCorner({ x: -400, y: -400 }, corners)).toBe('top-left');
+ expect(getNearestCorner({ x: 400, y: -400 }, corners)).toBe('top-right');
+ expect(getNearestCorner({ x: -400, y: 400 }, corners)).toBe('bottom-left');
+ expect(getNearestCorner({ x: 10, y: 10 }, corners)).toBe('bottom-right');
+ });
+
+ it('returns the exact corner when translation matches', () => {
+ expect(getNearestCorner({ x: 0, y: 0 }, corners)).toBe('bottom-right');
+ expect(getNearestCorner({ x: -500, y: -500 }, corners)).toBe('top-left');
+ });
+
+ it('handles equidistant case by returning the first found', () => {
+ // When equidistant from multiple corners, the iteration order determines the result
+ const result = getNearestCorner({ x: 0, y: -500 }, corners);
+ expect(['top-left', 'top-right']).toContain(result);
+ });
+ });
+
+ describe('getCornerStyles', () => {
+ it('returns correct CSS for top-left', () => {
+ expect(getCornerStyles('top-left')).toEqual({ top: '1.25rem', left: '1.25rem' });
+ });
+
+ it('returns correct CSS for top-right', () => {
+ expect(getCornerStyles('top-right')).toEqual({ top: '1.25rem', right: '1.25rem' });
+ });
+
+ it('returns correct CSS for bottom-left', () => {
+ expect(getCornerStyles('bottom-left')).toEqual({ bottom: '1.25rem', left: '1.25rem' });
+ });
+
+ it('returns correct CSS for bottom-right', () => {
+ expect(getCornerStyles('bottom-right')).toEqual({ bottom: '1.25rem', right: '1.25rem' });
+ });
+ });
+
+ describe('saveCornerPreference', () => {
+ afterEach(() => {
+ localStorage.removeItem(STORAGE_KEY);
+ });
+
+ it('saves corner to localStorage', () => {
+ saveCornerPreference('top-left');
+ expect(localStorage.getItem(STORAGE_KEY)).toBe('top-left');
+ });
+
+ it('overwrites previous value', () => {
+ saveCornerPreference('top-left');
+ saveCornerPreference('bottom-right');
+ expect(localStorage.getItem(STORAGE_KEY)).toBe('bottom-right');
+ });
+ });
+
+ describe('project', () => {
+ it('returns 0 for zero velocity', () => {
+ expect(project(0)).toBe(0);
+ });
+
+ it('returns positive value for positive velocity', () => {
+ expect(project(1000)).toBeGreaterThan(0);
+ });
+
+ it('returns negative value for negative velocity', () => {
+ expect(project(-1000)).toBeLessThan(0);
+ });
+
+ it('scales proportionally with velocity', () => {
+ const single = project(500);
+ const double = project(1000);
+ expect(double).toBeCloseTo(single * 2);
+ });
+ });
+
+ describe('calculateVelocity', () => {
+ it('returns zero for empty history', () => {
+ expect(calculateVelocity([])).toEqual({ x: 0, y: 0 });
+ });
+
+ it('returns zero for single entry', () => {
+ expect(calculateVelocity([{ position: { x: 10, y: 20 }, timestamp: 100 }])).toEqual({ x: 0, y: 0 });
+ });
+
+ it('returns zero when time delta is zero', () => {
+ const history = [
+ { position: { x: 0, y: 0 }, timestamp: 100 },
+ { position: { x: 50, y: 50 }, timestamp: 100 },
+ ];
+ expect(calculateVelocity(history)).toEqual({ x: 0, y: 0 });
+ });
+
+ it('calculates velocity correctly using first and last entries', () => {
+ const history = [
+ { position: { x: 0, y: 0 }, timestamp: 0 },
+ { position: { x: 50, y: 50 }, timestamp: 50 },
+ { position: { x: 100, y: 200 }, timestamp: 100 },
+ ];
+ const velocity = calculateVelocity(history);
+ // (100 - 0) / 100 * 1000 = 1000
+ expect(velocity.x).toBe(1000);
+ // (200 - 0) / 100 * 1000 = 2000
+ expect(velocity.y).toBe(2000);
+ });
+ });
+});
diff --git a/packages/ui/src/components/devPrompts/KeylessPrompt/index.tsx b/packages/ui/src/components/devPrompts/KeylessPrompt/index.tsx
index 5bd8d6517ce..e9e6854c03c 100644
--- a/packages/ui/src/components/devPrompts/KeylessPrompt/index.tsx
+++ b/packages/ui/src/components/devPrompts/KeylessPrompt/index.tsx
@@ -5,6 +5,7 @@ import { type ReactNode, useId, useMemo, useState } from 'react';
import { InternalThemeProvider } from '../../../styledSystem';
import { handleDashboardUrlParsing } from '../shared';
+import { useDragToCorner } from './use-drag-to-corner';
import { useRevalidateEnvironment } from './use-revalidate-environment';
type KeylessPromptProps = {
@@ -287,6 +288,7 @@ export function getResolvedContent(state: STATES, context: ResolvedContentContex
function KeylessPromptInternal(props: KeylessPromptProps) {
const id = useId();
const environment = useRevalidateEnvironment();
+ const { isDragging, cornerStyle, containerRef, onPointerDown, preventClick, isInitialized } = useDragToCorner();
const claimed = Boolean(environment.authConfig.claimedAt);
const success = typeof props.onDismiss === 'function' && claimed;
@@ -348,12 +350,16 @@ function KeylessPromptInternal(props: KeylessPromptProps) {
return (
setIsOpen(p => !p)}
+ onClick={() => {
+ if (preventClick) {
+ return;
+ }
+ setIsOpen(p => !p);
+ }}
css={css`
${CSS_RESET};
display: flex;
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
new file mode 100644
index 00000000000..469c6cf0b40
--- /dev/null
+++ b/packages/ui/src/components/devPrompts/KeylessPrompt/use-drag-to-corner.ts
@@ -0,0 +1,470 @@
+import type { PointerEventHandler } from 'react';
+import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
+
+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;
+const SPRING_DURATION = '350ms';
+const SPRING_EASING = 'cubic-bezier(0.34, 1.2, 0.64, 1)';
+const SPRING_TRANSITION = `transform ${SPRING_DURATION} ${SPRING_EASING}`;
+const ZERO_TRANSFORM = 'translate3d(0px, 0px, 0)';
+const ZERO_POINT: Point = { x: 0, y: 0 };
+
+interface Point {
+ x: number;
+ y: number;
+}
+
+interface Velocity {
+ position: Point;
+ timestamp: number;
+}
+
+interface CornerTranslation {
+ corner: Corner;
+ translation: Point;
+}
+
+interface UseDragToCornerResult {
+ corner: Corner;
+ isDragging: boolean;
+ cornerStyle: React.CSSProperties;
+ containerRef: React.RefObject
;
+ onPointerDown: PointerEventHandler;
+ preventClick: boolean;
+ isInitialized: boolean;
+}
+
+export function getNearestCorner(projectedTranslation: Point, corners: Record): Corner {
+ let nearestCorner: Corner = 'bottom-right';
+ let minDistance = Infinity;
+
+ for (const [corner, translation] of Object.entries(corners)) {
+ const dx = projectedTranslation.x - translation.x;
+ const dy = projectedTranslation.y - translation.y;
+ const distance = Math.sqrt(dx * dx + dy * dy);
+ if (distance < minDistance) {
+ minDistance = distance;
+ nearestCorner = corner as Corner;
+ }
+ }
+
+ return nearestCorner;
+}
+
+export function getCornerStyles(corner: Corner): React.CSSProperties {
+ switch (corner) {
+ case 'top-left':
+ // eslint-disable-next-line -- Corner positioning should not be affected by RTL
+ return { top: CORNER_OFFSET, left: CORNER_OFFSET };
+ case 'top-right':
+ // eslint-disable-next-line -- Corner positioning should not be affected by RTL
+ return { top: CORNER_OFFSET, right: CORNER_OFFSET };
+ case 'bottom-left':
+ // eslint-disable-next-line -- Corner positioning should not be affected by RTL
+ return { bottom: CORNER_OFFSET, left: CORNER_OFFSET };
+ case 'bottom-right':
+ // eslint-disable-next-line -- Corner positioning should not be affected by RTL
+ return { bottom: CORNER_OFFSET, right: CORNER_OFFSET };
+ }
+}
+
+const VALID_CORNERS: Corner[] = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
+
+export function saveCornerPreference(corner: Corner): void {
+ if (typeof window === 'undefined') {
+ return;
+ }
+ try {
+ localStorage.setItem(STORAGE_KEY, corner);
+ } catch {
+ // Ignore localStorage errors
+ }
+}
+
+export function project(initialVelocity: number): number {
+ return ((initialVelocity / 1000) * INERTIA_DECELERATION_RATE) / (1 - INERTIA_DECELERATION_RATE);
+}
+
+export function calculateVelocity(history: Velocity[]): Point {
+ if (history.length < 2) {
+ return ZERO_POINT;
+ }
+
+ const oldest = history[0];
+ const latest = history[history.length - 1];
+ const timeDelta = latest.timestamp - oldest.timestamp;
+
+ if (timeDelta === 0) {
+ return ZERO_POINT;
+ }
+
+ return {
+ x: ((latest.position.x - oldest.position.x) / timeDelta) * 1000,
+ y: ((latest.position.y - oldest.position.y) / timeDelta) * 1000,
+ };
+}
+
+export function useDragToCorner(): UseDragToCornerResult {
+ // Initialize with deterministic server-safe value to avoid SSR/hydration mismatch
+ const [corner, setCorner] = useState('bottom-right');
+ const [isDragging, setIsDragging] = useState(false);
+ const [preventClick, setPreventClick] = useState(false);
+ const [isInitialized, setIsInitialized] = useState(false);
+ const pendingCornerUpdate = useRef(null);
+
+ // Defer localStorage read to client-side only after mount
+ useEffect(() => {
+ if (typeof window === 'undefined') {
+ setIsInitialized(true);
+ return;
+ }
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ if (stored && VALID_CORNERS.includes(stored as Corner)) {
+ setCorner(stored as Corner);
+ }
+ } catch {
+ // Ignore localStorage errors
+ } finally {
+ setIsInitialized(true);
+ }
+ }, []);
+
+ 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 lastTimestamp = useRef(0);
+ const velocities = useRef([]);
+ const dragStartDimensions = useRef<{ width: number; height: number } | null>(null);
+
+ const setTranslation = useCallback((position: Point) => {
+ if (!containerRef.current) {
+ return;
+ }
+ translation.current = position;
+ containerRef.current.style.transform = `translate3d(${position.x}px, ${position.y}px, 0)`;
+ }, []);
+
+ const getCorners = useCallback((): Record => {
+ const container = containerRef.current;
+ if (!container) {
+ return {
+ 'top-left': ZERO_POINT,
+ 'top-right': ZERO_POINT,
+ 'bottom-left': ZERO_POINT,
+ 'bottom-right': ZERO_POINT,
+ };
+ }
+
+ // Use stored dimensions if available (during drag), otherwise read current dimensions
+ const triggerWidth = dragStartDimensions.current?.width ?? container.offsetWidth ?? 0;
+ const triggerHeight = dragStartDimensions.current?.height ?? container.offsetHeight ?? 0;
+ const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
+
+ function getAbsolutePosition(c: Corner): Point {
+ const isRight = c.includes('right');
+ const isBottom = c.includes('bottom');
+ return {
+ x: isRight ? window.innerWidth - scrollbarWidth - CORNER_OFFSET_PX - triggerWidth : CORNER_OFFSET_PX,
+ y: isBottom ? window.innerHeight - CORNER_OFFSET_PX - triggerHeight : CORNER_OFFSET_PX,
+ };
+ }
+
+ const base = getAbsolutePosition(corner);
+
+ function toRelative(c: Corner): Point {
+ const pos = getAbsolutePosition(c);
+ return { x: pos.x - base.x, y: pos.y - base.y };
+ }
+
+ return {
+ 'top-left': toRelative('top-left'),
+ 'top-right': toRelative('top-right'),
+ 'bottom-left': toRelative('bottom-left'),
+ 'bottom-right': toRelative('bottom-right'),
+ };
+ }, [corner]);
+
+ const animate = useCallback(
+ (cornerTranslation: CornerTranslation) => {
+ const el = containerRef.current;
+ if (!el) {
+ return;
+ }
+
+ // Skip animation if already at target (transitionend won't fire)
+ const dx = cornerTranslation.translation.x - translation.current.x;
+ const dy = cornerTranslation.translation.y - translation.current.y;
+ if (Math.sqrt(dx * dx + dy * dy) < 0.5) {
+ saveCornerPreference(cornerTranslation.corner);
+ translation.current = ZERO_POINT;
+ el.style.transition = '';
+ el.style.transform = ZERO_TRANSFORM;
+ machine.current = { state: 'idle' };
+ setPreventClick(false);
+ return;
+ }
+
+ const handleAnimationEnd = (e: TransitionEvent) => {
+ // Ignore non-transform transitions (width, border-radius from Emotion CSS)
+ if (e.propertyName !== 'transform') {
+ return;
+ }
+ el.removeEventListener('transitionend', handleAnimationEnd);
+
+ saveCornerPreference(cornerTranslation.corner);
+
+ if (cornerTranslation.corner === corner) {
+ // Same corner — clean up directly, no CSS repositioning needed
+ 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);
+ }
+ };
+
+ el.style.transition = SPRING_TRANSITION;
+ el.addEventListener('transitionend', handleAnimationEnd);
+ setTranslation(cornerTranslation.translation);
+ },
+ [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') {
+ 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 container = containerRef.current;
+ if (!container) {
+ return;
+ }
+
+ // Store dimensions at drag start to prevent issues with animating width
+ dragStartDimensions.current = {
+ 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' };
+ 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();
+ }
+ };
+
+ 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();
+ }
+
+ cleanup.current = () => {
+ window.removeEventListener('pointermove', handlePointerMove);
+ window.removeEventListener('pointerup', handlePointerUp);
+ window.removeEventListener('pointercancel', cancel);
+ container.removeEventListener('click', handleClick);
+ };
+ },
+ [cancel, setTranslation, animate, getCorners],
+ );
+
+ return {
+ corner,
+ isDragging,
+ cornerStyle: getCornerStyles(corner),
+ containerRef,
+ onPointerDown: handlePointerDown,
+ preventClick,
+ isInitialized,
+ };
+}