diff --git a/frontend/apps/artcraft-webapp/src/components/motion/reveal.tsx b/frontend/apps/artcraft-webapp/src/components/motion/reveal.tsx
index c208ee26d3..9a4a118b1d 100644
--- a/frontend/apps/artcraft-webapp/src/components/motion/reveal.tsx
+++ b/frontend/apps/artcraft-webapp/src/components/motion/reveal.tsx
@@ -16,6 +16,23 @@ import type { ElementType, ReactNode } from "react";
import { motion, useReducedMotion, type Variants } from "framer-motion";
import { DUR_SLOW, EASE_OUT } from "../../lib/motion";
+// `motion(as)` returns a BRAND-NEW component on every call. Calling it during
+// render would hand each render a different component type, so React would
+// unmount and remount the whole subtree on every re-render — stealing focus
+// from any inside (this is what broke the login form: typing a
+// character re-rendered the page and blurred the field). Cache one motion
+// component per element type so the reference stays stable across renders.
+const motionTagCache = new Map();
+
+function motionTag(as: ElementType): ElementType {
+ let cached = motionTagCache.get(as);
+ if (!cached) {
+ cached = motion(as) as ElementType;
+ motionTagCache.set(as, cached);
+ }
+ return cached;
+}
+
interface RevealProps {
children: ReactNode;
/** Render as a different element/component (e.g. "section", "li", Link). */
@@ -49,7 +66,7 @@ export function RevealGroup({
className?: string;
}) {
const reduceMotion = useReducedMotion();
- const MotionTag = motion(as);
+ const MotionTag = motionTag(as);
const variants: Variants = {
initial: {},
@@ -83,7 +100,7 @@ export function Reveal({
className,
}: RevealProps) {
const reduceMotion = useReducedMotion();
- const MotionTag = motion(as);
+ const MotionTag = motionTag(as);
const variants: Variants = {
initial: reduceMotion ? { opacity: 0 } : { opacity: 0, y },