Motion+

Clerk: Card Stack

The verification step of a custom Clerk flow. Submitting the email stacks a one-time-code card up over the sign-in card, with AnimatePresence popLayout dimming the card behind it.

React

Source code

"use client"

import { useEffect, useState } from "react"
import { AnimatePresence, motion, MotionConfig } from "motion/react"

/* The verification step in a custom Clerk flow. Submitting the email
 * "stacks" a verification card up over the sign-in card: the lower card
 * dims and recedes while the OTP card springs in from below. In a real
 * flow the email submit calls Clerk's signIn.emailCode.sendCode() and the
 * code is verified with signIn.emailCode.verifyCode(). */

const SIGN_IN_VARIANTS = {
    default: { opacity: 1, scale: 1, y: 0, x: "-50%" },
    verifying: { opacity: 0.6, scale: 0.95, y: -10, x: "-50%" },
}

const VERIFY_VARIANTS = {
    default: { opacity: 0, y: 100, x: "-50%" },
    verifying: { opacity: 1, y: 0, x: "-50%" },
}

/* A minimal one-time-code input: a transparent field captures keystrokes
 * over a row of slots. The guide uses the `input-otp` package; this is an
 * inline stand-in so the example stays dependency-free. */
function OTPInput({ length = 6, autoFocus }: { length?: number; autoFocus?: boolean }) {
    const [code, setCode] = useState("")
    const activeIndex = Math.min(code.length, length - 1)
    return (
        <div className="otp">
            <input
                className="otp__input"
                value={code}
                onChange={(e) => setCode(e.target.value.replace(/\D/g, "").slice(0, length))}
                inputMode="numeric"
                autoComplete="one-time-code"
                aria-label="Verification code"
                autoFocus={autoFocus}
            />
            <div className="otp__slots" aria-hidden="true">
                {Array.from({ length }).map((_, i) => (
                    <div key={i} className="otp__slot" data-active={i === activeIndex}>
                        {code[i] ?? ""}
                    </div>
                ))}
            </div>
        </div>
    )
}

function ResendButton(props: React.ComponentProps<"button">) {
    const [countdown, setCountdown] = useState(30)

    useEffect(() => {
        const id = setInterval(() => setCountdown((prev) => (prev <= 0 ? prev : prev - 1)), 1000)
        return () => clearInterval(id)
    }, [])

    return (
        <button {...props} type="button" className="resend" disabled={countdown > 0}>
            Didn&apos;t receive a code? Resend {countdown > 0 ? `(${countdown})` : ""}
        </button>
    )
}

export default function ClerkCardStack() {
    const [email, setEmail] = useState("")
    const [isVerifying, setIsVerifying] = useState(false)

    const handleSubmit = (e: React.FormEvent) => {
        e.preventDefault()
        setIsVerifying(true)
    }

    const handleReset = () => {
        setIsVerifying(false)
        setEmail("")
    }

    const handleSubmitVerify = (e: React.FormEvent) => {
        e.preventDefault()
        handleReset()
    }

    return (
        <MotionConfig transition={{ type: "spring", bounce: 0.3, visualDuration: 0.4 }}>
            <motion.div className="clerk-stage" animate={isVerifying ? "verifying" : "default"}>
                <div className="auth__root">
                    <div className="auth__card-container">
                        <motion.div variants={SIGN_IN_VARIANTS} initial="default" className="auth__card">
                            <h3 className="auth__card-title">Welcome to Motion</h3>
                            <p className="auth__card-description">Enter your details to continue</p>
                            <form onSubmit={handleSubmit}>
                                <div className="auth__field">
                                    <label htmlFor="email">Email</label>
                                    <input
                                        required
                                        placeholder="Enter your email address"
                                        type="email"
                                        name="email"
                                        id="email"
                                        onChange={(e) => setEmail(e.target.value)}
                                        value={email}
                                    />
                                </div>
                                <button className="btn btn--primary" style={{ marginTop: "1.5rem" }} type="submit">
                                    Submit
                                </button>
                            </form>
                        </motion.div>

                        <AnimatePresence mode="popLayout">
                            {isVerifying && (
                                <motion.div
                                    className="auth__card auth__card--overlay"
                                    variants={VERIFY_VARIANTS}
                                    initial="default"
                                    animate="verifying"
                                    exit="default"
                                >
                                    <h3 className="auth__card-title">Verify your account</h3>
                                    <p className="auth__card-description">
                                        Enter the code sent to your email address
                                    </p>
                                    <form onSubmit={handleSubmitVerify}>
                                        <div
                                            className="auth__field"
                                            style={{ width: "min-content", marginInline: "auto" }}
                                        >
                                            <OTPInput autoFocus length={6} />
                                        </div>
                                        <ResendButton style={{ marginInline: "auto", marginTop: "0.5rem" }} />
                                        <button className="btn btn--primary" style={{ marginTop: "1.5rem" }} type="submit">
                                            Verify
                                        </button>
                                    </form>
                                    <button className="btn" onClick={handleReset} type="button" style={{ marginTop: "0.5rem" }}>
                                        Start over
                                    </button>
                                </motion.div>
                            )}
                        </AnimatePresence>
                    </div>
                </div>

                <style>{`
                    .clerk-stage {
                        --surface: #f7f7f8;
                        color-scheme: light;
                        position: relative;
                        align-self: stretch;
                        flex: 1 1 auto;
                        width: 100%;
                        min-height: 100%;
                        overflow: auto;
                        background: #ededed;
                        color: #212126;
                        font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
                    }
                    .clerk-stage, .clerk-stage * { box-sizing: border-box; }

                    .auth__root {
                        display: grid;
                        justify-content: center;
                        align-items: flex-start;
                        min-height: 100%;
                        padding: clamp(2rem, 14vh, 6rem) 1rem 2rem;
                    }
                    .auth__card-container {
                        position: relative;
                        grid-column: 1;
                        grid-row: 1;
                    }
                    .auth__card {
                        position: absolute;
                        left: 50%;
                        width: 25rem;
                        max-width: calc(100vw - 2rem);
                        transform: translateX(-50%);
                        padding: 2.5rem 2rem;
                        background-color: var(--surface);
                        border-radius: 2rem;
                        transform-origin: top center;
                        box-shadow:
                            inset 0 0 0 1px rgb(255 255 255),
                            inset 0 2px 4px 0 rgb(255 255 255),
                            inset 0 4px 10px 0 hsl(0 0 0 / 0.05),
                            0 4px 14px -10px hsl(0 0 0 / 0.4),
                            0 8px 28px -10px hsl(0 0 0 / 0.1),
                            0 0 0 1px rgb(0 0 0 / 0.06);
                    }
                    .auth__card--overlay { z-index: 10; }
                    .auth__card button { width: 100%; }
                    .auth__card form { margin-top: 2rem; }

                    .auth__card-title {
                        text-align: center;
                        font-size: 1.125rem;
                        line-height: 1.5rem;
                        font-weight: 600;
                        letter-spacing: -0.025em;
                    }
                    .auth__card-description {
                        text-align: center;
                        font-size: 0.875rem;
                        line-height: 1.25rem;
                        opacity: 0.72;
                    }

                    .auth__field {
                        display: flex;
                        flex-direction: column;
                        gap: 0.5rem;
                    }
                    .auth__field label {
                        font-size: 0.8125rem;
                        line-height: 1rem;
                        font-weight: 500;
                    }
                    .auth__field input {
                        --border-color: rgb(0 0 0 / 0.1);
                        --ring: 0 0 0 0px rgb(0 0 0 / 0);
                        width: 100%;
                        border-radius: 0.375rem;
                        background-color: var(--surface);
                        padding: 0.375rem 0.75rem;
                        font-size: 0.8125rem;
                        line-height: 1.125rem;
                        color: #212126;
                        border: none;
                        outline: none;
                        box-shadow: 0 1px 3px -1px rgb(0 0 0 / 0.08), 0 0 0 1px var(--border-color), var(--ring);
                    }
                    .auth__field input::placeholder { color: rgb(0 0 0 / 0.4); }
                    .auth__field input:hover { --border-color: rgb(0 0 0 / 0.2); }
                    .auth__field input:focus-visible {
                        --border-color: rgb(0 0 0 / 0.2);
                        --ring: 0 0 0 3px rgb(0 0 0 / 0.06);
                    }

                    .btn {
                        display: inline-flex;
                        align-items: center;
                        justify-content: center;
                        position: relative;
                        height: 2rem;
                        border: 0;
                        padding: 0 0.75rem;
                        background-color: transparent;
                        color: #212126;
                        border-radius: 0.5rem;
                        overflow: hidden;
                        font-size: 0.8125rem;
                        font-weight: 500;
                        cursor: pointer;
                    }
                    .btn::after { content: ""; position: absolute; inset: 0; pointer-events: none; }
                    .btn:hover::after { background: linear-gradient(to bottom, rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.05)); }
                    .btn:active::after { background: linear-gradient(to bottom, rgb(0 0 0 / 0.08), rgb(0 0 0 / 0.08)); }
                    .btn--primary {
                        background-color: #212126;
                        color: #fff;
                        box-shadow: inset 0 12px 0 -11px rgb(255 255 255 / 0.12), 0 2px 3px rgb(0 0 0 / 0.2), 0 0 0 1px #212126;
                    }
                    .btn--primary::after { background: linear-gradient(to bottom, rgb(255 255 255 / 0.15), rgb(255 255 255 / 0) 60%); }
                    .btn--primary:hover::after { background: linear-gradient(to bottom, rgb(255 255 255 / 0.12), rgb(255 255 255 / 0.12)); }
                    .btn--primary:active::after { background: linear-gradient(to bottom, rgb(255 255 255 / 0.2), rgb(255 255 255 / 0.2)); }

                    .otp { position: relative; width: min-content; }
                    .otp__input {
                        position: absolute;
                        inset: 0;
                        width: 100%;
                        height: 100%;
                        opacity: 0;
                        cursor: text;
                        border: none;
                        background: transparent;
                    }
                    .otp__slots { display: flex; }
                    .otp__slot {
                        --border-color: rgb(0 0 0 / 0.1);
                        --ring: 0 0 0 0px rgb(0 0 0 / 0);
                        margin-inline-start: -1px;
                        position: relative;
                        display: flex;
                        width: 2.25rem;
                        height: 2.25rem;
                        align-items: center;
                        justify-content: center;
                        background-color: var(--surface);
                        font-size: 0.8125rem;
                        line-height: 1.125rem;
                        font-weight: 500;
                        box-shadow: 0 1px 3px -1px rgb(0 0 0 / 0.08), 0 0 0 0.5px var(--border-color), var(--ring);
                    }
                    .otp__slot[data-active="true"] {
                        --border-color: rgb(0 0 0 / 0.2);
                        --ring: 0 0 0 3px rgb(0 0 0 / 0.12);
                        z-index: 10;
                    }
                    .otp__slot:first-child {
                        border-top-left-radius: 0.375rem;
                        border-bottom-left-radius: 0.375rem;
                        margin-inline-start: 0;
                    }
                    .otp__slot:last-child {
                        border-top-right-radius: 0.375rem;
                        border-bottom-right-radius: 0.375rem;
                    }

                    .resend {
                        border: 0;
                        background-color: transparent;
                        display: block;
                        color: #212126;
                        opacity: 0.75;
                        font-size: 0.8125rem;
                        line-height: 1rem;
                        font-weight: 500;
                        cursor: pointer;
                    }
                    .resend:hover { opacity: 1; }
                    .resend[disabled] { opacity: 0.4; cursor: not-allowed; }
                `}</style>
            </motion.div>
        </MotionConfig>
    )
}

Related examples

Latest in React

Motion+

Unlock all 400+ examples

  • Source code for every Plus example.
  • Provide examples direct to your agent via Motion's MCP.
  • Lifetime access to new examples and APIs.