Particular.Tech
Blocksblock

Sign up

Name, email, and password registration with optional OAuth providers.

Preview

Open full screen

Installation

npx shadcn@latest add @particular/sign-up
npm dependencies
  • cn@^0.4.0
Installs these registry items

Usage

sign-up-demo.tsx
"use client"

import { LockIcon } from "lucide-react"

import { SignUpForm } from "@/components/sign-up-form"
import { toast } from "@/components/ui/toast"

function wait(ms: number) {
  return new Promise<void>((resolve) => setTimeout(resolve, ms))
}

export function Demo() {
  return (
    <div className="flex min-h-svh items-center justify-center bg-muted/30 p-6">
      <div className="w-full max-w-sm rounded-2xl border bg-card p-8 shadow-sm">
        <SignUpForm
          providers={[{ id: "sso", label: "SAML SSO", icon: <LockIcon /> }]}
          onProviderSignUp={async (provider) => {
            await wait(800)
            toast.add({ title: `Redirecting to ${provider}…` })
          }}
          onSignUp={async ({ email }) => {
            await wait(800)
            if (email.endsWith("@taken.dev")) throw new Error("An account with this email already exists.")
            toast.add({ title: "Account created", type: "success" })
          }}
          footer={
            <>
              Already have an account?{" "}
              <a href="#" className="font-medium text-foreground underline underline-offset-4">
                Sign in
              </a>
            </>
          }
        />
      </div>
    </div>
  )
}

Source

blocks/sign-up/sign-up-form.tsx
"use client"

import * as React from "react"
import { cn } from "cn"

import { LogoMark } from "@/components/logo"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
import { Field, FieldDescription, FieldGroup, FieldLabel, FieldSeparator } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Spinner } from "@/components/ui/spinner"

export type SignUpProvider = { id: string; label: string; icon?: React.ReactNode }

export type SignUpFormProps = {
  title?: string
  description?: React.ReactNode
  /** Create the account. Reject with an Error to show its message. */
  onSignUp: (input: { name: string; email: string; password: string }) => Promise<void>
  providers?: SignUpProvider[]
  onProviderSignUp?: (providerId: string) => Promise<void>
  minPasswordLength?: number
  /** Legal copy or a link to sign in, shown under the form. */
  footer?: React.ReactNode
  className?: string
}

function messageOf(error: unknown) {
  return error instanceof Error ? error.message : "Something went wrong. Try again."
}

function SignUpForm({
  title = "Create your Particular.Tech account",
  description = "One account for every Particular.Tech product.",
  onSignUp,
  providers = [],
  onProviderSignUp,
  minPasswordLength = 10,
  footer,
  className,
}: SignUpFormProps) {
  const [pending, setPending] = React.useState<string | null>(null)
  const [error, setError] = React.useState<string | null>(null)

  const run = async (kind: string, action: () => Promise<void>) => {
    setPending(kind)
    setError(null)
    try {
      await action()
    } catch (caught) {
      setError(messageOf(caught))
    } finally {
      setPending(null)
    }
  }

  const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault()
    const form = new FormData(event.currentTarget)
    const name = String(form.get("name") ?? "").trim()
    const email = String(form.get("email") ?? "").trim()
    const password = String(form.get("password") ?? "")
    if (!name) {
      setError("Enter your name.")
      return
    }
    if (password.length < minPasswordLength) {
      setError(`Use at least ${minPasswordLength} characters for your password.`)
      return
    }
    void run("email", () => onSignUp({ name, email, password }))
  }

  return (
    <div className={cn("mx-auto flex w-full max-w-sm flex-col gap-6", className)}>
      <div className="flex flex-col items-center gap-3 text-center">
        <LogoMark className="size-10" />
        <h1 className="font-heading text-xl font-semibold tracking-tight">{title}</h1>
        {description && <p className="text-sm text-muted-foreground">{description}</p>}
      </div>

      {providers.length > 0 && onProviderSignUp && (
        <>
          <div className="grid gap-2">
            {providers.map((provider) => (
              <Button
                key={provider.id}
                variant="outline"
                size="lg"
                disabled={pending !== null}
                onClick={() => void run(`provider:${provider.id}`, () => onProviderSignUp(provider.id))}
              >
                {pending === `provider:${provider.id}` ? <Spinner data-icon="inline-start" /> : provider.icon}
                Continue with {provider.label}
              </Button>
            ))}
          </div>
          <FieldSeparator>or</FieldSeparator>
        </>
      )}

      <form method="post" onSubmit={onSubmit}>
        <FieldGroup>
          <Field>
            <FieldLabel htmlFor="sign-up-name">Name</FieldLabel>
            <Input id="sign-up-name" name="name" autoComplete="name" placeholder="Ada Lovelace" required />
          </Field>
          <Field>
            <FieldLabel htmlFor="sign-up-email">Email</FieldLabel>
            <Input id="sign-up-email" name="email" type="email" autoComplete="email" placeholder="you@company.com" required />
          </Field>
          <Field>
            <FieldLabel htmlFor="sign-up-password">Password</FieldLabel>
            <Input id="sign-up-password" name="password" type="password" autoComplete="new-password" minLength={minPasswordLength} required />
            <FieldDescription>At least {minPasswordLength} characters.</FieldDescription>
          </Field>
          {error && (
            <Alert variant="destructive">
              <AlertDescription>{error}</AlertDescription>
            </Alert>
          )}
          <Button type="submit" size="lg" disabled={pending !== null}>
            {pending === "email" && <Spinner data-icon="inline-start" />}
            Create account
          </Button>
        </FieldGroup>
      </form>

      {footer && <div className="text-center text-xs text-muted-foreground">{footer}</div>}
    </div>
  )
}

export { SignUpForm }