# Login Form

> A login with labels that stay visible and inline errors, in three forms: beside a calm brand panel with a password you can reveal, one card on a grey ground, or email only with a check-your-inbox screen.

A shadcn/ui block from the [@miseui](https://miseui.junsu4621.workers.dev/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/miseui/login-form).

- Page, with a live preview: https://shadcn.exchange/r/miseui/login-form
- Screenshot: https://shadcn.exchange/shot/miseui/login-form/light.png
- Registry item: https://miseui.junsu4621.workers.dev/r/login-form.json
- Categories: blocks
- Listed: 2026-11-05, source last changed 2026-11-05

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/miseui/login-form.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`, `motion`
- Registry items: `utils`, `https://miseui.junsu4621.workers.dev/r/motion.json`, `https://miseui.junsu4621.workers.dev/r/badge.json`, `https://miseui.junsu4621.workers.dev/r/button.json`, `https://miseui.junsu4621.workers.dev/r/card.json`, `https://miseui.junsu4621.workers.dev/r/input.json`, `https://miseui.junsu4621.workers.dev/r/label.json`

## Files

### `registry/miseui/blocks/login-form.tsx`

```tsx
import type * as React from "react"
import { cn } from "@/registry/miseui/lib/utils"
import { LoginFields, SignUpLine, type LoginFieldsProps } from "./login-form-credentials"
import { LoginFormMagic, type LoginMagicProps } from "./login-form-magic"
import type { LoginProvider, LoginValues } from "./login-form-parts"
import { BrandPanel, InvoiceFragments } from "./login-form-visual"

type LoginFormVariant = "split" | "centered" | "magic"

const PREVIEW_DELAY_MS = 1200
const DEFAULT_PROVIDERS: readonly LoginProvider[] = [
  { id: "google", label: "Google" },
  { id: "github", label: "GitHub" },
]
/** Scoped to touch, on this design only: the shared fields stay at 36 px with a mouse. */
const LINK_TOUCH_CLASS = "pointer-coarse:[&_a]:relative pointer-coarse:[&_a]:after:absolute pointer-coarse:[&_a]:after:-inset-x-1 pointer-coarse:[&_a]:after:-inset-y-3"
const TOUCH_TARGETS_CLASS = "pointer-coarse:[&_[data-slot=button]]:h-10 pointer-coarse:[&_[data-slot=button]]:min-w-10 pointer-coarse:[&_input]:h-10"

/** Stands in until you pass onSubmit, so the preview shows the loading and error states. Replace it. */
function previewSubmit() {
  return new Promise<void>((_, reject) => {
    window.setTimeout(() => reject(new Error("That password does not match this email.")), PREVIEW_DELAY_MS)
  })
}

interface LoginFormProps extends Omit<React.ComponentProps<"section">, "onSubmit">, LoginMagicProps {
  /** `split` the form beside a brand panel, `centered` one card on the grey ground, `magic` email only, then check your inbox. */
  variant?: LoginFormVariant
  /** Called once the fields pass the basic checks. Return a promise to get the loading state; a rejection's message is shown inline. */
  onSubmit?: (values: LoginValues) => void | Promise<void>
  /** An error you already know about, such as a locked account. Shown under the last field. */
  error?: string
  /** Forces the loading state, if you track the request yourself. */
  pending?: boolean
  brandName?: string
  /** The one product sentence on the panel, on `split`. Keep it true; this is no place for a testimonial. */
  tagline?: string
  /** The muted second sentence of the heading. */
  headingNote?: string
  /** Small pieces of your real product for the panel, on `split`. The default is a sample invoice; null leaves only the sentence. */
  visual?: React.ReactNode
  forgotPasswordHref?: string
  /** Where "Create an account" goes. Null removes the line, for invite-only products. */
  signUpHref?: string | null
  /** Pass an empty list if you only support email. `magic` shows none unless you pass some. */
  socialProviders?: readonly LoginProvider[]
  onSocial?: (id: string) => void
}

type LoginShellProps = Omit<React.ComponentProps<"section">, "onSubmit"> & {
  brandName: string
  headingNote: string
  signUpHref: string | null
  fields: LoginFieldsProps
}

function LoginSplit({ brandName, headingNote, signUpHref, fields, tagline, visual, className, ...props }: LoginShellProps & { tagline: string; visual: React.ReactNode }) {
  return (
    <section data-slot="login-form" data-variant="split" className={cn("@container flex w-full flex-col bg-background", className)} {...props}>
      <div className="grid min-h-[640px] flex-1 grid-cols-1 @3xl:grid-cols-2">
        <div className="flex items-center justify-center px-6 py-12 @3xl:px-12">
          <div className="flex w-full max-w-sm flex-col gap-8">
            <h2 className="text-display text-3xl leading-[1.12]! text-balance @2xl:text-4xl">
              Log in to {brandName}. <span className="text-muted-foreground">{headingNote}</span>
            </h2>
            <LoginFields {...fields} />
            <SignUpLine href={signUpHref} />
          </div>
        </div>
        <BrandPanel brandName={brandName} tagline={tagline} visual={visual} />
      </div>
    </section>
  )
}

/** The sign-up line sits outside the card: it is for people who do not belong in the card yet. */
function LoginCentered({ brandName, headingNote, signUpHref, fields, className, ...props }: LoginShellProps) {
  return (
    <section data-slot="login-form" data-variant="centered" className={cn("@container flex w-full flex-col bg-muted dark:bg-background", className)} {...props}>
      <div className={cn("flex min-h-[640px] flex-1 flex-col items-center justify-center gap-6 px-4 py-12 @md:px-6", LINK_TOUCH_CLASS)}>
        <div className={cn("flex w-full max-w-sm flex-col gap-6 rounded-2xl border bg-background p-6 @md:p-8 dark:bg-card", TOUCH_TARGETS_CLASS)}>
          <h2 className="text-display text-2xl leading-[1.12]! text-balance">
            Log in to {brandName}. <span className="text-muted-foreground">{headingNote}</span>
          </h2>
          <LoginFields {...fields} />
        </div>
        <SignUpLine href={signUpHref} />
      </div>
    </section>
  )
}

function LoginForm({
  variant,
  onSendLink,
  resendAfterSeconds,
  sentNote,
  onSubmit = previewSubmit,
  error,
  pending = false,
  brandName = "Paperline",
  tagline = "Invoices that chase themselves, so you do not have to.",
  headingNote,
  visual = <InvoiceFragments />,
  forgotPasswordHref = "#forgot-password",
  signUpHref,
  socialProviders,
  onSocial,
  ...props
}: LoginFormProps) {
  if (variant === "magic") {
    return (
      <LoginFormMagic
        {...{ onSendLink, resendAfterSeconds, sentNote, error, pending, brandName, headingNote, signUpHref, socialProviders, onSocial }}
        {...props}
      />
    )
  }
  const fields = { onSubmit, error, pending, forgotPasswordHref, socialProviders: socialProviders ?? DEFAULT_PROVIDERS, onSocial }
  const shell = { brandName, headingNote: headingNote ?? "Your invoices are where you left them.", signUpHref: signUpHref === undefined ? "#sign-up" : signUpHref, fields }
  if (variant === "centered") return <LoginCentered {...shell} {...props} />
  return <LoginSplit {...shell} tagline={tagline} visual={visual} {...props} />
}

export { LoginForm, type LoginFormProps, type LoginFormVariant, type LoginProvider, type LoginValues }
```

### `registry/miseui/blocks/login-form-credentials.tsx`

```tsx
import type * as React from "react"
import { useId, useRef } from "react"
import { Loader2 } from "lucide-react"
import { cn } from "@/registry/miseui/lib/utils"
import { Button } from "@/registry/miseui/ui/button"
import { Label } from "@/registry/miseui/ui/label"
import {
  EmailField,
  FieldError,
  PasswordField,
  SocialRow,
  useLoginForm,
  type LoginProvider,
  type LoginValues,
} from "./login-form-parts"

const LINK_CLASS =
  "rounded-sm underline-offset-4 outline-none hover:underline focus-visible:ring-[3px] focus-visible:ring-ring/50"

function SignUpLine({ href }: { href: string | null }) {
  if (!href) return null
  return (
    <p className="text-[13px] leading-relaxed text-muted-foreground">
      No account yet?{" "}
      <a href={href} className={cn(LINK_CLASS, "font-medium text-foreground")}>
        Create an account
      </a>
    </p>
  )
}

interface CredentialsFormProps {
  form: ReturnType<typeof useLoginForm>
  emailRef: React.RefObject<HTMLInputElement | null>
  passwordRef: React.RefObject<HTMLInputElement | null>
  busy: boolean
  error?: string
  forgotPasswordHref: string
}

/** Inputs go read-only while busy instead of disabled: disabling would throw away focus and hide the text. */
function CredentialsForm({ form, emailRef, passwordRef, busy, error, forgotPasswordHref }: CredentialsFormProps) {
  const emailId = useId()
  const passwordId = useId()
  const passwordErrorId = `${passwordId}-error`
  const passwordError = form.errors.password ?? form.submitError ?? error

  const submit = (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault()
    if (!busy) void form.submit()
  }

  return (
    <form noValidate onSubmit={submit} className="flex flex-col gap-6">
      <EmailField
        ref={emailRef}
        id={emailId}
        error={form.errors.email}
        readOnly={busy}
        value={form.values.email}
        onChange={(event) => form.change("email", event.target.value)}
      />
      <div className="flex flex-col gap-2">
        <Label htmlFor={passwordId}>Password</Label>
        <PasswordField
          ref={passwordRef}
          id={passwordId}
          readOnly={busy}
          value={form.values.password}
          onChange={(event) => form.change("password", event.target.value)}
          aria-invalid={passwordError ? true : undefined}
          aria-describedby={passwordError ? passwordErrorId : undefined}
        />
        {/* Under the field, not beside its label: there, Tab would land on the link between email and password. */}
        <div className="flex items-start justify-between gap-4">
          <div className="min-w-0 flex-1">{passwordError ? <FieldError id={passwordErrorId}>{passwordError}</FieldError> : null}</div>
          <a href={forgotPasswordHref} className={cn(LINK_CLASS, "shrink-0 text-[13px] text-muted-foreground hover:text-foreground")}>
            Forgot password?
          </a>
        </div>
      </div>
      <Button type="submit" disabled={busy} aria-busy={busy || undefined} className="w-full">
        {busy ? <Loader2 aria-hidden="true" className="animate-spin motion-reduce:animate-none" /> : null}
        {busy ? "Logging in" : "Log in"}
      </Button>
    </form>
  )
}

interface LoginFieldsProps {
  onSubmit: (values: LoginValues) => void | Promise<void>
  error?: string
  pending: boolean
  forgotPasswordHref: string
  socialProviders: readonly LoginProvider[]
  onSocial?: (id: string) => void
}

/** Social row and password form, the part `split` and `centered` share. Only what is around them differs. */
function LoginFields({ onSubmit, error, pending, forgotPasswordHref, socialProviders, onSocial }: LoginFieldsProps) {
  const emailRef = useRef<HTMLInputElement>(null)
  const passwordRef = useRef<HTMLInputElement>(null)
  const form = useLoginForm(onSubmit, (field) => (field === "email" ? emailRef : passwordRef).current?.focus())
  const busy = pending || form.submitting
  return (
    <div className="flex flex-col gap-6">
      <SocialRow providers={socialProviders} disabled={busy} onSocial={onSocial} />
      <CredentialsForm form={form} emailRef={emailRef} passwordRef={passwordRef} busy={busy} error={error} forgotPasswordHref={forgotPasswordHref} />
    </div>
  )
}

export { LINK_CLASS, LoginFields, SignUpLine, type LoginFieldsProps }
```

### `registry/miseui/blocks/login-form-magic.tsx`

```tsx
import type * as React from "react"
import { useEffect, useId, useRef } from "react"
import { Loader2, Mail } from "lucide-react"
import { motion, useReducedMotion } from "motion/react"
import { SPRING_DEFAULT } from "@/registry/miseui/lib/motion"
import { cn } from "@/registry/miseui/lib/utils"
import { Button } from "@/registry/miseui/ui/button"
import { SignUpLine } from "./login-form-credentials"
import { useMagicLink, type MagicLink } from "./login-form-magic-state"
import { EmailField, SocialRow, type LoginProvider } from "./login-form-parts"

const PREVIEW_DELAY_MS = 900
const DEFAULT_RESEND_SECONDS = 30
const NO_PROVIDERS: readonly LoginProvider[] = []
const MARK_START = { opacity: 0, scale: 0.6 }
const MARK_END = { opacity: 1, scale: 1 }

/** Stands in until you pass onSendLink, so the preview reaches the inbox screen. Replace it. */
function previewSend() {
  return new Promise<void>((resolve) => {
    window.setTimeout(resolve, PREVIEW_DELAY_MS)
  })
}

interface LoginMagicProps {
  /** Send the link here. Return a promise to get the loading state; a rejection's message is shown inline. */
  onSendLink?: (email: string) => void | Promise<void>
  /** How long before another link may be requested. Long enough for mail to arrive, so the button does not invite duplicates. */
  resendAfterSeconds?: number
  /** Under the address on the inbox screen. Say how long your link really lasts. */
  sentNote?: string
}

function RequestForm({ link, pending, error }: { link: MagicLink; pending: boolean; error?: string }) {
  const fieldId = useId()
  const fieldRef = useRef<HTMLInputElement>(null)
  const busy = pending || link.sending
  // Back from the inbox screen: this form has just replaced the button they pressed, and focus would fall to the page.
  useEffect(() => {
    if (link.cameBack) fieldRef.current?.focus()
  }, [link.cameBack])

  const submit = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault()
    if (busy) return
    if (!(await link.submit())) fieldRef.current?.focus()
  }

  return (
    <form noValidate onSubmit={submit} className="flex flex-col gap-4">
      <EmailField
        ref={fieldRef}
        id={fieldId}
        error={link.fieldError ?? link.sendError ?? error}
        readOnly={busy}
        value={link.email}
        placeholder="maya@northgate.studio"
        onChange={(event) => link.change(event.target.value)}
        className="pointer-coarse:h-10"
      />
      <Button type="submit" disabled={busy} aria-busy={busy || undefined} className="w-full pointer-coarse:h-10">
        {busy ? <Loader2 aria-hidden="true" className="animate-spin motion-reduce:animate-none" /> : null}
        {busy ? "Sending the link" : "Email me a link"}
      </Button>
    </form>
  )
}

function InboxScreen({ link, note }: { link: MagicLink; note: string }) {
  const reduceMotion = useReducedMotion()
  const headingRef = useRef<HTMLHeadingElement>(null)
  // Takes focus so a screen reader hears the result instead of silence after the form disappears.
  useEffect(() => headingRef.current?.focus(), [])
  const waiting = link.secondsLeft > 0
  return (
    <div className="flex flex-col items-start gap-6">
      <motion.span
        data-testid="inbox-mark"
        className="grid size-12 place-items-center rounded-full bg-muted"
        initial={reduceMotion ? false : MARK_START}
        animate={MARK_END}
        transition={SPRING_DEFAULT}
      >
        <Mail aria-hidden="true" className="size-6" />
      </motion.span>
      <h2 ref={headingRef} tabIndex={-1} className="text-display text-3xl leading-[1.12]! text-balance break-words outline-none @2xl:text-4xl">
        Check your inbox. <span className="text-muted-foreground">We sent a link to {link.sentTo}.</span>
      </h2>
      <p className="text-[15px] leading-relaxed text-pretty text-muted-foreground">{note}</p>
      {link.sendError ? (
        <p role="alert" className="text-[13px] leading-snug text-pretty text-destructive">
          {link.sendError}
        </p>
      ) : null}
      <div className="flex w-full flex-col gap-2">
        <Button variant="outline" disabled={waiting || link.sending} aria-busy={link.sending || undefined} onClick={() => void link.resend()} className="w-full tabular-nums pointer-coarse:h-10">
          {link.sending ? <Loader2 aria-hidden="true" className="animate-spin motion-reduce:animate-none" /> : null}
          {waiting ? `Send again in ${link.secondsLeft}s` : "Send again"}
        </Button>
        <Button variant="ghost" onClick={link.startOver} className="w-full pointer-coarse:h-10">
          Use a different email
        </Button>
      </div>
      <p role="status" className="min-h-5 text-[13px] leading-5 text-muted-foreground">
        {link.resent ? "Another link is on its way." : ""}
      </p>
    </div>
  )
}

interface LoginFormMagicProps extends Omit<React.ComponentProps<"section">, "onSubmit">, LoginMagicProps {
  error?: string
  pending?: boolean
  brandName?: string
  headingNote?: string
  signUpHref?: string | null
  socialProviders?: readonly LoginProvider[]
  onSocial?: (id: string) => void
}

/** No card and no panel: with one field left, the sentence and the field are the whole design. */
function LoginFormMagic({
  onSendLink = previewSend,
  resendAfterSeconds = DEFAULT_RESEND_SECONDS,
  sentNote = "It works for 15 minutes. Not there? Look in spam.",
  error,
  pending = false,
  brandName = "Paperline",
  headingNote = "No password to remember.",
  signUpHref = null,
  socialProviders = NO_PROVIDERS,
  onSocial,
  className,
  ...props
}: LoginFormMagicProps) {
  const link = useMagicLink(onSendLink, resendAfterSeconds)
  return (
    <section data-slot="login-form" data-variant="magic" className={cn("@container flex w-full flex-col bg-background", className)} {...props}>
      <div className="flex min-h-[640px] flex-1 flex-col items-center justify-center px-6 py-12">
        <div className="flex w-full max-w-sm flex-col gap-8">
          {link.sentTo === null ? (
            <>
              <h2 className="text-display text-3xl leading-[1.12]! text-balance @2xl:text-4xl">
                Log in to {brandName}. <span className="text-muted-foreground">{headingNote}</span>
              </h2>
              <div className="flex flex-col gap-6">
                <SocialRow providers={socialProviders} disabled={pending || link.sending} onSocial={onSocial} />
                <RequestForm link={link} pending={pending} error={error} />
              </div>
              {signUpHref ? <SignUpLine href={signUpHref} /> : <p className="text-[13px] leading-relaxed text-muted-foreground">New here? The same link creates your account.</p>}
            </>
          ) : (
            <InboxScreen link={link} note={sentNote} />
          )}
        </div>
      </div>
    </section>
  )
}

export { LoginFormMagic, type LoginMagicProps }
```

### `registry/miseui/blocks/login-form-magic-state.ts`

```ts
import { useEffect, useState } from "react"
import { validateEmail } from "./login-form-parts"

const SEND_FAILED = "Could not send the link. Check your connection and try again."
const TICK_MS = 1000

function useCountdown() {
  const [secondsLeft, setSecondsLeft] = useState(0)
  const running = secondsLeft > 0
  useEffect(() => {
    if (!running) return
    const id = window.setInterval(() => setSecondsLeft((seconds) => Math.max(0, seconds - 1)), TICK_MS)
    return () => window.clearInterval(id)
  }, [running])
  return [secondsLeft, setSecondsLeft] as const
}

/** The request itself and the wait before another may go out. */
function useLinkRequest(onSendLink: (email: string) => void | Promise<void>, resendAfterSeconds: number) {
  const [sendError, setSendError] = useState<string>()
  const [sending, setSending] = useState(false)
  const [sentTo, setSentTo] = useState<string | null>(null)
  const [secondsLeft, setSecondsLeft] = useCountdown()

  const send = async (address: string) => {
    setSending(true)
    setSendError(undefined)
    try {
      await onSendLink(address)
      setSentTo(address)
      setSecondsLeft(resendAfterSeconds)
      return true
    } catch (failure) {
      setSendError(failure instanceof Error ? failure.message : SEND_FAILED)
      return false
    } finally {
      setSending(false)
    }
  }

  const reset = () => {
    setSentTo(null)
    setSendError(undefined)
    setSecondsLeft(0)
  }

  return { sendError, setSendError, sending, sentTo, secondsLeft, send, reset }
}

/** The screen's state. The address survives going back, so nobody retypes it. */
function useMagicLink(onSendLink: (email: string) => void | Promise<void>, resendAfterSeconds: number) {
  const [email, setEmail] = useState("")
  const [fieldError, setFieldError] = useState<string>()
  const [resent, setResent] = useState(false)
  const [cameBack, setCameBack] = useState(false)
  const { sendError, setSendError, sending, sentTo, secondsLeft, send, reset } = useLinkRequest(onSendLink, resendAfterSeconds)

  /** False when the field holds the person back, so the screen can move focus to it. */
  const submit = async () => {
    const problem = validateEmail(email)
    setFieldError(problem)
    if (problem) return false
    return send(email.trim())
  }

  const resend = async () => {
    if (sentTo === null || secondsLeft > 0 || sending) return
    setResent(await send(sentTo))
  }

  const change = (value: string) => {
    setEmail(value)
    setFieldError(undefined)
    setSendError(undefined)
  }

  const startOver = () => {
    reset()
    setResent(false)
    setCameBack(true)
  }

  return { email, fieldError, sendError, sending, sentTo, resent, cameBack, secondsLeft, change, submit, resend, startOver }
}

type MagicLink = ReturnType<typeof useMagicLink>

export { useMagicLink, type MagicLink }
```

### `registry/miseui/blocks/login-form-parts.tsx`

```tsx
import type * as React from "react"
import { useState } from "react"
import { Eye, EyeOff } from "lucide-react"
import { motion, useReducedMotion } from "motion/react"
import { SPRING_DEFAULT } from "@/registry/miseui/lib/motion"
import { Button } from "@/registry/miseui/ui/button"
import { Input } from "@/registry/miseui/ui/input"
import { Label } from "@/registry/miseui/ui/label"

interface LoginValues {
  email: string
  password: string
}

interface LoginProvider {
  id: string
  label: string
}

interface FieldErrors {
  email?: string
  password?: string
}

const EMAIL_SHAPE = /^\S+@\S+$/
const FIELD_ERROR_START = { opacity: 0, y: -4 }
const FIELD_ERROR_END = { opacity: 1, y: 0 }
const SUBMIT_FAILED = "Could not log in. Check your connection and try again."

function validateEmail(email: string) {
  const trimmed = email.trim()
  if (!trimmed) return "Enter your email address."
  return EMAIL_SHAPE.test(trimmed) ? undefined : "That email looks incomplete. Check the @ and what follows it."
}

function validate({ email, password }: LoginValues): FieldErrors {
  return { email: validateEmail(email), password: password ? undefined : "Enter your password." }
}

function useLoginForm(
  onSubmit: (values: LoginValues) => void | Promise<void>,
  focusField: (field: keyof LoginValues) => void,
) {
  const [values, setValues] = useState<LoginValues>({ email: "", password: "" })
  const [errors, setErrors] = useState<FieldErrors>({})
  const [submitError, setSubmitError] = useState<string>()
  const [submitting, setSubmitting] = useState(false)

  const change = (field: keyof LoginValues, value: string) => {
    setValues((current) => ({ ...current, [field]: value }))
    setErrors((current) => ({ ...current, [field]: undefined }))
    setSubmitError(undefined)
  }

  const submit = async () => {
    const found = validate(values)
    setErrors(found)
    setSubmitError(undefined)
    if (found.email) return focusField("email")
    if (found.password) return focusField("password")
    setSubmitting(true)
    try {
      await onSubmit({ ...values, email: values.email.trim() })
    } catch (error) {
      setSubmitError(error instanceof Error ? error.message : SUBMIT_FAILED)
      focusField("password")
    } finally {
      setSubmitting(false)
    }
  }

  return { values, errors, submitError, submitting, change, submit }
}

function FieldError({ id, children }: { id: string; children: React.ReactNode }) {
  const reduceMotion = useReducedMotion()
  return (
    <motion.p
      id={id}
      role="alert"
      className="text-[13px] leading-snug text-pretty text-destructive"
      initial={reduceMotion ? false : FIELD_ERROR_START}
      animate={FIELD_ERROR_END}
      transition={SPRING_DEFAULT}
    >
      {children}
    </motion.p>
  )
}

interface EmailFieldProps extends Omit<React.ComponentProps<typeof Input>, "type" | "id"> {
  id: string
  error?: string
}

function EmailField({ id, error, ...props }: EmailFieldProps) {
  const errorId = `${id}-error`
  return (
    <div className="flex flex-col gap-2">
      <Label htmlFor={id}>Email</Label>
      <Input
        id={id}
        type="email"
        autoComplete="email"
        autoCapitalize="none"
        spellCheck={false}
        aria-invalid={error ? true : undefined}
        aria-describedby={error ? errorId : undefined}
        {...props}
      />
      {error ? <FieldError id={errorId}>{error}</FieldError> : null}
    </div>
  )
}

interface PasswordFieldProps extends Omit<React.ComponentProps<typeof Input>, "type"> {
  id: string
}

/** Seeing what was typed beats retyping it blind: the toggle swaps the type and leaves the value and the caret alone. */
function PasswordField({ id, ...props }: PasswordFieldProps) {
  const [visible, setVisible] = useState(false)
  return (
    <div className="relative">
      <Input id={id} type={visible ? "text" : "password"} autoComplete="current-password" className="pr-12" {...props} />
      <Button
        type="button"
        variant="ghost"
        size="icon"
        aria-label={visible ? "Hide password" : "Show password"}
        aria-pressed={visible}
        onClick={() => setVisible((current) => !current)}
        className="absolute inset-y-0 right-0 text-muted-foreground"
      >
        {visible ? <EyeOff /> : <Eye />}
      </Button>
    </div>
  )
}

interface SocialRowProps {
  providers: readonly LoginProvider[]
  disabled: boolean
  onSocial?: (id: string) => void
}

function SocialRow({ providers, disabled, onSocial }: SocialRowProps) {
  if (providers.length === 0) return null
  return (
    <div className="flex flex-col gap-6">
      <div className="flex gap-2">
        {providers.map((provider) => (
          <Button
            key={provider.id}
            type="button"
            variant="outline"
            disabled={disabled}
            aria-label={`Continue with ${provider.label}`}
            onClick={() => onSocial?.(provider.id)}
            className="min-w-0 flex-1"
          >
            {provider.label}
          </Button>
        ))}
      </div>
      <div className="flex items-center gap-3 text-[13px] text-muted-foreground">
        <span aria-hidden="true" className="h-px flex-1 bg-border" />
        or with email
        <span aria-hidden="true" className="h-px flex-1 bg-border" />
      </div>
    </div>
  )
}

export {
  EmailField,
  FieldError,
  PasswordField,
  SocialRow,
  useLoginForm,
  validateEmail,
  type LoginProvider,
  type LoginValues,
}
```

### `registry/miseui/blocks/login-form-visual.tsx`

```tsx
import type * as React from "react"
import { Check } from "lucide-react"
import { cn } from "@/registry/miseui/lib/utils"
import { Badge } from "@/registry/miseui/ui/badge"
import { Card } from "@/registry/miseui/ui/card"

const SOFT_SHADOW_CLASS = "shadow-[0_16px_40px_-24px_color-mix(in_oklab,var(--mise)_24%,oklch(0_0_0/40%))]"
/** A whisper of the accent on a neutral: the panel belongs to the brand in every palette without shouting. */
const PANEL_TINT_CLASS = "bg-[color-mix(in_oklab,var(--mise)_6%,var(--muted))]"
const GRID_CELL_PX = 32
const GRID_STYLE: React.CSSProperties = {
  backgroundImage:
    "linear-gradient(to right, var(--border) 1px, transparent 1px), linear-gradient(to bottom, var(--border) 1px, transparent 1px)",
  backgroundSize: `${GRID_CELL_PX}px ${GRID_CELL_PX}px`,
  maskImage: "radial-gradient(ellipse at center, #000 20%, transparent 72%)",
  opacity: 0.5,
}

/** Sample product UI, so it is hidden from assistive tech: nobody logging in needs a stranger's invoice read out. */
function InvoiceFragments() {
  return (
    <div aria-hidden="true" data-testid="login-visual" className="relative mt-8 w-full max-w-xs">
      <Card className="absolute inset-x-4 -top-8 gap-0 rounded-2xl bg-card px-4 py-2 shadow-none">
        <div className="flex items-center justify-between gap-3 text-[13px] text-muted-foreground">
          Invoice 1041
          <Badge variant="outline">Paid</Badge>
        </div>
      </Card>
      <Card className={cn("relative gap-3 rounded-2xl bg-popover p-6", SOFT_SHADOW_CLASS)}>
        <div className="flex items-center justify-between gap-3 text-[13px] text-muted-foreground">
          Invoice 1042
          <Badge variant="secondary">Due in 3 days</Badge>
        </div>
        <div className="flex flex-col gap-1">
          <p className="text-[15px]">Northgate Studio</p>
          <p className="text-display text-3xl tabular-nums">$1,240.00</p>
        </div>
      </Card>
      <Badge variant="mise" className={cn("absolute -right-3 -bottom-3", SOFT_SHADOW_CLASS)}>
        <Check />
        Reminder sent
      </Badge>
    </div>
  )
}

interface BrandPanelProps {
  brandName: string
  tagline: string
  visual: React.ReactNode
}

/** Inset from the edge and rounded like the cards, so it reads as a calm surface and not a half-painted page. */
function BrandPanel({ brandName, tagline, visual }: BrandPanelProps) {
  return (
    <aside className={cn("relative isolate m-2 hidden flex-col justify-between gap-8 overflow-hidden rounded-2xl border p-8 @3xl:flex @5xl:p-12", PANEL_TINT_CLASS)}>
      <span aria-hidden="true" className="absolute inset-0 -z-10" style={GRID_STYLE} />
      <span className="text-[15px] font-medium">{brandName}</span>
      <div className="flex flex-1 items-center justify-center">{visual}</div>
      <p className="text-display max-w-md text-3xl leading-[1.12]! text-balance @5xl:text-4xl">{tagline}</p>
    </aside>
  )
}

export { BrandPanel, InvoiceFragments }
```
