shadimage
Spectrum UI
componentaccount-access-cardsource: ok · hit

Account Access Card

A credentials card that auto-fills email and password on a loop, with a danger zone.

npx shadcn add "https://ui.spectrumhq.in/r/account-access-card.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
animationmotion
iconslucide-react

Registry dependencies

@spectrumui/use-typewriter

Files1 · source cached 10/9/2026, 2:23:35 AM

app/registry/account-access-card/account-access-card.tsx
"use client";

import * as React from "react";
import { motion, useInView } from "motion/react";
import { ChevronRight, CircleAlert, Lock } from "lucide-react";

import { cn } from "@/lib/utils";

import { usePrefersReducedMotion } from "@/components/spectrumui/use-typewriter";

export interface AccountCredential {
  email: string;
  /** How many password dots get "typed" for this account. */
  passwordLength?: number;
}

export interface AccountAccessCardProps {
  title?: string;
  description?: string;
  emailLabel?: string;
  passwordLabel?: string;
  forgotLabel?: string;
  buttonLabel?: string;
  buttonIcon?: React.ReactNode;
  dangerTitle?: string;
  dangerDescription?: string;
  /** Accounts the card cycles through, typing each one in. */
  credentials?: AccountCredential[];
  /** Turn the auto-fill animation off and show static values. */
  animated?: boolean;
  emailValue?: string;
  passwordValue?: string;
  onSubmit?: () => void;
  onForgot?: () => void;
  onDanger?: () => void;
  className?: string;
}

const DEFAULT_CREDENTIALS: AccountCredential[] = [
  { email: "alfa@spectrum.com", passwordLength: 14 },
  { email: "john.doe@gmail.com", passwordLength: 10 },
  { email: "emma@example.com", passwordLength: 16 },
];

type ActiveField = "email" | "password" | null;

function Caret() {
  return (
    <motion.span
      aria-hidden
      className="ml-px inline-block h-3.5 w-px shrink-0 bg-foreground align-middle"
      animate={{ opacity: [1, 1, 0, 0] }}
      transition={{ duration: 1, repeat: Infinity, times: [0, 0.5, 0.5, 1] }}
    />
  );
}

export function AccountAccessCard({
  title = "Account Access",
  description = "Update your credentials or re-authenticate.",
  emailLabel = "Email Address",
  passwordLabel = "Current Password",
  forgotLabel = "Forgot?",
  buttonLabel = "Update Security",
  buttonIcon,
  dangerTitle = "Danger Zone",
  dangerDescription = "Archive account and remove access",
  credentials = DEFAULT_CREDENTIALS,
  animated = true,
  emailValue = "alfa@spectrum.com",
  passwordValue = "••••••••••••••••••••••••",
  onSubmit,
  onForgot,
  onDanger,
  className,
}: AccountAccessCardProps) {
  const rootRef = React.useRef<HTMLDivElement>(null);
  const inView = useInView(rootRef, { margin: "-10% 0px" });
  const reduced = usePrefersReducedMotion();
  const play = animated && inView && !reduced && credentials.length > 0;

  const [email, setEmail] = React.useState(animated ? "" : emailValue);
  const [password, setPassword] = React.useState(animated ? "" : passwordValue);
  const [activeField, setActiveField] = React.useState<ActiveField>(null);

  React.useEffect(() => {
    if (!animated) {
      setEmail(emailValue);
      setPassword(passwordValue);
      setActiveField(null);
      return;
    }
    if (reduced) {
      setEmail(credentials[0]?.email ?? emailValue);
      setPassword("•".repeat(credentials[0]?.passwordLength ?? 14));
      setActiveField(null);
      return;
    }
    if (!play) return;

    let cancelled = false;
    const sleep = (ms: number) =>
      new Promise<void>((resolve) => setTimeout(resolve, ms));

    (async () => {
      await sleep(800);
      let index = 0;
      while (!cancelled) {
        const cred = credentials[index % credentials.length];
        const dots = cred.passwordLength ?? 14;

        setActiveField("email");
        for (let i = 1; i <= cred.email.length; i++) {
          if (cancelled) return;
          setEmail(cred.email.slice(0, i));
          await sleep(80 + Math.random() * 60);
        }
        await sleep(550);

        setActiveField("password");
        for (let i = 1; i <= dots; i++) {
          if (cancelled) return;
          setPassword("•".repeat(i));
          await sleep(85 + Math.random() * 45);
        }
        setActiveField(null);
        await sleep(3200);

        for (let i = dots - 1; i >= 0; i--) {
          if (cancelled) return;
          setPassword("•".repeat(i));
          await sleep(18);
        }
        for (let i = cred.email.length - 1; i >= 0; i--) {
          if (cancelled) return;
          setEmail(cred.email.slice(0, i));
          await sleep(24);
        }
        await sleep(750);
        index++;
      }
    })();

    return () => {
      cancelled = true;
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [play, animated, reduced]);

  return (
    <div
      ref={rootRef}
      className={cn(
        "w-full rounded-[24px] bg-white p-5",
        "shadow-[0_0_0_1px_rgba(10,10,10,0.05),0_1px_3px_0_rgba(0,0,0,0.1),0_1px_2px_-1px_rgba(0,0,0,0.1)]",
        "dark:bg-neutral-950 dark:shadow-[0_0_0_1px_rgba(255,255,255,0.12),0_1px_3px_0_rgba(0,0,0,0.5)]",
        className,
      )}
    >
      <h3 className="text-[16px] font-medium leading-6 text-foreground">
        {title}
      </h3>
      <p className="mt-1.5 text-sm leading-5 text-muted-foreground">
        {description}
      </p>

      {/* Email */}
      <div className="mt-5">
        <label className="text-sm font-medium leading-[19.25px] text-foreground">
          {emailLabel}
        </label>
        <div
          className={cn(
            "mt-3 flex h-8 items-center rounded-[18px] bg-neutral-200/50 px-2.5 transition-all duration-300 dark:bg-neutral-800/50",
            activeField === "email" &&
              "bg-neutral-200/80 ring-1 ring-neutral-300 dark:bg-neutral-800/80 dark:ring-neutral-700",
          )}
        >
          <span className="flex min-w-0 items-center text-sm text-muted-foreground">
            <span className="truncate">{email}</span>
            {activeField === "email" ? <Caret /> : null}
          </span>
        </div>
      </div>

      {/* Password */}
      <div className="mt-6">
        <div className="flex items-center justify-between">
          <label className="text-sm font-medium leading-[19.25px] text-foreground">
            {passwordLabel}
          </label>
          <motion.button
            type="button"
            onClick={onForgot}
            whileTap={{ scale: 0.94 }}
            className="text-xs font-medium uppercase leading-4 tracking-[0.6px] text-muted-foreground transition-colors hover:text-foreground"
          >
            {forgotLabel}
          </motion.button>
        </div>
        <div
          className={cn(
            "mt-3 flex h-8 items-center rounded-[18px] bg-neutral-200/50 px-2.5 transition-all duration-300 dark:bg-neutral-800/50",
            activeField === "password" &&
              "bg-neutral-200/80 ring-1 ring-neutral-300 dark:bg-neutral-800/80 dark:ring-neutral-700",
          )}
        >
          <span className="flex min-w-0 items-center text-sm leading-none text-muted-foreground">
            <span className="truncate pt-0.5 tracking-[1px]">{password}</span>
            {activeField === "password" ? <Caret /> : null}
          </span>
        </div>
      </div>

      {/* Submit */}
      <motion.button
        type="button"
        onClick={onSubmit}
        whileHover="hover"
        whileTap={{ scale: 0.97 }}
        className="mt-[21px] flex h-[36px] w-full items-center justify-center gap-2 rounded-[18px] bg-black text-sm font-medium leading-5 text-[#eff6ff] transition-colors hover:bg-neutral-800 dark:bg-white dark:text-neutral-950 dark:hover:bg-neutral-200"
      >
        <motion.span
          variants={{
            hover: {
              rotate: [0, -12, 10, -6, 0],
              transition: { duration: 0.5 },
            },
          }}
          className="flex"
        >
          {buttonIcon ?? <Lock className="h-4 w-4" />}
        </motion.span>
        {buttonLabel}
      </motion.button>

      {/* Danger zone */}
      <motion.button
        type="button"
        onClick={onDanger}
        whileTap={{ scale: 0.985 }}
        className="group mt-[17px] flex min-h-[73px] w-full items-center gap-3.5 rounded-[18px] bg-neutral-100/50 p-4 text-left transition-colors hover:bg-neutral-100 dark:bg-neutral-900/50 dark:hover:bg-neutral-900"
      >
        <CircleAlert className="h-4 w-4 shrink-0 self-start text-red-500" />
        <span className="min-w-0 flex-1">
          <span className="block text-sm font-medium leading-[19.25px] text-foreground">
            {dangerTitle}
          </span>
          <span className="mt-1 block truncate text-sm leading-5 text-muted-foreground">
            {dangerDescription}
          </span>
        </span>
        <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200 group-hover:translate-x-0.5" />
      </motion.button>
    </div>
  );
}

More from Spectrum UI

Browse all components in this library →