feat(webapp): stagger the login form entrance as its own beat

The login form faded in at the same time as the shell/header, so its
entrance read as one undifferentiated fade rather than a deliberate beat.
Two changes fix that:

1. Reveal: stop hardcoding `delay` in the stagger-child transition.
   framer-motion lets a child's explicit `transition.delay` override the
   parent's `staggerChildren`/`delayChildren`, so leaving it in (even as 0)
   collapsed every RevealGroup cascade — all children animated in unison.
   Now the explicit `delay` is only applied to standalone reveals; stagger
   children inherit the parent orchestration. This makes RevealGroup
   actually cascade (affects every RevealGroup in the webapp, as intended).

2. Login: split the single form Reveal into per-element children so email,
   password and the submit button cascade in sequence, with the secondary
   options (divider, Google, footer) continuing the same cascade. The email
   field leads on a near-zero delay so the primary input lands immediately —
   typing is never gated by the animation (inputs stay focusable and accept
   keystrokes mid-cascade).

Verified with Playwright: a smooth ~75ms staircase across the six elements
(email → password → button → divider → Google → footer), and typing the
instant the field exists retains the full value with zero focus loss.
This commit is contained in:
Tanish Manku committed 2026-06-25 13:29:47 -07:00
1 parent e6be45f3d4
commit 41615dfab9
2 files changed
+108 -87

No files matched your search

@@ -102,20 +102,28 @@ export function Reveal({
const reduceMotion = useReducedMotion();
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, delay },
transition: {
duration: DUR_SLOW,
ease: EASE_OUT,
...(standalone ? { delay } : {}),
},
},
};
// 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;
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>
</>
);