# Sign-in Form

> Sign-in form with social providers, validation, remember me and loading state.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/sign-in-form
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/sign-in-form.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/sign-in-form-demo.tsx"
"use client"

import { SignInForm } from "@/components/sign-in-form"

function GitHubIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden className={className}>
      <path d="M12 .5a11.5 11.5 0 0 0-3.64 22.41c.58.1.79-.25.79-.56v-2c-3.2.7-3.88-1.37-3.88-1.37-.52-1.33-1.28-1.69-1.28-1.69-1.04-.71.08-.7.08-.7 1.16.08 1.77 1.19 1.77 1.19 1.03 1.76 2.7 1.25 3.36.96.1-.75.4-1.25.73-1.54-2.56-.29-5.24-1.28-5.24-5.69 0-1.26.45-2.29 1.19-3.1-.12-.29-.52-1.46.11-3.05 0 0 .97-.31 3.17 1.18a11 11 0 0 1 5.77 0c2.2-1.49 3.17-1.18 3.17-1.18.63 1.59.23 2.76.11 3.05.74.81 1.19 1.84 1.19 3.1 0 4.42-2.69 5.39-5.25 5.68.41.36.78 1.06.78 2.14v3.17c0 .31.21.67.8.56A11.5 11.5 0 0 0 12 .5Z" />
    </svg>
  )
}

function GoogleIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" aria-hidden className={className}>
      <path fill="#4285F4" d="M23.5 12.27c0-.85-.08-1.67-.22-2.45H12v4.64h6.46a5.52 5.52 0 0 1-2.4 3.62v3h3.88c2.27-2.09 3.56-5.17 3.56-8.81Z" />
      <path fill="#34A853" d="M12 24c3.24 0 5.96-1.07 7.94-2.92l-3.88-3c-1.07.72-2.45 1.15-4.06 1.15-3.12 0-5.77-2.11-6.71-4.95H1.28v3.1A12 12 0 0 0 12 24Z" />
      <path fill="#FBBC05" d="M5.29 14.28a7.2 7.2 0 0 1 0-4.56v-3.1H1.28a12 12 0 0 0 0 10.76l4.01-3.1Z" />
      <path fill="#EA4335" d="M12 4.77c1.76 0 3.34.61 4.59 1.8l3.44-3.44A11.5 11.5 0 0 0 12 0 12 12 0 0 0 1.28 6.62l4.01 3.1C6.23 6.88 8.88 4.77 12 4.77Z" />
    </svg>
  )
}

const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))

export default function SignInFormDemo() {
  return (
    <SignInForm
      providers={[
        { id: "github", label: "GitHub", icon: GitHubIcon },
        { id: "google", label: "Google", icon: GoogleIcon },
      ]}
      onSubmit={async ({ password }) => {
        await wait(1000)
        if (password !== "password123") throw new Error("Wrong email or password. Hint: password123")
      }}
    />
  )
}
```

## Source

```tsx title="components/sign-in-form.tsx"
"use client"

import * as React from "react"
import { Eye, EyeOff, LoaderCircle } from "lucide-react"

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

export type SignInValues = { email: string; password: string; remember: boolean }
export type SignInProvider = { id: string; label: string; icon: React.ComponentType<{ className?: string }> }

export interface SignInFormProps extends Omit<React.ComponentProps<"div">, "onSubmit" | "title"> {
  /** Resolve on success; throw an Error to show its message above the form */
  onSubmit?: (values: SignInValues) => Promise<void> | void
  onProvider?: (id: string) => void
  providers?: SignInProvider[]
  title?: React.ReactNode
  description?: React.ReactNode
  submitLabel?: string
  forgotHref?: string
  signUpHref?: string
  /** Show the "Remember me" checkbox */
  showRemember?: boolean
}

type Errors = Partial<Record<"email" | "password" | "form", string>>

function validate({ email, password }: SignInValues): Errors {
  const errors: Errors = {}
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errors.email = "Enter a valid email address."
  if (password.length < 8) errors.password = "Password must be at least 8 characters."
  return errors
}

const fieldClass =
  "h-10 w-full rounded-lg border bg-background px-3 text-sm shadow-xs transition-[border-color,box-shadow] outline-none placeholder:text-muted-foreground focus:border-ring focus:ring-3 focus:ring-ring/20 aria-invalid:border-destructive aria-invalid:focus:ring-destructive/20"

export function SignInForm({
  onSubmit,
  onProvider,
  providers = [],
  title = "Welcome back",
  description = "Sign in to your account to continue.",
  submitLabel = "Sign in",
  forgotHref = "#",
  signUpHref = "#",
  showRemember = true,
  className,
  ...props
}: SignInFormProps) {
  const id = React.useId()
  const [values, setValues] = React.useState<SignInValues>({ email: "", password: "", remember: true })
  const [errors, setErrors] = React.useState<Errors>({})
  const [touched, setTouched] = React.useState(false)
  const [pending, setPending] = React.useState(false)
  const [visible, setVisible] = React.useState(false)

  const change = (patch: Partial<SignInValues>) => {
    const next = { ...values, ...patch }
    setValues(next)
    // Re-validate as you type once the form has been submitted
    if (touched) setErrors(validate(next))
  }

  const submit = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault()
    setTouched(true)
    const found = validate(values)
    setErrors(found)
    if (found.email || found.password) {
      event.currentTarget.querySelector<HTMLInputElement>("[aria-invalid=true]")?.focus()
      return
    }
    setPending(true)
    try {
      await onSubmit?.(values)
    } catch (error) {
      setErrors({ form: error instanceof Error ? error.message : "Sign in failed." })
    } finally {
      setPending(false)
    }
  }

  return (
    <div className={cn("w-full max-w-sm rounded-2xl border bg-card p-6 text-card-foreground shadow-sm sm:p-8", className)} {...props}>
      <div className="mb-6 text-center">
        <h2 className="text-xl font-semibold tracking-tight">{title}</h2>
        {description && <p className="mt-1.5 text-sm text-muted-foreground">{description}</p>}
      </div>

      {providers.length > 0 && (
        <>
          <div className={cn("grid gap-2", providers.length > 1 && "grid-cols-2")}>
            {providers.map(({ id: providerId, label, icon: Icon }) => (
              <button
                key={providerId}
                type="button"
                onClick={() => onProvider?.(providerId)}
                className="inline-flex h-10 items-center justify-center gap-2 rounded-lg border bg-background text-sm font-medium shadow-xs transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
              >
                <Icon className="size-4" />
                {label}
              </button>
            ))}
          </div>
          <div className="my-5 flex items-center gap-3 text-xs text-muted-foreground">
            <span className="h-px flex-1 bg-border" />
            or continue with email
            <span className="h-px flex-1 bg-border" />
          </div>
        </>
      )}

      <form noValidate onSubmit={submit} className="flex flex-col gap-4">
        {errors.form && (
          <p role="alert" className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
            {errors.form}
          </p>
        )}
        <div className="flex flex-col gap-1.5">
          <label htmlFor={`${id}-email`} className="text-sm font-medium">
            Email
          </label>
          <input
            id={`${id}-email`}
            type="email"
            autoComplete="email"
            placeholder="you@company.com"
            value={values.email}
            onChange={(event) => change({ email: event.target.value })}
            aria-invalid={!!errors.email}
            aria-describedby={errors.email ? `${id}-email-error` : undefined}
            className={fieldClass}
          />
          {errors.email && (
            <p id={`${id}-email-error`} className="text-xs text-destructive">
              {errors.email}
            </p>
          )}
        </div>
        <div className="flex flex-col gap-1.5">
          <div className="flex items-center justify-between">
            <label htmlFor={`${id}-password`} className="text-sm font-medium">
              Password
            </label>
            <a href={forgotHref} className="text-xs text-muted-foreground underline-offset-4 hover:text-foreground hover:underline">
              Forgot password?
            </a>
          </div>
          <div className="relative">
            <input
              id={`${id}-password`}
              type={visible ? "text" : "password"}
              autoComplete="current-password"
              value={values.password}
              onChange={(event) => change({ password: event.target.value })}
              aria-invalid={!!errors.password}
              aria-describedby={errors.password ? `${id}-password-error` : undefined}
              className={cn(fieldClass, "pr-10")}
            />
            <button
              type="button"
              onClick={() => setVisible((v) => !v)}
              aria-label={visible ? "Hide password" : "Show password"}
              className="absolute top-1 right-1 flex size-8 items-center justify-center rounded-md text-muted-foreground hover:text-foreground"
            >
              {visible ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
            </button>
          </div>
          {errors.password && (
            <p id={`${id}-password-error`} className="text-xs text-destructive">
              {errors.password}
            </p>
          )}
        </div>
        {showRemember && (
          <label className="flex items-center gap-2 text-sm text-muted-foreground">
            <input
              type="checkbox"
              checked={values.remember}
              onChange={(event) => change({ remember: event.target.checked })}
              className="size-4 rounded accent-primary"
            />
            Remember me for 30 days
          </label>
        )}
        <button
          type="submit"
          disabled={pending}
          className="mt-1 inline-flex h-10 items-center justify-center gap-2 rounded-lg bg-primary text-sm font-medium text-primary-foreground shadow-xs transition-opacity hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:opacity-70"
        >
          {pending && <LoaderCircle className="size-4 animate-spin" />}
          {submitLabel}
        </button>
      </form>
      <p className="mt-6 text-center text-sm text-muted-foreground">
        Don&apos;t have an account?{" "}
        <a href={signUpHref} className="font-medium text-foreground underline-offset-4 hover:underline">
          Sign up
        </a>
      </p>
    </div>
  )
}
```
