mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-13 05:12:50 +00:00
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:
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>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user