Clerk: Sign-in-or-up
A complete custom sign-in-or-up flow built with Motion, combining the conditional password field and the card-stack verification step. Wire the same UI to Clerk's useSignIn() and useSignUp() hooks.
Source code
"use client"
import { useCallback, useEffect, useId, useState } from "react"
import { AnimatePresence, motion, MotionConfig, resize } from "motion/react"
/* The complete sign-in-or-up flow, combining the conditional password
* field and the card-stack verification step into one animated surface.
* The auth logic is mocked so the demo runs standalone: submitting an
* email reveals the password field (the "create your account" path),
* and submitting again stacks up the one-time-code card. Wire the same
* UI to Clerk's useSignIn()/useSignUp() hooks for a real custom flow. */
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%" },
}
const TEXT_VARIANTS = {
initial: { opacity: 0, filter: "blur(10px)", y: -10 },
animate: { opacity: 1, filter: "blur(0px)", y: 0 },
exit: { opacity: 0, filter: "blur(10px)", y: 10 },
}
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
function Spinner({ size = 12 }: { size?: number }) {
return (
<div style={{ width: size, height: size }}>
<div className="spinner" style={{ width: size, height: size }}>
{Array.from({ length: 12 }).map((_, i) => (
<div
key={i}
className="spinner__bar"
style={{
transform: `rotate(${i * 30}deg) translate(146%)`,
animationDelay: `-${1.2 - i * 0.1}s`,
}}
/>
))}
</div>
</div>
)
}
function ConditionalField({
open,
label,
error,
...props
}: React.ComponentProps<"input"> & { open: boolean; label: string; error?: string | null }) {
const [height, setHeight] = useState(0)
const internalId = useId()
const id = props.id || internalId
const measureRef = useCallback((el: HTMLDivElement | null) => {
if (!el) return
return resize(el, (_, { height }) => setHeight(height))
}, [])
return (
<motion.div animate={{ height: open ? height : 0 }} style={{ willChange: "height" }}>
<div ref={measureRef} style={{ position: "relative" }}>
<AnimatePresence mode="popLayout">
{open && (
<motion.div
key="password-field"
className="auth__field"
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 0 }}
style={{ marginTop: "0.75rem" }}
>
<label htmlFor={id}>{label}</label>
<input autoFocus {...props} id={id} />
{error && <p className="auth__field-error">{error}</p>}
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
)
}
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't receive a code? Resend {countdown > 0 ? `(${countdown})` : ""}
</button>
)
}
function SignIn({ onVerify }: { onVerify: () => void }) {
const [emailAddress, setEmailAddress] = useState("")
const [password, setPassword] = useState("")
const [showPassword, setShowPassword] = useState(false)
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!showPassword) return
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault()
setPassword("")
setShowPassword(false)
}
}
window.addEventListener("keyup", handleKeyUp)
return () => window.removeEventListener("keyup", handleKeyUp)
}, [showPassword])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
await wait(650)
setLoading(false)
/* No password yet: in the real flow Clerk returns
* `form_identifier_not_found` for an unknown email, so we reveal
* the password field to create an account. With a password set,
* we advance to verification. */
if (!showPassword) {
setShowPassword(true)
} else {
onVerify()
}
}
return (
<motion.div variants={SIGN_IN_VARIANTS} className="auth__card" initial="default">
<AnimatePresence initial={false} mode="popLayout">
{!showPassword ? (
<motion.h3
key="sign-in-title"
className="auth__card-title"
variants={TEXT_VARIANTS}
initial="initial"
animate="animate"
exit="exit"
>
Welcome to Motion
</motion.h3>
) : (
<motion.h3
key="sign-up-title"
className="auth__card-title"
variants={TEXT_VARIANTS}
initial="initial"
animate="animate"
exit="exit"
>
Create your account
</motion.h3>
)}
</AnimatePresence>
<p className="auth__card-description">Enter your details to continue</p>
<form onSubmit={handleSubmit}>
<div className="auth__field">
<label htmlFor="email">Email address</label>
<input
id="email"
name="email"
type="email"
required
placeholder="Enter your email address"
value={emailAddress}
onChange={(e) => setEmailAddress(e.target.value)}
/>
</div>
<ConditionalField
label="Password"
name="password"
id="password"
type="password"
placeholder="Create a password"
required
onBlur={({ target }) => setShowPassword(target.value.length > 0)}
onChange={(e) => setPassword(e.target.value)}
value={password}
open={showPassword}
/>
<button className="btn btn--primary" style={{ marginTop: "1.5rem" }} type="submit" disabled={loading}>
{loading ? <Spinner size={12} /> : <span>Continue</span>}
</button>
</form>
</motion.div>
)
}
function Verify({ onCancel, onComplete }: { onCancel: () => void; onComplete: () => void }) {
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
await wait(650)
setLoading(false)
onComplete()
}
return (
<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={handleSubmit}>
<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" disabled={loading}>
{loading ? <Spinner size={12} /> : <span>Verify</span>}
</button>
</form>
<button className="btn" onClick={onCancel} type="button" style={{ marginTop: "0.5rem" }}>
Start over
</button>
</motion.div>
)
}
export default function ClerkSignIn() {
const [isVerifying, setIsVerifying] = useState(false)
/* Remount the sign-in card on reset so its internal email/password
* state clears, mirroring signIn.reset()/signUp.reset(). */
const [resetKey, setResetKey] = useState(0)
const reset = () => {
setIsVerifying(false)
setResetKey((k) => k + 1)
}
return (
<MotionConfig transition={{ type: "spring", bounce: 0.3, visualDuration: 0.4 }}>
<motion.div animate={isVerifying ? "verifying" : "default"} className="clerk-stage">
<div className="auth__root">
<div className="auth__card-container">
<SignIn key={resetKey} onVerify={() => setIsVerifying(true)} />
<AnimatePresence mode="popLayout">
{isVerifying && <Verify onCancel={reset} onComplete={reset} />}
</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-error {
color: #ff3300;
font-size: 0.75rem;
line-height: 1rem;
}
.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[disabled] { cursor: default; }
.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; }
.spinner { position: relative; top: 50%; left: 50%; }
.spinner__bar {
position: absolute;
background-color: currentColor;
height: 8%;
width: 24%;
left: -10%;
top: -3.9%;
border-radius: 6px;
animation: clerk-spinner-fade 1.2s linear infinite;
}
@keyframes clerk-spinner-fade {
0% { opacity: 1; }
100% { opacity: 0.15; }
}
`}</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.







