Fix login modal: stop Reveal motion wrappers from stealing input focus (#1698)

Reveal/RevealGroup called `motion(as)` inside the render body. Unlike
the cached `motion.div` accessor, calling `motion()` as a function
returns a brand-new component type on every invocation. So each
re-render produced a different element type at that position, React
unmounted and remounted the whole subtree, and any focused <input>
inside (the login/signup/forgot-password forms) lost focus on every
keystroke — you could only type one character at a time.

Cache one motion component per element type via a module-level map so
the component reference stays stable across renders. React now
reconciles in place and focus is preserved.

Verified with a Playwright A/B test on the login form: before the fix
typing "test@example.com" left only "t" with focus on <body> (1 blur
mid-type); after the fix the full value is retained, focus stays on the
input, zero blurs.
This commit is contained in:
Tanish Manku authored and GitHub committed 2026-06-25 13:48:53 -07:00
1 parent fef83ad271
commit ad5ae7968f
4 files changed
+234 -173

No files matched your search

@@ -18,6 +18,7 @@ import {
import { refreshSession } from "../../lib/session";
import { hasActiveSubscription } from "../../lib/billing";
import { GoogleLoginButton } from "./GoogleLoginButton";
import { Reveal, RevealGroup } from "../motion/reveal";
interface SignupFormProps {
onSuccess: (isNewUser?: boolean) => void;
@@ -115,74 +116,97 @@ export const SignupForm = ({
</div>
)}
<div className="space-y-2">
<label className="text-xs font-semibold text-white/70 ml-1">
Email
</label>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
autoFocus={autoFocus}
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
/>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center ml-1">
<label className="text-xs font-semibold text-white/70">
Password
{/* Fields cascade in as their own beat, leading with the email field on
an almost-zero delay so the primary input lands immediately — the
entrance never gates typing (inputs stay focusable mid-cascade).
Mirrors the login page. */}
<RevealGroup
inView={false}
delayChildren={0.04}
stagger={0.08}
className="space-y-4"
>
<Reveal className="space-y-2">
<label className="text-xs font-semibold text-white/70 ml-1">
Email
</label>
</div>
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min. 8 characters"
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
autoFocus={autoFocus}
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 transition-colors"
tabIndex={-1}
>
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} />
</button>
</div>
</div>
</Reveal>
<div className="pt-2">
<Button
className="rounded-full w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
type="submit"
disabled={isLoading}
>
{isLoading ? (
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
) : (
"Create account"
)}
</Button>
</div>
<Reveal className="space-y-2">
<div className="flex justify-between items-center ml-1">
<label className="text-xs font-semibold text-white/70">
Password
</label>
</div>
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min. 8 characters"
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 transition-colors"
tabIndex={-1}
>
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} />
</button>
</div>
</Reveal>
<Reveal className="pt-2">
<Button
className="rounded-full w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
type="submit"
disabled={isLoading}
>
{isLoading ? (
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
) : (
"Create account"
)}
</Button>
</Reveal>
</RevealGroup>
</form>
<div className="relative flex items-center justify-center py-2">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-white/10" />
</div>
<span className="relative bg-[#1C1C20] px-4 text-xs uppercase tracking-widest text-white/40">
or
</span>
</div>
{/* Secondary sign-up options continue the same cascade just after the
fields (delay continues from the form group's three children above:
0.04 + 3 × 0.08). */}
<RevealGroup
inView={false}
delayChildren={0.28}
stagger={0.08}
className="space-y-4"
>
<Reveal className="relative flex items-center justify-center py-2">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-white/10" />
</div>
<span className="relative bg-[#1C1C20] px-4 text-xs uppercase tracking-widest text-white/40">
or
</span>
</Reveal>
<GoogleLoginButton
mode="signup"
onSuccess={handleGoogleSuccess}
onError={handleGoogleError}
/>
<Reveal>
<GoogleLoginButton
mode="signup"
onSuccess={handleGoogleSuccess}
onError={handleGoogleError}
/>
</Reveal>
</RevealGroup>
</div>
);
};
@@ -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 <input> 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<ElementType, ElementType>();
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,22 +100,30 @@ export function Reveal({
className,
}: RevealProps) {
const reduceMotion = useReducedMotion();
const MotionTag = motion(as);
const variants: Variants = {
initial: reduceMotion ? { opacity: 0 } : { opacity: 0, y },
animate: {
opacity: 1,
y: 0,
transition: { duration: DUR_SLOW, ease: EASE_OUT, delay },
},
};
const MotionTag = motionTag(as);
// When `inView` is left undefined the element behaves as a stagger child:
// it inherits the parent RevealGroup's orchestration and must NOT drive its
// own initial/animate props (that would override the cascade).
const standalone = inView !== undefined;
// Only a standalone reveal sets its own `delay`. For a stagger child the
// delay MUST be omitted: framer-motion lets a child's explicit transition
// `delay` override the parent's `staggerChildren`/`delayChildren`, so leaving
// it in (even as 0) collapses the cascade and every child animates at once.
const variants: Variants = {
initial: reduceMotion ? { opacity: 0 } : { opacity: 0, y },
animate: {
opacity: 1,
y: 0,
transition: {
duration: DUR_SLOW,
ease: EASE_OUT,
...(standalone ? { delay } : {}),
},
},
};
return (
<MotionTag
className={className}
@@ -72,8 +72,6 @@ const Login = () => {
/>
<AuthHeader title="Welcome Back" subtitle="Log in to your account" />
<RevealGroup inView={false} delayChildren={0.24} stagger={0.07}>
<Reveal>
<form
className="space-y-4"
onSubmit={(e) => {
@@ -87,93 +85,108 @@ const Login = () => {
</div>
)}
<div className="space-y-2">
<label className="text-xs font-semibold text-white/70 ml-1">
Email or Username
</label>
<Input
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com or username"
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
/>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center ml-1">
<label className="text-xs font-semibold text-white/70">
Password
{/* The form fields cascade in as their own beat, distinct from the
shell/header settling. We lead with the email field on an almost-zero
delay so the primary input lands immediately — the entrance never
gates typing: inputs stay focusable and accept keystrokes while the
password/button below are still cascading in. */}
<RevealGroup
inView={false}
delayChildren={0.04}
stagger={0.08}
className="space-y-4"
>
<Reveal className="space-y-2">
<label className="text-xs font-semibold text-white/70 ml-1">
Email or Username
</label>
<Link
to="/forgot-password"
className="text-xs text-primary hover:text-primary-400 transition-colors"
>
Forgot password?
</Link>
</div>
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min. 8 characters"
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com or username"
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 transition-colors"
tabIndex={-1}
</Reveal>
<Reveal className="space-y-2">
<div className="flex justify-between items-center ml-1">
<label className="text-xs font-semibold text-white/70">
Password
</label>
<Link
to="/forgot-password"
className="text-xs text-primary hover:text-primary-400 transition-colors"
>
Forgot password?
</Link>
</div>
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min. 8 characters"
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 transition-colors"
tabIndex={-1}
>
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} />
</button>
</div>
</Reveal>
<Reveal className="pt-2">
<Button
className="rounded-full w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
type="submit"
disabled={isLoading}
>
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} />
</button>
</div>
</div>
<div className="pt-2">
<Button
className="rounded-full w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
type="submit"
disabled={isLoading}
>
{isLoading ? (
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
) : (
"Log in"
)}
</Button>
</div>
{isLoading ? (
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
) : (
"Log in"
)}
</Button>
</Reveal>
</RevealGroup>
</form>
</Reveal>
<Reveal className="relative my-6 flex items-center justify-center">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-white/10" />
</div>
<span className="relative bg-[#1C1C20] px-4 text-xs uppercase tracking-widest text-white/40">
or
</span>
</Reveal>
{/* Secondary sign-in options pick up the same cascade just after the
fields (delay continues from the form group's three children above:
0.04 + 3 × 0.08). */}
<RevealGroup inView={false} delayChildren={0.28} stagger={0.08}>
<Reveal className="relative my-6 flex items-center justify-center">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-white/10" />
</div>
<span className="relative bg-[#1C1C20] px-4 text-xs uppercase tracking-widest text-white/40">
or
</span>
</Reveal>
<Reveal>
<GoogleLoginButton
mode="login"
onSuccess={handleGoogleSuccess}
onError={handleGoogleError}
/>
</Reveal>
<Reveal>
<GoogleLoginButton
mode="login"
onSuccess={handleGoogleSuccess}
onError={handleGoogleError}
/>
</Reveal>
<Reveal>
<AuthFooter>
Don't have an account?{" "}
<Link
to="/signup"
className="font-semibold text-primary transition-colors hover:text-primary-400"
>
Sign up
</Link>
</AuthFooter>
</Reveal>
<Reveal>
<AuthFooter>
Don't have an account?{" "}
<Link
to="/signup"
className="font-semibold text-primary transition-colors hover:text-primary-400"
>
Sign up
</Link>
</AuthFooter>
</Reveal>
</RevealGroup>
</>
);
@@ -1,7 +1,7 @@
import { Link, useNavigate } from "react-router-dom";
import { AuthHeader, AuthFooter, SignupForm } from "../../components/auth";
import Seo from "../../components/seo";
import { Reveal, RevealGroup } from "../../components/motion/reveal";
import { Reveal } from "../../components/motion/reveal";
const Signup = () => {
const navigate = useNavigate();
@@ -14,26 +14,25 @@ const Signup = () => {
/>
<AuthHeader title="Create an Account" subtitle="Join thousands of creators" />
<RevealGroup inView={false} delayChildren={0.24} stagger={0.07}>
<Reveal>
<SignupForm
onSuccess={() => navigate("/welcome")}
signupSource="artcraft"
/>
</Reveal>
{/* SignupForm runs its own field cascade internally (see signup-form.tsx).
The footer picks up the tail of that cascade — the form's six staggered
steps land by ~0.36s, so the footer follows at ~0.44s. */}
<SignupForm
onSuccess={() => navigate("/welcome")}
signupSource="artcraft"
/>
<Reveal>
<AuthFooter>
Already have an account?{" "}
<Link
to="/login"
className="font-semibold text-primary transition-colors hover:text-primary-400"
>
Log in
</Link>
</AuthFooter>
</Reveal>
</RevealGroup>
<Reveal inView={false} delay={0.44}>
<AuthFooter>
Already have an account?{" "}
<Link
to="/login"
className="font-semibold text-primary transition-colors hover:text-primary-400"
>
Log in
</Link>
</AuthFooter>
</Reveal>
</>
);
};