Retour au catalogue

Magic Link

Connexion sans mot de passe par magic link avec animation d'envoi et confirmation visuelle.

authsimple Both Responsive a11y
minimalelegantsaasuniversalcentered
Theme

How to build a passwordless email auth form in React

A passwordless React auth form manages two visual states, an email input and a confirmation screen, swapped with Framer Motion's AnimatePresence in 'wait' mode. The sending phase shows a rotating Sparkles icon driven by an infinite motion loop, then flips to a bouncing CheckCircle2 once sent.

  • Stack: React 18 + Framer Motion 11 + Lucide React, ~260 lines, zero extra dependencies.
  • State machine: three stages managed with two booleans, `sending` and `sent`, no external state library needed.
  • Accessible: uses a native <form> with type='email' and required, so browser validation and screen readers work out of the box.
  • Fully responsive with a 400px max-width centered card; works on all screen sizes.
  • All colors use CSS custom properties (--color-accent, --color-background, etc.) so it adapts to any theme preset without code changes.

AuthMagicLink is a passwordless login section built for React applications that want to drop password fields entirely. It handles the full micro-journey in one component: email capture, an animated loading state, and a success screen with a retry path. The transition between states is smooth and felt rather than mechanical, the kind of flow that raises perceived quality without adding backend complexity.

Anatomy

The section fills the viewport (min-height: 100vh) and centers a single 400px card. Behind the card sits a blurred radial glow (opacity 0.04) that subtly warms the background without competing with the content. The card itself has three layers: a brand badge at the top (Wand2 icon in an accent-colored square), a title/subtitle block, and the interactive zone. The interactive zone switches between the form and the confirmation panel via AnimatePresence, keeping both states at the same DOM position so the layout never jumps.

How it works

The animation relies on three Framer Motion primitives. First, the whole card mounts with `initial={{ opacity: 0, y: 30 }}` triggered by `useInView` with a -40px margin, so it enters just before the user reaches it on scroll. Second, the brand icon scales in with a separate spring-like ease (`[0.16, 1, 0.3, 1]`). Third, the form/confirmation swap uses `<AnimatePresence mode='wait'>`: the exiting element fully fades and scales out before the entering one starts, preventing overlap. Inside the confirmation, `animate={{ y: [0, -6, 0] }}` with `repeat: Infinity` and `ease: 'easeInOut'` creates the floating CheckCircle2. The sending state drives a continuous 360-degree rotation on the Sparkles icon via `transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}`.

How to build it in React

  1. Set up the three-stage state machine

    Two booleans cover all three stages: idle (both false), sending (sending=true, sent=false), and confirmed (sent=true). Keep the form submit handler synchronous, call setSending(true) immediately, then simulate or replace with your real API call. Set setSent(true) in the callback.

    const [sent, setSent] = useState(false);
    const [sending, setSending] = useState(false);
    
    const handleSubmit = (e: React.FormEvent) => {
      e.preventDefault();
      setSending(true);
      sendMagicLink(email).then(() => {
        setSending(false);
        setSent(true);
      });
    };
  2. Wrap both states in AnimatePresence

    Use `mode='wait'` so the exit animation completes before the enter starts. Give each child a stable `key` prop, 'form' and 'sent', so Framer Motion knows which element is entering and which is leaving. Without distinct keys, AnimatePresence cannot detect the change.

    <AnimatePresence mode="wait">
      {sent ? (
        <motion.div key="sent"
          initial={{ opacity: 0, scale: 0.9 }}
          animate={{ opacity: 1, scale: 1 }}
          exit={{ opacity: 0, scale: 0.9 }}
          transition={{ duration: 0.5 }}
        >
          {/* confirmation content */}
        </motion.div>
      ) : (
        <motion.form key="form"
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}
          onSubmit={handleSubmit}
        >
          {/* email input + button */}
        </motion.form>
      )}
    </AnimatePresence>
  3. Animate the sending button state

    Inside the submit button, swap the static icon group for a spinning icon when `sending` is true. A continuous rotate animation on a motion.div wrapping the icon reads cleanly and signals activity without a separate spinner component. Disable the button and set cursor to 'wait' so the interaction is blocked while the request is in flight.

    {sending ? (
      <motion.div
        animate={{ rotate: 360 }}
        transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
      >
        <Sparkles style={{ width: 18, height: 18 }} />
      </motion.div>
    ) : (
      <>
        <Sparkles style={{ width: 16, height: 16 }} />
        {ctaLabel}
        <ArrowRight style={{ width: 14, height: 14 }} />
      </>
    )}
  4. Add the floating confirmation icon

    In the confirmation panel, wrap the CheckCircle2 in a motion.div with a keyframe y animation looping between 0 and -6px. Use `ease: 'easeInOut'` and a 2-second duration for a gentle float. This small detail reinforces the positive state without being distracting.

    <motion.div
      animate={{ y: [0, -6, 0] }}
      transition={{ duration: 2, repeat: Infinity, ease: "easeInOut" }}
    >
      <CheckCircle2 style={{ width: 30, height: 30, color: "var(--color-accent)" }} />
    </motion.div>

When to use it

Use this component on any SaaS or tool product that supports passwordless auth via email (Auth.js, Supabase, Clerk, Firebase). It works best as a standalone auth page, not embedded in a modal, where the centered layout would conflict with the modal chrome. Skip it if you need social login buttons or a multi-step signup form; this layout is intentionally minimal and single-purpose. On mobile it renders correctly since there is no pointer-dependent interaction.

Used by

  • Linear, Uses a clean email-only login flow as the primary auth path, consistent with their minimal product philosophy.
  • Notion, Defaults to a magic link sent to email before showing password as a secondary option.
  • Slack, Offers 'Sign in with email link' as an alternative to passwords on its login screen.

FAQ

How do I connect this to a real magic link backend?

Replace the `setTimeout` in `handleSubmit` with your actual API call, for example `supabase.auth.signInWithOtp({ email })` or `fetch('/api/auth/magic-link', { method: 'POST', body: JSON.stringify({ email }) })`. The component only controls visual state; the network layer is plugged in at the handler.

Can I add social login buttons to this layout?

Yes, extend the form with a divider and OAuth buttons below the email input. Keep the card max-width at 400px; it handles up to 3-4 social buttons comfortably before becoming visually crowded.

Why AnimatePresence mode='wait' instead of 'sync'?

With 'sync', the exit and enter animations run simultaneously, which can look chaotic when both elements scale and fade at the same time. 'wait' sequences them, the form disappears completely before the confirmation fades in, giving the transition a cleaner, deliberate feel.

Does it work with the incubator theme presets?

Out of the box. All colors reference CSS custom properties (`--color-accent`, `--color-background`, `--color-border`, etc.) defined by each preset. Switch the `data-theme` attribute on a parent element and the form repaints automatically.

"use client";

import { useState, useRef } from "react";
import { motion, useInView, AnimatePresence } from "framer-motion";
import { Sparkles, Mail, ArrowRight, CheckCircle2, Wand2 } from "lucide-react";

interface AuthMagicLinkProps {
  brandName?: string;
  title?: string;
  subtitle?: string;
  placeholder?: string;
  ctaLabel?: string;
  sentTitle?: string;
  sentMessage?: string;
  retryLabel?: string;
  footerNote?: string;
}

const EASE: [number, number, number, number] = [0.16, 1, 0.3, 1];

export default function AuthMagicLink({
  brandName = "Flux",

Code complet réservé à Pro

Code source intégral, export multi-framework et playground.

Passer en Pro, 9,99€/mois

Reviews

React Passwordless Login Form (Magic Link), Code + Tutorial