Sign-in Card
A two-step sign-in: email first, then password slides in with show / hide, plus a magic-link option and inline errors.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/sign-in-card.json'use client'
import { useEffect, useRef, useState } from 'react'
import './sign-in-card.css'
export interface SignInCardProps {
onSignIn?: (email: string, password: string) => Promise<boolean>
onMagicLink?: (email: string) => void
magicLink?: boolean
fill?: 'dark' | 'light'
className?: string
}
const okEmail = (e: string) => /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(e)
/** Two-step email → password sign-in. */
export function SignInCard({ onSignIn, onMagicLink, magicLink = true, fill = 'dark', className }: SignInCardProps) {
const [step, setStep] = useState<'email' | 'pw' | 'link' | 'done'>('email')
const [back, setBack] = useState(false)
const [email, setEmail] = useState('')
const [pw, setPw] = useState('')
const [show, setShow] = useState(false)
const [err, setErr] = useState('')
const [shake, setShake] = useState(0)
const [caps, setCaps] = useState(false)
const [busy, setBusy] = useState(false)
const first = useRef<HTMLInputElement>(null)
useEffect(() => { first.current?.focus({ preventScroll: true }) }, [step])
const fail = (m: string) => { setErr(m); setShake((s) => s + 1) }
return (
<div className={['sd-sic', className].filter(Boolean).join(' ')} data-fill={fill}>
{step === 'done' ? (
<div className="sd-sic__ok sd-sic__step" role="status"><svg viewBox="0 0 40 40" aria-hidden><circle cx="20" cy="20" r="17" /><path d="m13 20.5 5 5 9-11" strokeWidth="2" /></svg><b>Signed in</b><span>Welcome back.</span></div>
) : step === 'link' ? (
<div className="sd-sic__ok sd-sic__step" role="status"><svg viewBox="0 0 40 40" aria-hidden><path d="M6 11h28v19H6zM6 11l14 11 14-11" /></svg><b>Check your inbox</b><span>We sent a sign-in link to {email}.</span><button type="button" className="sd-sic__alt" onClick={() => { setBack(true); setStep('pw') }}>Use password instead</button></div>
) : (
<>
<div className="sd-sic__h">Sign in</div>
<div className="sd-sic__s">{step === 'email' ? 'Use your work email.' : 'Enter your password to continue.'}</div>
{step === 'email' ? (
<form key="e" className="sd-sic__step" data-back={back || undefined} noValidate onSubmit={(e) => { e.preventDefault(); if (!okEmail(email)) return fail('Enter a valid email address.'); setErr(''); setBack(false); setStep('pw') }}>
<label className="sd-sic__lab" htmlFor="sic-e">Email</label>
<div key={shake} className="sd-sic__f" data-bad={err || undefined}><input ref={first} id="sic-e" type="email" autoComplete="email" value={email} aria-invalid={Boolean(err)} aria-describedby={err ? 'sic-err' : undefined} onChange={(e) => { setEmail(e.target.value); setErr('') }} placeholder="you@company.com" /></div>
{err && <p id="sic-err" className="sd-sic__err" role="alert">{err}</p>}
<button type="submit" className="sd-sic__cta">Continue</button>
</form>
) : (
<form key="p" className="sd-sic__step" data-back={back || undefined} onSubmit={async (e) => {
e.preventDefault(); if (!pw) return fail('Enter your password.')
setBusy(true); const ok = onSignIn ? await onSignIn(email, pw) : pw.length >= 6; setBusy(false)
if (ok) setStep('done'); else fail('That password isn’t right. Try again or use a link.')
}}>
<span className="sd-sic__chip"><span>{email}</span><button type="button" onClick={() => { setBack(true); setErr(''); setStep('email') }}>Change</button></span>
<label className="sd-sic__lab" htmlFor="sic-p">Password</label>
<div key={shake} className="sd-sic__f" data-bad={err || undefined}>
<input ref={first} id="sic-p" type={show ? 'text' : 'password'} autoComplete="current-password" value={pw} aria-invalid={Boolean(err)} aria-describedby={err || caps ? 'sic-err' : undefined} onChange={(e) => { setPw(e.target.value); setErr('') }} onKeyUp={(e) => setCaps(e.getModifierState('CapsLock'))} />
<button type="button" className="sd-sic__eye" aria-label={show ? 'Hide password' : 'Show password'} aria-pressed={show} onClick={() => setShow(!show)}>
<svg viewBox="0 0 16 16" aria-hidden>{show ? <path d="M2 2l12 12M4.3 4.4C2.9 5.3 2 6.6 1.5 8c1 2.6 3.6 4.5 6.5 4.5 1.2 0 2.3-.3 3.3-.9M7 3.6l1-.1c2.9 0 5.5 1.9 6.5 4.5-.3.8-.7 1.5-1.2 2.1" /> : <><path d="M1.5 8C2.5 5.4 5.1 3.5 8 3.5s5.5 1.9 6.5 4.5c-1 2.6-3.6 4.5-6.5 4.5S2.5 10.6 1.5 8Z" /><circle cx="8" cy="8" r="2" /></>}</svg>
</button>
</div>
{(err || caps) && <p id="sic-err" className="sd-sic__err" role="alert">{err || 'Caps Lock is on.'}</p>}
<button type="submit" className="sd-sic__cta" disabled={busy}>{busy ? 'Signing in…' : 'Sign in'}</button>
{magicLink && <button type="button" className="sd-sic__alt" onClick={() => { onMagicLink?.(email); setStep('link') }}>Email me a sign-in link instead</button>}
</form>
)}
</>
)}
</div>
)
}.sd-sic { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-sic[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-sic button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-sic :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-sic .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-sic { width: min(100%, 320px); padding: 20px; border-radius: 22px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; }
.sd-sic__h { font-size: 18px; font-weight: 650; letter-spacing: -0.01em; }
.sd-sic__s { font-size: 13px; color: var(--mute); margin: 4px 0 16px; }
.sd-sic__step { animation: sd-sic-r 320ms cubic-bezier(0.2, 1, 0.3, 1); }
.sd-sic__step[data-back] { animation-name: sd-sic-l; }
@keyframes sd-sic-r { from { opacity: 0; transform: translateX(24px); } }
@keyframes sd-sic-l { from { opacity: 0; transform: translateX(-24px); } }
.sd-sic__lab { display: block; font-size: 12.5px; font-weight: 500; margin-bottom: 6px; }
.sd-sic__f { display: flex; align-items: center; height: 44px; border-radius: 12px; box-shadow: inset 0 0 0 1px var(--line); transition: box-shadow 160ms; }
.sd-sic__f:focus-within { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-sic__f[data-bad] { box-shadow: inset 0 0 0 1.5px var(--fg); animation: sd-sic-shake 300ms; }
@keyframes sd-sic-shake { 25% { transform: translateX(-4px); } 75% { transform: translateX(4px); } }
.sd-sic__f input { flex: 1; min-width: 0; height: 100%; padding: 0 12px; border: 0; outline: 0; background: none; color: var(--fg); font: inherit; font-size: 14.5px; }
.sd-sic__eye { display: grid; place-items: center; width: 40px; height: 40px; color: var(--mute); }
.sd-sic__eye:hover { color: var(--fg); }
.sd-sic__eye svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
.sd-sic__err { margin: 6px 2px 0; font-size: 12px; }
.sd-sic__cta { margin-top: 14px; width: 100%; height: 44px; border-radius: 12px; background: var(--strong) !important; color: var(--on) !important; font-weight: 600; font-size: 14px; }
.sd-sic__cta:disabled { opacity: 0.5; cursor: default; }
.sd-sic__chip { display: inline-flex; align-items: center; gap: 8px; height: 30px; padding: 0 4px 0 12px; margin-bottom: 14px; border-radius: 99px; background: var(--soft); font-size: 13px; max-width: 100%; }
.sd-sic__chip span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sd-sic__chip button { height: 24px; padding: 0 8px; border-radius: 99px; font-size: 12px; color: var(--mute); }
.sd-sic__chip button:hover { color: var(--fg); background: var(--bg) !important; }
.sd-sic__alt { display: block; margin: 14px auto 0; font-size: 12.5px; color: var(--mute); text-decoration: underline; text-underline-offset: 3px; }
.sd-sic__alt:hover { color: var(--fg); }
.sd-sic__ok { display: grid; justify-items: center; gap: 8px; text-align: center; padding: 10px 0; }
.sd-sic__ok svg { width: 40px; height: 40px; fill: none; stroke: currentColor; stroke-width: 1.4; stroke-linejoin: round; }
.sd-sic__ok b { font-size: 15px; }
.sd-sic__ok span { font-size: 13px; color: var(--mute); }
@media (prefers-reduced-motion: reduce) { .sd-sic *, .sd-sic *::before, .sd-sic *::after { animation: none !important; transition: none !important; } }Overview
Step one asks only for email and validates it inline. Continue slides the card to the password step, with the email shown as an editable chip at the top.
Password has a show / hide toggle and caps-lock warning; “Email me a link instead” swaps to a sent confirmation.
When to use
- Product sign-in pages and modals.
When not to use
- SSO-only products: show the provider buttons instead.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/sign-in-card.json$
npx shadcn@latest add https://sundaeui.dev/r/sign-in-card.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/sign-in-card.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/sign-in-card.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy sign-in-card.tsx and sign-in-card.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { SignInCard } from '@/components/sundae/sign-in-card'
<SignInCard />Props
| Prop | Type | Default |
|---|---|---|
onSignInReturn false for wrong password. | (email: string, password: string) => Promise<boolean> | — |
onMagicLinkSend a magic link. | (email: string) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Each step is a form with labelled fields; errors use aria-invalid and aria-describedby.
- Focus moves to the first field of each step; caps lock is announced.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-sic; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Sign-in Card free?
- Yes. Sign-in Card is free for personal and commercial projects, with full source on this page.
- How do I install Sign-in Card?
- Run npx shadcn@latest add https://sundaeui.dev/r/sign-in-card.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Sign-in Card support keyboard and screen readers?
- Each step is a form with labelled fields; errors use aria-invalid and aria-describedby. Focus moves to the first field of each step; caps lock is announced.
- Does Sign-in Card respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →Password Strength
Forms · RefinedOTP Field
Forms · Refined- Pro
Drop Zone
Forms · Refined - Pro
Onboarding Flow
Forms · Refined - Pro
Wheel Picker
Forms · Refined Rating Scale
Forms · Refined