shadimage
tween-ui
uiauth-modalsource: ok · hit

Auth Modal

A sign-in dialog: the backdrop fades, the panel scales up, then the close button, title, providers and email field stagger in. Closing plays the sequence in reverse.

npx shadcn add "https://tween-ui.vercel.app/r/auth-modal.json"
free

Where it goes

login

Interaction

modal

Use cases

saas-landing

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Files1 · source cached 10/8/2026, 8:15:19 PM

components/tweenui/auth-modal.tsx
'use client';

import { useCallback, useEffect, useRef, type FormEvent } from 'react';
import { useGSAP } from '@gsap/react';
import gsap from 'gsap';
import { createPortal } from 'react-dom';

gsap.registerPlugin(useGSAP);

const prefersReducedMotion = () =>
  typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;

const PROVIDERS = [
  {
    id: 'google',
    label: 'Google',
    icon: (
      <svg viewBox="0 0 24 24" className="size-5" aria-hidden="true">
        <path
          fill="#4285F4"
          d="M21.7541 12.2199C21.7541 11.4182 21.6878 10.8332 21.5441 10.2266H12.2031V13.8449H17.6861C17.5756 14.744 16.9786 16.0982 15.6521 17.0082L15.6335 17.1293L18.5869 19.3715L18.7915 19.3916C20.6708 17.6907 21.7541 15.1882 21.7541 12.2199Z"
        />
        <path
          fill="#34A853"
          d="M12.2002 21.7514C14.8864 21.7514 17.1415 20.8847 18.7886 19.3897L15.6492 17.0063C14.8091 17.5805 13.6815 17.9813 12.2002 17.9813C9.56932 17.9813 7.33635 16.2805 6.54036 13.9297L6.42369 13.9394L3.35266 16.2686L3.3125 16.378C4.94853 19.563 8.30907 21.7514 12.2002 21.7514Z"
        />
        <path
          fill="#FBBC05"
          d="M6.53907 13.9306C6.32904 13.3239 6.20749 12.6739 6.20749 12.0023C6.20749 11.3305 6.32904 10.6806 6.52802 10.0739L6.52246 9.94471L3.41294 7.57812L3.3112 7.62555C2.63691 8.94723 2.25 10.4314 2.25 12.0023C2.25 13.5731 2.63691 15.0572 3.3112 16.3789L6.53907 13.9306Z"
        />
        <path
          fill="#EB4335"
          d="M12.2003 6.01997C14.0685 6.01997 15.3286 6.8108 16.0472 7.47168L18.855 4.785C17.1306 3.21417 14.8865 2.25 12.2003 2.25C8.3091 2.25 4.94854 4.43832 3.3125 7.62329L6.52933 10.0717C7.33638 7.72083 9.56936 6.01997 12.2003 6.01997Z"
        />
      </svg>
    ),
  },
  {
    id: 'apple',
    label: 'Apple',
    icon: (
      <svg viewBox="0 0 24 24" className="size-5" aria-hidden="true">
        <path
          fill="currentColor"
          d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"
        />
      </svg>
    ),
  },
  {
    id: 'microsoft',
    label: 'Microsoft',
    icon: (
      <svg viewBox="0 0 88 88" className="size-4.5" aria-hidden="true">
        <path fill="#f35325" d="M0 0h42v42H0z" />
        <path fill="#81bc06" d="M46 0h42v42H46z" />
        <path fill="#05a6f0" d="M0 46h42v42H0z" />
        <path fill="#ffba08" d="M46 46h42v42H46z" />
      </svg>
    ),
  },
  {
    id: 'github',
    label: 'GitHub',
    icon: (
      <svg viewBox="0 0 24 24" className="size-5" aria-hidden="true">
        <path
          fill="currentColor"
          d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 12 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0 0 22 12.017C22 6.484 17.522 2 12 2z"
        />
      </svg>
    ),
  },
  {
    id: 'x',
    label: 'X',
    icon: (
      <svg viewBox="0 0 24 24" className="size-5 fill-none" aria-hidden="true">
        <path
          d="M19.2955 5 13.1929 11.2933M11.1136 13.4375 4.75 20M4.75 5 16.1136 20H19.75L8.38636 5H4.75Z"
          stroke="currentColor"
          strokeLinecap="round"
          strokeLinejoin="round"
        />
      </svg>
    ),
  },
] as const;

export interface AuthModalProps {
  open: boolean;
  onClose: () => void;
  onProvider?: (id: string) => void;
  onEmail?: (email: string) => void;
}

export default function AuthModal({ open, onClose, onProvider, onEmail }: AuthModalProps) {
  const backdropRef = useRef<HTMLDivElement>(null);
  const panelRef = useRef<HTMLDivElement>(null);

  const items = () =>
    panelRef.current
      ? [...panelRef.current.querySelectorAll<HTMLElement>('[data-auth-modal-item]')]
      : [];

  const close = useCallback(() => {
    const backdrop = backdropRef.current;
    const panel = panelRef.current;
    const nodes = items();

    if (!backdrop || !panel || prefersReducedMotion()) {
      onClose();
      return;
    }

    gsap.killTweensOf([backdrop, panel, ...nodes]);
    gsap
      .timeline({ onComplete: onClose })
      .to(nodes, { opacity: 0, y: 8, duration: 0.12, stagger: 0.02, ease: 'power1.in' })
      .to(panel, { opacity: 0, y: 16, scale: 0.96, duration: 0.18, ease: 'power2.in' }, '-=0.06')
      .to(backdrop, { opacity: 0, duration: 0.16, ease: 'power2.in' }, '-=0.1');
  }, [onClose]);

  useGSAP(
    () => {
      if (!open) return;
      const backdrop = backdropRef.current;
      const panel = panelRef.current;
      const nodes = items();
      if (!backdrop || !panel) return;

      if (prefersReducedMotion()) {
        gsap.set([backdrop, panel, ...nodes], { opacity: 1, y: 0, scale: 1 });
        return;
      }

      gsap.killTweensOf([backdrop, panel, ...nodes]);
      gsap.set(backdrop, { opacity: 0 });
      gsap.set(panel, { opacity: 0, y: 28, scale: 0.94 });
      gsap.set(nodes, { opacity: 0, y: 16 });
      gsap
        .timeline()
        .to(backdrop, { opacity: 1, duration: 0.22, ease: 'power2.out' })
        .to(panel, { opacity: 1, y: 0, scale: 1, duration: 0.32, ease: 'power3.out' }, '-=0.08')
        .to(
          nodes,
          { opacity: 1, y: 0, duration: 0.28, stagger: 0.045, ease: 'power2.out' },
          '-=0.18'
        );
    },
    { dependencies: [open] }
  );

  useEffect(() => {
    if (!open) return;
    const panel = panelRef.current;
    const previouslyFocused = document.activeElement as HTMLElement | null;
    const previous = document.body.style.overflow;
    document.body.style.overflow = 'hidden';

    const focusable = () =>
      panel
        ? [
            ...panel.querySelectorAll<HTMLElement>(
              'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
            ),
          ].filter((el) => el.offsetParent !== null)
        : [];

    // Move focus into the dialog so keyboard/AT users start inside it.
    panel?.focus();

    const onKey = (event: KeyboardEvent) => {
      if (event.key === 'Escape') {
        close();
        return;
      }
      if (event.key !== 'Tab' || !panel) return;

      // Trap focus within the panel.
      const items = focusable();
      if (items.length === 0) {
        event.preventDefault();
        panel.focus();
        return;
      }
      const first = items[0]!;
      const last = items[items.length - 1]!;
      const active = document.activeElement;
      if (event.shiftKey) {
        if (active === first || active === panel || !panel.contains(active)) {
          event.preventDefault();
          last.focus();
        }
      } else if (active === last || !panel.contains(active)) {
        event.preventDefault();
        first.focus();
      }
    };

    document.addEventListener('keydown', onKey);
    return () => {
      document.body.style.overflow = previous;
      document.removeEventListener('keydown', onKey);
      // Restore focus to whatever opened the dialog.
      previouslyFocused?.focus?.();
    };
  }, [open, close]);

  const submit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const email = new FormData(event.currentTarget).get('email');
    if (typeof email === 'string') onEmail?.(email.trim());
  };

  if (!open || typeof document === 'undefined') return null;

  return createPortal(
    <div
      className="fixed inset-0 z-9999 flex items-center justify-center p-4"
      role="dialog"
      aria-modal="true"
      aria-labelledby="auth-modal-title"
    >
      <div
        ref={backdropRef}
        data-auth-modal-backdrop
        className="bg-background/70 absolute inset-0 backdrop-blur-[14px] motion-reduce:opacity-100"
        onClick={close}
      />
      <div
        ref={panelRef}
        data-auth-modal-panel
        tabIndex={-1}
        className="bg-background relative z-10 w-full max-w-[360px] rounded-3xl border-2 border-[#045f64] p-6 outline-none motion-reduce:opacity-100"
        onClick={(event) => event.stopPropagation()}
      >
        <button
          type="button"
          data-auth-modal-item
          onClick={close}
          aria-label="Close auth modal"
          className="absolute top-4 right-4 z-20 flex size-8 cursor-pointer items-center justify-center rounded-full bg-[#045f64]/10 text-[#045f64] transition-colors hover:text-[#045f64]/70 motion-reduce:transition-none"
        >
          <svg viewBox="0 0 24 24" className="size-4 fill-none stroke-current" aria-hidden="true">
            <path d="M18 6 6 18M6 6l12 12" strokeLinecap="round" strokeLinejoin="round" />
          </svg>
        </button>

        <div data-auth-modal-item className="mb-7 text-center">
          <h2 id="auth-modal-title" className="text-foreground text-xl font-medium">
            Welcome back
          </h2>
          <p className="text-muted-foreground mt-2 text-sm">Sign in to your account to continue</p>
        </div>

        <div data-auth-modal-item className="mb-7 grid grid-cols-5 gap-2.5">
          {PROVIDERS.map((provider) => (
            <button
              key={provider.id}
              type="button"
              aria-label={`Continue with ${provider.label}`}
              onClick={() => onProvider?.(provider.id)}
              className="text-foreground flex h-10 w-full items-center justify-center rounded-2xl bg-[#045f64]/10 transition-transform hover:scale-95 motion-reduce:transition-none motion-reduce:hover:scale-100"
            >
              {provider.icon}
            </button>
          ))}
        </div>

        <div data-auth-modal-item className="mb-6 flex items-center gap-2">
          <span className="h-px w-full bg-[#045f64]/10" />
          <span className="text-muted-foreground shrink-0 text-xs">Or continue with email</span>
          <span className="h-px w-full bg-[#045f64]/10" />
        </div>

        <form data-auth-modal-item className="space-y-4" onSubmit={submit}>
          <div className="relative">
            <svg
              viewBox="0 0 24 24"
              className="pointer-events-none absolute top-1/2 left-3 size-5 -translate-y-1/2 fill-none stroke-[#045f64]/70"
              aria-hidden="true"
            >
              <path
                d="M3 7l9 6 9-6M5 19h14a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2z"
                strokeLinecap="round"
                strokeLinejoin="round"
              />
            </svg>
            <input
              name="email"
              type="email"
              required
              placeholder="name@example.com"
              className="bg-background text-foreground placeholder:text-muted-foreground h-11 w-full rounded-full border border-[#045f64]/10 pr-12 pl-10 text-sm outline-none focus-visible:ring-2 focus-visible:ring-[#045f64]"
            />
            <button
              type="submit"
              aria-label="Continue with email"
              className="absolute top-1/2 right-1 flex size-9 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full bg-[#045f64] text-[#c6f56f] transition-transform hover:scale-95 motion-reduce:transition-none motion-reduce:hover:scale-100"
            >
              <svg
                viewBox="0 0 24 24"
                className="size-5 fill-none stroke-current"
                aria-hidden="true"
              >
                <path d="M5 12h14M13 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
              </svg>
            </button>
          </div>
        </form>

        <p data-auth-modal-item className="text-muted-foreground mt-6 text-center text-xs">
          By clicking continue, you agree to our Terms of Service and Privacy Policy.
        </p>
      </div>
    </div>,
    document.body
  );
}

More from tween-ui

Browse all components in this library →