All patterns

Sign-in Form Entrance

Heading, fields and button rise into place in one quick sequence as the screen opens.

authenticationpremiumminimalautomatic · finite · starter · ~0.7s
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

229 lines · react + motion only
import { motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Sign-in Form Entrance
 *
 * Heading, fields, button and footer rise into place in one quick
 * sequence as the sign-in screen opens.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Works with zero props; tune via `variant`, `title`, `accent`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type SignInFormEntranceProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Card heading. */
  title?: string;
  /** Line under the heading. */
  subtitle?: string;
  /** Primary button label. */
  submitLabel?: string;
  /** Primary button color. */
  accent?: string;
  /** Fires once the last row has landed. */
  onComplete?: () => void;
};

type VariantConfig = {
  /** Travel before a row lands, in px. */
  rise: number;
  /** Gap between consecutive rows — this is what makes it read as a sequence. */
  stagger: number;
  /** Beat before the first row moves, so the card reads as the stage. */
  lead: number;
  spring: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: every spring here sits at or above a 0.8 damping ratio,
// so a row lands with at most one soft settle. Rows carry text, and
// wobbling text reads as cheap — variants differ in travel and tempo,
// never in bounce.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Barely a lift. For products where signing in is a daily chore, not
  // an event worth animating.
  subtle: {
    rise: 8,
    stagger: 0.045,
    lead: 0.03,
    spring: { type: "spring", stiffness: 520, damping: 46 },
  },
  // Enough travel to read as a sequence without slowing anyone down.
  // The all-purpose setting.
  default: {
    rise: 14,
    stagger: 0.065,
    lead: 0.06,
    spring: { type: "spring", stiffness: 420, damping: 38 },
  },
  // Longer travel and a wider gap: the form visibly assembles itself.
  // Still lands without a rebound — energy comes from distance, not wobble.
  playful: {
    rise: 20,
    stagger: 0.085,
    lead: 0.08,
    spring: { type: "spring", stiffness: 380, damping: 32 },
  },
};

const fieldWrap = {
  display: "flex",
  flexDirection: "column",
  gap: 6,
} as const;

const labelStyle = {
  fontSize: 11.5,
  fontWeight: 600,
  letterSpacing: 0.2,
  opacity: 0.6,
} as const;

const inputStyle = {
  width: "100%",
  boxSizing: "border-box",
  padding: "9px 11px",
  fontSize: 14,
  fontFamily: "inherit",
  color: "inherit",
  borderRadius: 9,
  border: "1px solid rgba(127,127,140,0.28)",
  background: "rgba(127,127,140,0.10)",
} as const;

export default function SignInFormEntrance({
  variant = "default",
  title = "Sign in",
  subtitle = "Continue to your workspace",
  submitLabel = "Continue",
  accent = "#5B5BD6",
  onComplete,
}: SignInFormEntranceProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  // Reduced motion keeps one markup tree and swaps the variants instead
  // of rendering a second copy of the form: same DOM, same focus order,
  // just no travel and no sequence.
  const container = reduceMotion
    ? { hidden: {}, visible: {} }
    : {
        hidden: {},
        visible: {
          transition: { delayChildren: cfg.lead, staggerChildren: cfg.stagger },
        },
      };

  const row = reduceMotion
    ? {
        hidden: { opacity: 0 },
        visible: {
          opacity: 1,
          transition: { duration: 0.2, ease: "easeOut" as const },
        },
      }
    : {
        hidden: { opacity: 0, y: cfg.rise },
        visible: {
          opacity: 1,
          y: 0,
          transition: {
            ...cfg.spring,
            // Opacity runs on its own short curve; springing a fade
            // leaves a long, muddy tail behind the movement.
            opacity: { duration: 0.22, ease: "easeOut" as const },
          },
        },
      };

  return (
    <motion.form
      initial="hidden"
      animate="visible"
      variants={container}
      onSubmit={(event) => event.preventDefault()}
      style={{
        width: 288,
        display: "flex",
        flexDirection: "column",
        gap: 12,
        padding: 20,
        borderRadius: 16,
        border: "1px solid rgba(127,127,140,0.22)",
        background: "rgba(127,127,140,0.07)",
      }}
    >
      <motion.div variants={row}>
        <div style={{ fontSize: 17, fontWeight: 650, lineHeight: 1.25 }}>
          {title}
        </div>
        <div style={{ fontSize: 12.5, opacity: 0.55, marginTop: 3 }}>
          {subtitle}
        </div>
      </motion.div>

      <motion.div variants={row} style={fieldWrap}>
        <label htmlFor="vibary-sfe-email" style={labelStyle}>
          Email
        </label>
        <input
          id="vibary-sfe-email"
          type="email"
          autoComplete="email"
          placeholder="you@company.com"
          style={inputStyle}
        />
      </motion.div>

      <motion.div variants={row} style={fieldWrap}>
        <label htmlFor="vibary-sfe-password" style={labelStyle}>
          Password
        </label>
        <input
          id="vibary-sfe-password"
          type="password"
          autoComplete="current-password"
          placeholder="••••••••"
          style={inputStyle}
        />
      </motion.div>

      <motion.div variants={row}>
        <button
          type="submit"
          style={{
            width: "100%",
            padding: "10px 14px",
            fontSize: 14,
            fontWeight: 600,
            fontFamily: "inherit",
            color: "#ffffff",
            background: accent,
            border: "none",
            borderRadius: 9,
            cursor: "pointer",
          }}
        >
          {submitLabel}
        </button>
      </motion.div>

      {/* Last row owns the completion callback: with a stagger the parent
          finishes first, so the sequence is only really over down here. */}
      <motion.div
        variants={row}
        onAnimationComplete={() => onComplete?.()}
        style={{
          display: "flex",
          justifyContent: "space-between",
          fontSize: 12,
          opacity: 0.55,
        }}
      >
        <span>Forgot password?</span>
        <span>Create account</span>
      </motion.div>
    </motion.form>
  );
}

About this pattern

The first screen most users see, so it sets the tone for everything after it. Each row of the form travels a short distance up and lands on a near-critically damped spring, one shortly after the next, which walks the eye from the heading down to the primary button. The sequence is deliberately fast: returning users sign in daily and a slow entrance turns a chore into a wait. Rows carry text, so they translate and fade only — nothing scales, nothing rebounds.

Sign-in screenSign-up screenPassword reset screenAuth modal opening

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Sign inUse the address your team invited
    Email
    nils@ridgeline.co
    Password
    ••••••••••
    Continue
    Sign-in screen

    Auth surfaces built on speed and restraint rather than flourish.

Related patterns