Settings recipe

A complete, responsive settings form with validation and submission feedback.

Live example

Profile settings
Manage the details used for your workspace.

Shown to your workspace.

Used for account updates.

A short introduction for your team.

Choose your preferred language.

Source

"use client";

import { SpinnerGapIcon } from "@phosphor-icons/react";
import { type FormEvent, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import {
  Field,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";

export function SettingsPageExample() {
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");
  const [submitted, setSubmitted] = useState(false);
  const [pending, setPending] = useState(false);
  const [simulateError, setSimulateError] = useState(false);
  const [result, setResult] = useState("");
  const nameRef = useRef<HTMLInputElement>(null);
  const emailRef = useRef<HTMLInputElement>(null);
  const nameInvalid = submitted && !name.trim();
  const emailInvalid = submitted && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);

  async function submit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    if (pending) return;
    // Render aria-invalid and the error text before moving focus, so the
    // field is announced together with its error.
    flushSync(() => setSubmitted(true));
    if (!name.trim()) {
      nameRef.current?.focus();
      setResult("Please correct the fields below.");
      return;
    }
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
      emailRef.current?.focus();
      setResult("Please correct the fields below.");
      return;
    }
    setPending(true);
    setResult("Saving settings…");
    await new Promise((resolve) => setTimeout(resolve, 600));
    setPending(false);
    if (simulateError) {
      setResult("Could not save settings. Turn off Simulate error and retry.");
      return;
    }
    setResult("Settings saved.");
    toast.success("Settings saved.");
  }

  return (
    <Card>
      <CardHeader>
        <CardTitle>Profile settings</CardTitle>
        <CardDescription>
          Manage the details used for your workspace.
        </CardDescription>
      </CardHeader>
      <CardContent>
        <form noValidate onSubmit={submit} className="space-y-6">
          <FieldGroup className="grid gap-5 sm:grid-cols-2">
            <Field>
              <FieldLabel htmlFor="settings-name">
                Display name{" "}
                <span data-slot="field-required" aria-hidden="true">
                  *
                </span>
              </FieldLabel>
              <Input
                ref={nameRef}
                id="settings-name"
                required
                aria-required="true"
                value={name}
                onChange={(event) => setName(event.target.value)}
                aria-invalid={nameInvalid}
                aria-describedby={
                  nameInvalid
                    ? "settings-name-help settings-name-error"
                    : "settings-name-help"
                }
              />
              <FieldDescription id="settings-name-help">
                Shown to your workspace.
              </FieldDescription>
              {nameInvalid && (
                <FieldError id="settings-name-error">
                  Enter your display name.
                </FieldError>
              )}
            </Field>
            <Field>
              <FieldLabel htmlFor="settings-email">
                Email address{" "}
                <span data-slot="field-required" aria-hidden="true">
                  *
                </span>
              </FieldLabel>
              <Input
                ref={emailRef}
                id="settings-email"
                required
                aria-required="true"
                type="email"
                value={email}
                onChange={(event) => setEmail(event.target.value)}
                aria-invalid={emailInvalid}
                aria-describedby={
                  emailInvalid
                    ? "settings-email-help settings-email-error"
                    : "settings-email-help"
                }
              />
              <FieldDescription id="settings-email-help">
                Used for account updates.
              </FieldDescription>
              {emailInvalid && (
                <FieldError id="settings-email-error">
                  Enter a valid email address.
                </FieldError>
              )}
            </Field>
          </FieldGroup>
          <Field>
            <FieldLabel htmlFor="settings-bio">Bio</FieldLabel>
            <Textarea
              id="settings-bio"
              placeholder="Tell your team about yourself"
              aria-describedby="settings-bio-help"
            />
            <FieldDescription id="settings-bio-help">
              A short introduction for your team.
            </FieldDescription>
          </Field>
          <Field>
            <FieldLabel htmlFor="settings-language">Language</FieldLabel>
            <Select defaultValue="en">
              <SelectTrigger
                id="settings-language"
                aria-describedby="settings-language-help"
              >
                <SelectValue />
              </SelectTrigger>
              <SelectContent>
                <SelectItem value="en">English</SelectItem>
                <SelectItem value="id">Bahasa Indonesia</SelectItem>
              </SelectContent>
            </Select>
            <FieldDescription id="settings-language-help">
              Choose your preferred language.
            </FieldDescription>
          </Field>
          <Separator />
          <div className="flex items-center justify-between gap-4">
            <Label htmlFor="settings-notifications">Email notifications</Label>
            <Switch id="settings-notifications" defaultChecked />
          </div>
          <div className="flex items-center justify-between gap-4">
            <Label htmlFor="settings-error">Simulate error</Label>
            <Switch
              id="settings-error"
              checked={simulateError}
              onCheckedChange={setSimulateError}
            />
          </div>
          <div aria-live="polite" className="text-sm">
            {result}
          </div>
          <Button type="submit" aria-disabled={pending}>
            {pending ? (
              <>
                <SpinnerGapIcon aria-hidden="true" className="animate-spin" />{" "}
                Saving…
              </>
            ) : result.startsWith("Could not") ? (
              "Retry"
            ) : (
              "Save settings"
            )}
          </Button>
        </form>
      </CardContent>
    </Card>
  );
}

Customise within

  • Brand: --primary, --radius-card, --radius-button, --font-sans and --font-heading.
  • Tunable: motion durations and easing, --shadow-card and --shadow-overlay.
  • Locked: --ring-width and --ring-offset, density structure and the three-tier foreground hierarchy.