Settings recipe
A complete, responsive settings form with validation and submission feedback.
Live example
Profile settings
Manage the details used for your workspace.
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.