Parent : [[INDEX-WEB-UIUX]]
Web UI/UX - Auth Modal OAuth + Email : divider "or", error/success states, mode toggle
Problème
Un formulaire d'authentification mal conçu pousse les utilisateurs à abandonner. Trop de champs, pas de feedback visuel, ou OAuth mal intégré = taux de conversion faible.
Solution
Modal d'authentification avec 3 sections : OAuth rapide (GitHub/Google), divider visuel "ou", formulaire email/password avec validation, et toggle login/signup.
type Mode = 'login' | 'signup'
export function AuthModal({ onClose }: { onClose: () => void }) {
const [mode, setMode] = useState<Mode>('login')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setSuccess(null)
setLoading(true)
if (mode === 'login') {
const err = await signInWithEmail(email, password)
if (err) setError(err)
else onClose()
} else {
const err = await signUpWithEmail(email, password)
if (err) setError(err)
else setSuccess('Email de confirmation envoyé ! Vérifiez votre boîte mail.')
}
setLoading(false)
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.75)', backdropFilter: 'blur(4px)' }}
onClick={onClose}
>
<div className="w-full max-w-sm mx-4 rounded-[6px] overflow-hidden"
style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}
onClick={(e) => e.stopPropagation()}
>
{/* Header avec logo */}
<div className="p-5 pb-0 flex items-center justify-between">
<div className="flex items-center gap-2 mb-1">
<div className="w-6 h-6 rounded-[3px] flex items-center justify-center text-white font-black text-[10px]"
style={{ background: 'var(--accent)' }}>F</div>
<span className="font-bold text-sm">BRAND<span style={{ color: 'var(--accent)' }}>NAME</span></span>
</div>
<button onClick={onClose}>✕</button>
</div>
<div className="p-5 flex flex-col gap-4">
{/* ── OAuth Buttons ── */}
<div className="flex flex-col gap-2">
<button onClick={() => signInWithProvider('github')}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-[4px] text-sm font-semibold"
style={{ background: '#24292e', color: '#fff', border: '1px solid #30363d' }}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0..."/></svg>
Continuer avec GitHub
</button>
<button onClick={() => signInWithProvider('google')}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-[4px] text-sm font-semibold"
style={{ background: '#fff', color: '#333', border: '1px solid #ddd' }}>
<svg width="18" height="18" viewBox="0 0 24 24">...</svg>
Continuer avec Google
</button>
</div>
{/* ── Divider "ou" ── */}
<div className="flex items-center gap-3">
<div className="flex-1 h-px" style={{ background: 'var(--border)' }} />
<span className="text-[10px] font-bold uppercase tracking-wider" style={{ color: 'var(--text-3)' }}>ou</span>
<div className="flex-1 h-px" style={{ background: 'var(--border)' }} />
</div>
{/* ── Email Form ── */}
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
<input type="email" placeholder="email@exemple.com" required
className="w-full px-3 py-2.5 rounded-[4px] text-sm outline-none"
style={{ background: 'var(--bg)', border: '1px solid var(--border)', color: 'var(--text)' }}
onFocus={(e) => (e.currentTarget.style.borderColor = 'var(--accent)')}
onBlur={(e) => (e.currentTarget.style.borderColor = 'var(--border)')}
/>
<input type="password" placeholder="Mot de passe (min. 6 caractères)" required minLength={6}
className="w-full px-3 py-2.5 rounded-[4px] text-sm outline-none"
style={{ background: 'var(--bg)', border: '1px solid var(--border)', color: 'var(--text)' }}
onFocus={(e) => (e.currentTarget.style.borderColor = 'var(--accent)')}
onBlur={(e) => (e.currentTarget.style.borderColor = 'var(--border)')}
/>
{/* Error state */}
{error && (
<div className="text-xs px-3 py-2 rounded-[4px]"
style={{ background: 'rgba(229,25,26,0.08)', border: '1px solid rgba(229,25,26,0.2)', color: '#fca5a5' }}>
{error}
</div>
)}
{/* Success state */}
{success && (
<div className="text-xs px-3 py-2 rounded-[4px]"
style={{ background: 'rgba(0,255,136,0.08)', border: '1px solid rgba(0,255,136,0.2)', color: '#4ade80' }}>
{success}
</div>
)}
<button type="submit" disabled={loading} className="btn-primary w-full">
{loading ? '...' : mode === 'login' ? 'Se connecter' : 'Créer le compte'}
</button>
</form>
{/* Toggle login/signup */}
<p className="text-center text-xs" style={{ color: 'var(--text-3)' }}>
{mode === 'login' ? (
<>Pas encore de compte ? <button onClick={() => { setMode('signup'); setError(null) }} className="font-bold" style={{ color: 'var(--accent)' }}>Inscription</button></>
) : (
<>Déjà un compte ? <button onClick={() => { setMode('login'); setError(null) }} className="font-bold" style={{ color: 'var(--accent)' }}>Connexion</button></>
)}
</p>
</div>
</div>
</div>
)
}
Points clés
- OAuth en premier : l'utilisateur préfère cliquer 1 fois plutôt que taper un formulaire
- Divider "ou" : texte 10px uppercase tracking-wider, couleur muted → élégant
- Focus border accent : feedback visuel immédiat sur les inputs
- Error/success states : fond coloré faiblement opaque + texte contrasté
- Toggle mode : reset des erreurs au changement de mode
- Loading state : bouton disabled + spinner/texte "..."
- Pas de labels visibles : placeholders suffisants pour 2 champs simples
Pourquoi
- OAuth first = taux de conversion +30-50%
- Error state visuel évite la frustration (l'utilisateur sait immédiatement ce qui ne va pas)
- Mode toggle évite 2 modals séparées
Quand l'utiliser
- Connexion, inscription, onboarding
- Toute authentification utilisateur
Quand NE PAS l'utiliser
- SSO enterprise (pas besoin de formulaire email)
- Magic link only (pas de password)
Références
- ProjectAlpha/AuthModal.tsx L1-165