{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["lucide-react","motion"],"description":"A presentation-only sign-in / sign-up form that reveals fields progressively and stays fully accessible.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport SmoothButton from \"@/components/smoothui/smooth-button\";\nimport { Check, Eye, EyeOff } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport type { FormEvent, ReactNode, RefObject } from \"react\";\nimport { useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport {\n  DURATION_INSTANT,\n  SPRING_DEFAULT,\n  SPRING_SNAPPY,\n} from \"@/components/smoothui/lib/animation\";\n\nconst EMAIL_PATTERN = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\nconst UPPERCASE_PATTERN = /[A-Z]/;\nconst NUMBER_PATTERN = /[0-9]/;\n\nexport type AuthFormMode = \"sign-in\" | \"sign-up\";\n\nexport type AuthFormStatus = \"idle\" | \"submitting\" | \"success\" | \"error\";\n\nexport interface AuthProvider {\n  icon: ReactNode;\n  id: string;\n  label: string;\n}\n\nexport interface AuthPasswordRequirement {\n  id: string;\n  label: string;\n  test: (password: string) => boolean;\n}\n\nexport interface AuthFormValues {\n  email: string;\n  password: string;\n}\n\nexport interface AuthFormFieldErrors {\n  email?: string;\n  password?: string;\n}\n\nexport interface AuthFormProps {\n  className?: string;\n  error?: string;\n  fieldErrors?: AuthFormFieldErrors;\n  footer?: ReactNode;\n  mode: AuthFormMode;\n  onModeChange?: (mode: AuthFormMode) => void;\n  onSubmit?: (values: AuthFormValues) => void;\n  providers?: AuthProvider[];\n  requirements?: AuthPasswordRequirement[];\n  showMagicLink?: boolean;\n  status?: AuthFormStatus;\n}\n\nconst DEFAULT_REQUIREMENTS: AuthPasswordRequirement[] = [\n  {\n    id: \"length\",\n    label: \"At least 8 characters\",\n    test: (password) => password.length >= 8,\n  },\n  {\n    id: \"uppercase\",\n    label: \"One uppercase letter\",\n    test: (password) => UPPERCASE_PATTERN.test(password),\n  },\n  {\n    id: \"number\",\n    label: \"One number\",\n    test: (password) => NUMBER_PATTERN.test(password),\n  },\n];\n\nconst SLIDE_SPRING = { ...SPRING_DEFAULT, bounce: 0 };\n\nconst panelInitial = (direction: number) => ({\n  opacity: 0,\n  x: direction * 16,\n});\nconst panelExit = (direction: number) => ({ opacity: 0, x: -direction * 16 });\n\n// ---------------------------------------------------------------------------\n// AuthModeHeader — direction-aware cross-fade between sign-in / sign-up copy\n// ---------------------------------------------------------------------------\n\ninterface AuthModeHeaderProps {\n  direction: number;\n  isSignUp: boolean;\n  mode: AuthFormMode;\n  shouldReduceMotion: boolean | null;\n}\n\nconst AuthModeHeader = ({\n  mode,\n  isSignUp,\n  direction,\n  shouldReduceMotion,\n}: AuthModeHeaderProps) => (\n  <AnimatePresence custom={direction} initial={false} mode=\"wait\">\n    <motion.div\n      animate={shouldReduceMotion ? { opacity: 1 } : { opacity: 1, x: 0 }}\n      custom={direction}\n      exit={shouldReduceMotion ? { opacity: 0 } : panelExit(direction)}\n      initial={shouldReduceMotion ? { opacity: 0 } : panelInitial(direction)}\n      key={mode}\n      transition={shouldReduceMotion ? DURATION_INSTANT : SLIDE_SPRING}\n    >\n      <h2 className=\"font-semibold text-foreground text-xl\">\n        {isSignUp ? \"Create your account\" : \"Welcome back\"}\n      </h2>\n      <p className=\"mt-1 text-muted-foreground text-sm\">\n        {isSignUp\n          ? \"Start with your email — we'll take it from there.\"\n          : \"Sign in to continue where you left off.\"}\n      </p>\n    </motion.div>\n  </AnimatePresence>\n);\n\n// ---------------------------------------------------------------------------\n// AuthProviderList — optional OAuth-style buttons + divider\n// ---------------------------------------------------------------------------\n\ninterface AuthProviderListProps {\n  providers: AuthProvider[];\n}\n\nconst AuthProviderList = ({ providers }: AuthProviderListProps) => {\n  if (providers.length === 0) {\n    return null;\n  }\n  return (\n    <div className=\"mt-5 grid gap-2\">\n      {providers.map((provider) => (\n        <SmoothButton\n          className=\"w-full text-sm\"\n          key={provider.id}\n          prefix={\n            <span aria-hidden=\"true\" className=\"flex shrink-0 items-center\">\n              {provider.icon}\n            </span>\n          }\n          size=\"lg\"\n          variant=\"outline\"\n        >\n          Continue with {provider.label}\n        </SmoothButton>\n      ))}\n      <div className=\"relative my-2 text-center text-xs\">\n        <span className=\"relative z-10 bg-background px-2 text-muted-foreground\">\n          or continue with email\n        </span>\n        <div\n          aria-hidden=\"true\"\n          className=\"absolute inset-x-0 top-1/2 -translate-y-1/2 border-t\"\n        />\n      </div>\n    </div>\n  );\n};\n\n// ---------------------------------------------------------------------------\n// AuthErrorSummary — form-level error, focused automatically on failure\n// ---------------------------------------------------------------------------\n\ninterface AuthErrorSummaryProps {\n  error?: string;\n  errorRef: RefObject<HTMLDivElement | null>;\n  shouldReduceMotion: boolean | null;\n  status: AuthFormStatus;\n}\n\nconst AuthErrorSummary = ({\n  status,\n  error,\n  errorRef,\n  shouldReduceMotion,\n}: AuthErrorSummaryProps) => (\n  <AnimatePresence initial={false}>\n    {status === \"error\" && error ? (\n      <motion.div\n        animate={\n          shouldReduceMotion ? { opacity: 1 } : { height: \"auto\", opacity: 1 }\n        }\n        className=\"mt-4 overflow-hidden\"\n        exit={\n          shouldReduceMotion\n            ? { opacity: 0, transition: { duration: 0 } }\n            : { height: 0, opacity: 0 }\n        }\n        initial={\n          shouldReduceMotion ? { opacity: 0 } : { height: 0, opacity: 0 }\n        }\n        transition={shouldReduceMotion ? DURATION_INSTANT : SPRING_SNAPPY}\n      >\n        <div\n          className=\"rounded-lg border border-destructive/40 bg-destructive/10 px-4 py-3 text-destructive text-sm\"\n          ref={errorRef}\n          role=\"alert\"\n          tabIndex={-1}\n        >\n          {error}\n        </div>\n      </motion.div>\n    ) : null}\n  </AnimatePresence>\n);\n\n// ---------------------------------------------------------------------------\n// AuthPasswordRequirements — live-ticking password rule list\n// ---------------------------------------------------------------------------\n\ninterface AuthPasswordRequirementsProps {\n  password: string;\n  requirements: AuthPasswordRequirement[];\n  requirementsId: string;\n}\n\nconst AuthPasswordRequirements = ({\n  password,\n  requirements,\n  requirementsId,\n}: AuthPasswordRequirementsProps) => (\n  <ul\n    aria-label=\"Password requirements\"\n    className=\"mt-1 grid gap-1\"\n    id={requirementsId}\n  >\n    {requirements.map((requirement) => {\n      const passed = requirement.test(password);\n      return (\n        <li\n          aria-label={`${requirement.label}: ${passed ? \"met\" : \"not met\"}`}\n          className=\"flex items-center gap-2 text-sm\"\n          key={requirement.id}\n        >\n          <span\n            aria-hidden=\"true\"\n            className={cn(\n              \"flex h-4 w-4 shrink-0 items-center justify-center rounded-full border transition-colors\",\n              passed\n                ? \"border-emerald-500 bg-emerald-500 text-white\"\n                : \"border-muted-foreground/40 text-transparent\"\n            )}\n          >\n            <Check className=\"h-3 w-3\" strokeWidth={3} />\n          </span>\n          <span\n            className={cn(\n              \"transition-colors\",\n              passed ? \"text-foreground\" : \"text-muted-foreground\"\n            )}\n          >\n            {requirement.label}\n          </span>\n        </li>\n      );\n    })}\n  </ul>\n);\n\n// ---------------------------------------------------------------------------\n// AuthSubmitButton — morphs idle → submitting → success\n// ---------------------------------------------------------------------------\n\ninterface AuthSubmitButtonProps {\n  disabled: boolean;\n  isSignUp: boolean;\n  shouldReduceMotion: boolean | null;\n  status: AuthFormStatus;\n}\n\nconst getSubmitButtonContent = (\n  status: AuthFormStatus,\n  isSignUp: boolean,\n  shouldReduceMotion: boolean | null\n) => {\n  if (status === \"submitting\") {\n    return (\n      <motion.span\n        animate={shouldReduceMotion ? { opacity: 1 } : { opacity: 1, y: 0 }}\n        className=\"flex items-center\"\n        exit={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: -6 }}\n        initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 6 }}\n        key=\"submitting\"\n        transition={shouldReduceMotion ? DURATION_INSTANT : SPRING_SNAPPY}\n      >\n        Signing in…\n      </motion.span>\n    );\n  }\n  if (status === \"success\") {\n    return (\n      <motion.span\n        animate={shouldReduceMotion ? { opacity: 1 } : { opacity: 1, scale: 1 }}\n        className=\"flex items-center gap-2\"\n        exit={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.8 }}\n        initial={\n          shouldReduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.6 }\n        }\n        key=\"success\"\n        transition={shouldReduceMotion ? DURATION_INSTANT : SPRING_DEFAULT}\n      >\n        <Check aria-hidden=\"true\" className=\"h-4 w-4\" />\n        Success\n      </motion.span>\n    );\n  }\n  return (\n    <motion.span\n      animate={shouldReduceMotion ? { opacity: 1 } : { opacity: 1, y: 0 }}\n      className=\"flex items-center gap-2\"\n      exit={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: -6 }}\n      initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 6 }}\n      key=\"idle\"\n      transition={shouldReduceMotion ? DURATION_INSTANT : SPRING_SNAPPY}\n    >\n      {isSignUp ? \"Create account\" : \"Sign in\"}\n    </motion.span>\n  );\n};\n\nconst AuthSubmitButton = ({\n  status,\n  isSignUp,\n  shouldReduceMotion,\n  disabled,\n}: AuthSubmitButtonProps) => (\n  <SmoothButton\n    className=\"mt-1 w-full overflow-hidden font-semibold text-sm\"\n    disabled={disabled}\n    loading={status === \"submitting\"}\n    size=\"lg\"\n    type=\"submit\"\n    // `candy` is the house primary — it is what every CTA and footer block\n    // uses. Its gradient, hairline border and text shadow also give the white\n    // label real separation from the brand pink, which a flat fill does not.\n    variant=\"candy\"\n  >\n    <AnimatePresence initial={false} mode=\"wait\">\n      {getSubmitButtonContent(status, isSignUp, shouldReduceMotion)}\n    </AnimatePresence>\n  </SmoothButton>\n);\n\n// ---------------------------------------------------------------------------\n// AuthForm\n// ---------------------------------------------------------------------------\n\nexport default function AuthForm({\n  mode,\n  onModeChange,\n  providers = [],\n  onSubmit,\n  status = \"idle\",\n  error,\n  fieldErrors,\n  requirements = DEFAULT_REQUIREMENTS,\n  showMagicLink = false,\n  footer,\n  className,\n}: AuthFormProps) {\n  const shouldReduceMotion = useReducedMotion();\n  const uid = useId();\n  const [email, setEmail] = useState(\"\");\n  const [password, setPassword] = useState(\"\");\n  const [showPassword, setShowPassword] = useState(false);\n  const [direction, setDirection] = useState(0);\n  const prevModeRef = useRef(mode);\n  const passwordContentRef = useRef<HTMLDivElement>(null);\n  const errorSummaryRef = useRef<HTMLDivElement>(null);\n  const [passwordHeight, setPasswordHeight] = useState(0);\n\n  const isSubmitting = status === \"submitting\";\n  const isSuccess = status === \"success\";\n  const isEmailValid = EMAIL_PATTERN.test(email.trim());\n  const isSignUp = mode === \"sign-up\";\n\n  const emailId = `${uid}-email`;\n  const emailErrorId = `${uid}-email-error`;\n  const passwordId = `${uid}-password`;\n  const passwordErrorId = `${uid}-password-error`;\n  const requirementsId = `${uid}-requirements`;\n\n  useEffect(() => {\n    if (prevModeRef.current !== mode) {\n      setDirection(mode === \"sign-up\" ? 1 : -1);\n      prevModeRef.current = mode;\n    }\n  }, [mode]);\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: isSignUp/password/requirements/fieldErrors intentionally re-measure the reveal height whenever the field's rendered content changes\n  useLayoutEffect(() => {\n    const el = passwordContentRef.current;\n    if (!el) {\n      return;\n    }\n    setPasswordHeight(isEmailValid ? el.scrollHeight : 0);\n  }, [isEmailValid, isSignUp, password, requirements, fieldErrors?.password]);\n\n  useEffect(() => {\n    if (status === \"error\" && error) {\n      errorSummaryRef.current?.focus();\n    }\n  }, [status, error]);\n\n  const handleSubmit = (event: FormEvent<HTMLFormElement>) => {\n    event.preventDefault();\n    onSubmit?.({ email, password });\n  };\n\n  const handleMagicLink = () => {\n    onSubmit?.({ email, password: \"\" });\n  };\n\n  const getStatusMessage = () => {\n    if (isSubmitting) {\n      return \"Submitting…\";\n    }\n    if (isSuccess) {\n      return \"Success. You're signed in.\";\n    }\n    if (status === \"error\" && error) {\n      return error;\n    }\n    return \"\";\n  };\n\n  const passwordDescribedBy =\n    [\n      fieldErrors?.password ? passwordErrorId : null,\n      isSignUp ? requirementsId : null,\n    ]\n      .filter(Boolean)\n      .join(\" \") || undefined;\n\n  return (\n    <div className={cn(\"w-full max-w-sm\", className)}>\n      <AuthModeHeader\n        direction={direction}\n        isSignUp={isSignUp}\n        mode={mode}\n        shouldReduceMotion={shouldReduceMotion}\n      />\n\n      <AuthProviderList providers={providers} />\n\n      <AuthErrorSummary\n        error={error}\n        errorRef={errorSummaryRef}\n        shouldReduceMotion={shouldReduceMotion}\n        status={status}\n      />\n\n      <form className=\"mt-4 grid gap-4\" onSubmit={handleSubmit}>\n        <div className=\"grid gap-1.5\">\n          <label\n            className=\"font-medium text-foreground text-sm\"\n            htmlFor={emailId}\n          >\n            Email\n          </label>\n          <input\n            aria-describedby={fieldErrors?.email ? emailErrorId : undefined}\n            aria-invalid={Boolean(fieldErrors?.email)}\n            autoComplete=\"email\"\n            className=\"min-h-[44px] w-full rounded-lg border bg-background px-3 text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 aria-invalid:border-destructive\"\n            disabled={isSubmitting || isSuccess}\n            id={emailId}\n            inputMode=\"email\"\n            name=\"email\"\n            onChange={(event) => setEmail(event.target.value)}\n            required\n            type=\"email\"\n            value={email}\n          />\n          {fieldErrors?.email ? (\n            <p\n              className=\"text-destructive text-sm\"\n              id={emailErrorId}\n              role=\"alert\"\n            >\n              {fieldErrors.email}\n            </p>\n          ) : null}\n        </div>\n\n        <motion.div\n          animate={\n            shouldReduceMotion\n              ? { opacity: isEmailValid ? 1 : 0 }\n              : { height: passwordHeight, opacity: isEmailValid ? 1 : 0 }\n          }\n          aria-hidden={!isEmailValid}\n          initial={false}\n          style={\n            shouldReduceMotion\n              ? { height: isEmailValid ? \"auto\" : 0, overflow: \"hidden\" }\n              : { overflow: \"hidden\" }\n          }\n          transition={shouldReduceMotion ? DURATION_INSTANT : SPRING_DEFAULT}\n        >\n          <div className=\"grid gap-1.5 pt-1\" ref={passwordContentRef}>\n            <label\n              className=\"font-medium text-foreground text-sm\"\n              htmlFor={passwordId}\n            >\n              Password\n            </label>\n            <div className=\"relative\">\n              <input\n                aria-describedby={passwordDescribedBy}\n                aria-invalid={Boolean(fieldErrors?.password)}\n                autoComplete={isSignUp ? \"new-password\" : \"current-password\"}\n                className=\"min-h-[44px] w-full rounded-lg border bg-background px-3 pr-11 text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 aria-invalid:border-destructive\"\n                disabled={isSubmitting || isSuccess}\n                id={passwordId}\n                minLength={isSignUp ? 8 : undefined}\n                name=\"password\"\n                onChange={(event) => setPassword(event.target.value)}\n                required={isEmailValid}\n                tabIndex={isEmailValid ? undefined : -1}\n                type={showPassword ? \"text\" : \"password\"}\n                value={password}\n              />\n              <SmoothButton\n                aria-label={showPassword ? \"Hide password\" : \"Show password\"}\n                aria-pressed={showPassword}\n                className=\"absolute top-1/2 right-1 -translate-y-1/2 text-muted-foreground hover:text-foreground [&_svg]:size-4\"\n                onClick={() => setShowPassword((value) => !value)}\n                shape=\"pill\"\n                size=\"icon\"\n                tabIndex={isEmailValid ? undefined : -1}\n                variant=\"ghost\"\n              >\n                {showPassword ? (\n                  <EyeOff aria-hidden=\"true\" />\n                ) : (\n                  <Eye aria-hidden=\"true\" />\n                )}\n              </SmoothButton>\n            </div>\n            {fieldErrors?.password ? (\n              <p\n                className=\"text-destructive text-sm\"\n                id={passwordErrorId}\n                role=\"alert\"\n              >\n                {fieldErrors.password}\n              </p>\n            ) : null}\n\n            {isSignUp ? (\n              <AuthPasswordRequirements\n                password={password}\n                requirements={requirements}\n                requirementsId={requirementsId}\n              />\n            ) : null}\n\n            {showMagicLink ? (\n              <SmoothButton\n                className=\"mt-1 -ml-3 justify-self-start\"\n                color=\"accent\"\n                onClick={handleMagicLink}\n                size=\"sm\"\n                variant=\"link\"\n              >\n                Send a magic link instead\n              </SmoothButton>\n            ) : null}\n          </div>\n        </motion.div>\n\n        <AuthSubmitButton\n          disabled={isSubmitting || isSuccess}\n          isSignUp={isSignUp}\n          shouldReduceMotion={shouldReduceMotion}\n          status={status}\n        />\n\n        <p aria-live=\"polite\" className=\"sr-only\" role=\"status\">\n          {getStatusMessage()}\n        </p>\n      </form>\n\n      <p className=\"mt-5 text-center text-muted-foreground text-sm\">\n        {isSignUp ? \"Already have an account?\" : \"Don't have an account?\"}{\" \"}\n        <button\n          className=\"font-medium text-brand underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\"\n          onClick={() => onModeChange?.(isSignUp ? \"sign-in\" : \"sign-up\")}\n          type=\"button\"\n        >\n          {isSignUp ? \"Sign in\" : \"Sign up\"}\n        </button>\n      </p>\n\n      {footer}\n    </div>\n  );\n}\n","path":"index.tsx","target":"components/smoothui/auth-form/index.tsx","type":"registry:ui"}],"name":"auth-form","registryDependencies":["https://smoothui.dev/r/smooth-button.json","https://smoothui.dev/r/lib.json","https://smoothui.dev/r/tokens.json"],"title":"Auth Form","type":"registry:ui"}