Profile Form
Original · freeprofile settings form
byWeb InnoventixReact + Tailwind
formprofileforms
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/form-profile.jsonform-profile.tsx
"use client";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import type {
FormEvent,
KeyboardEvent as ReactKeyboardEvent,
ReactNode,
Ref,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type AccentKey = "indigo" | "violet" | "emerald" | "rose" | "amber" | "sky";
type Visibility = "public" | "members" | "private";
interface ProfileState {
displayName: string;
username: string;
headline: string;
bio: string;
email: string;
website: string;
timezone: string;
visibility: Visibility;
accent: AccentKey;
notifyProduct: boolean;
notifyMentions: boolean;
notifyDigest: boolean;
}
const BIO_MAX = 180;
const TAKEN = new Set(["admin", "design", "hello", "team", "lena", "support"]);
const ACCENTS: Record<
AccentKey,
{ label: string; swatch: string; ring: string; grad: string }
> = {
indigo: {
label: "Indigo",
swatch: "bg-indigo-500",
ring: "ring-indigo-500",
grad: "from-indigo-500 to-violet-600",
},
violet: {
label: "Violet",
swatch: "bg-violet-500",
ring: "ring-violet-500",
grad: "from-violet-500 to-indigo-600",
},
emerald: {
label: "Emerald",
swatch: "bg-emerald-500",
ring: "ring-emerald-500",
grad: "from-emerald-500 to-sky-600",
},
rose: {
label: "Rose",
swatch: "bg-rose-500",
ring: "ring-rose-500",
grad: "from-rose-500 to-amber-500",
},
amber: {
label: "Amber",
swatch: "bg-amber-500",
ring: "ring-amber-500",
grad: "from-amber-500 to-rose-500",
},
sky: {
label: "Sky",
swatch: "bg-sky-500",
ring: "ring-sky-500",
grad: "from-sky-500 to-indigo-600",
},
};
const ACCENT_KEYS = Object.keys(ACCENTS) as AccentKey[];
const TIMEZONES: { value: string; label: string }[] = [
{ value: "America/Los_Angeles", label: "Pacific Time — Los Angeles (UTC−8)" },
{ value: "America/New_York", label: "Eastern Time — New York (UTC−5)" },
{ value: "Europe/London", label: "GMT — London (UTC+0)" },
{ value: "Europe/Warsaw", label: "Central European — Warsaw (UTC+1)" },
{ value: "Asia/Kolkata", label: "India Standard — Kolkata (UTC+5:30)" },
{ value: "Asia/Singapore", label: "Singapore Time — Singapore (UTC+8)" },
{ value: "Australia/Sydney", label: "Eastern Australia — Sydney (UTC+11)" },
];
const VISIBILITY: { value: Visibility; label: string; desc: string }[] = [
{
value: "public",
label: "Public",
desc: "Anyone on the web can find and view your profile.",
},
{
value: "members",
label: "Members only",
desc: "Only signed-in members of your workspace can view it.",
},
{
value: "private",
label: "Private",
desc: "Only you can see this profile. It stays hidden from search.",
},
];
const INITIAL: ProfileState = {
displayName: "Lena Kowalski",
username: "lenak",
headline: "Product designer · design systems",
bio: "I design calm, high-contrast interfaces and the systems that keep them consistent — currently building the component library at Fieldnotes.",
email: "lena@fieldnotes.studio",
website: "https://lena.studio",
timezone: "Europe/Warsaw",
visibility: "members",
accent: "indigo",
notifyProduct: true,
notifyMentions: true,
notifyDigest: false,
};
const inputBase =
"w-full rounded-lg border bg-white px-3.5 py-2.5 text-sm text-zinc-900 shadow-sm outline-none transition placeholder:text-zinc-400 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-zinc-900 dark:text-zinc-100 dark:placeholder:text-zinc-500 dark:focus-visible:ring-offset-zinc-900";
function inputClass(hasError: boolean): string {
return `${inputBase} ${
hasError
? "border-rose-400 focus-visible:ring-rose-500 dark:border-rose-500/70"
: "border-zinc-300 focus-visible:ring-indigo-500 dark:border-zinc-700"
}`;
}
const labelClass =
"mb-1.5 block text-sm font-medium text-zinc-800 dark:text-zinc-200";
const hintClass = "mt-1.5 text-xs text-zinc-500 dark:text-zinc-400";
function validate(f: ProfileState): Partial<Record<keyof ProfileState, string>> {
const e: Partial<Record<keyof ProfileState, string>> = {};
if (f.displayName.trim().length < 2)
e.displayName = "Enter your name — at least 2 characters.";
if (!/^[a-z0-9_]{3,20}$/.test(f.username))
e.username = "3–20 characters: lowercase letters, numbers, or underscores.";
else if (TAKEN.has(f.username)) e.username = "That username is already taken.";
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(f.email.trim()))
e.email = "Enter a valid email address.";
if (f.website.trim() && !/^https?:\/\/[^\s.]+\.[^\s]{2,}$/.test(f.website.trim()))
e.website = "Use a full URL, e.g. https://example.com.";
if (f.bio.length > BIO_MAX)
e.bio = `Keep your bio under ${BIO_MAX} characters.`;
return e;
}
/* ---------- inline icons ---------- */
function CheckIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 20 20"
fill="none"
aria-hidden="true"
className={className}
>
<path
d="M4 10.5 8 14.5 16 6"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function WarnIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" className={className}>
<path
fillRule="evenodd"
d="M9.13 3.36a1 1 0 0 1 1.74 0l6.5 11.25A1 1 0 0 1 16.5 16h-13a1 1 0 0 1-.87-1.39l6.5-11.25ZM10 7a.9.9 0 0 0-.9.98l.3 3.2a.6.6 0 0 0 1.2 0l.3-3.2A.9.9 0 0 0 10 7Zm0 6.1a.95.95 0 1 0 0 1.9.95.95 0 0 0 0-1.9Z"
clipRule="evenodd"
/>
</svg>
);
}
function XIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className={className}>
<path
d="M6 6l8 8M14 6l-8 8"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
/>
</svg>
);
}
function ChevronIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className={className}>
<path
d="M6 8l4 4 4-4"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function ClockIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className={className}>
<circle cx="10" cy="10" r="7.2" stroke="currentColor" strokeWidth="1.6" />
<path
d="M10 6v4.2l2.8 1.6"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function GlobeIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className={className}>
<circle cx="10" cy="10" r="7.2" stroke="currentColor" strokeWidth="1.6" />
<path
d="M2.8 10h14.4M10 2.8c2 2 3 4.6 3 7.2s-1 5.2-3 7.2c-2-2-3-4.6-3-7.2s1-5.2 3-7.2Z"
stroke="currentColor"
strokeWidth="1.6"
/>
</svg>
);
}
function Spinner({ className }: { className?: string }) {
return (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className={`fp-spin ${className ?? ""}`}>
<circle cx="10" cy="10" r="7.5" stroke="currentColor" strokeWidth="2.2" className="opacity-25" />
<path
d="M10 2.5a7.5 7.5 0 0 1 7.5 7.5"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
/>
</svg>
);
}
/* ---------- reusable text field ---------- */
function TextField({
id,
label,
value,
onChange,
error,
type = "text",
placeholder,
hint,
autoComplete,
inputMode,
inputRef,
}: {
id: string;
label: string;
value: string;
onChange: (v: string) => void;
error?: string;
type?: string;
placeholder?: string;
hint?: string;
autoComplete?: string;
inputMode?: "text" | "email" | "url";
inputRef?: Ref<HTMLInputElement>;
}) {
const errId = `${id}-err`;
const hintId = `${id}-hint`;
return (
<div>
<label htmlFor={id} className={labelClass}>
{label}
</label>
<input
id={id}
ref={inputRef}
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
autoComplete={autoComplete}
inputMode={inputMode}
aria-invalid={error ? true : undefined}
aria-describedby={error ? errId : hint ? hintId : undefined}
className={inputClass(!!error)}
/>
{error ? (
<p
id={errId}
role="alert"
className="mt-1.5 flex items-center gap-1.5 text-xs font-medium text-rose-600 dark:text-rose-400"
>
<WarnIcon className="h-3.5 w-3.5 shrink-0" />
{error}
</p>
) : hint ? (
<p id={hintId} className={hintClass}>
{hint}
</p>
) : null}
</div>
);
}
/* ---------- toggle switch ---------- */
function Toggle({
checked,
onChange,
labelId,
descId,
}: {
checked: boolean;
onChange: () => void;
labelId: string;
descId: string;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-labelledby={labelId}
aria-describedby={descId}
onClick={onChange}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full outline-none transition-colors focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-900 ${
checked ? "bg-emerald-500" : "bg-zinc-300 dark:bg-zinc-700"
}`}
>
<span
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow-sm transition-transform ${
checked ? "translate-x-[22px]" : "translate-x-0.5"
}`}
/>
</button>
);
}
/* ---------- card wrapper ---------- */
function Card({
title,
description,
children,
}: {
title: string;
description: string;
children: ReactNode;
}) {
return (
<section className="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900/60">
<div className="mb-5">
<h3 className="text-base font-semibold text-zinc-900 dark:text-zinc-50">
{title}
</h3>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
{description}
</p>
</div>
{children}
</section>
);
}
export default function ProfileSettingsForm() {
const reduce = useReducedMotion();
const uid = useId();
const fid = (s: string) => `${uid}-${s}`;
const [baseline, setBaseline] = useState<ProfileState>(INITIAL);
const [form, setForm] = useState<ProfileState>(INITIAL);
const [errors, setErrors] = useState<
Partial<Record<keyof ProfileState, string>>
>({});
const [saving, setSaving] = useState(false);
const [showToast, setShowToast] = useState(false);
const [availability, setAvailability] = useState<
"idle" | "checking" | "available" | "taken" | "invalid"
>("idle");
const nameRef = useRef<HTMLInputElement>(null);
const usernameRef = useRef<HTMLInputElement>(null);
const emailRef = useRef<HTMLInputElement>(null);
const websiteRef = useRef<HTMLInputElement>(null);
const bioRef = useRef<HTMLTextAreaElement>(null);
const swatchRefs = useRef<(HTMLButtonElement | null)[]>([]);
function setField<K extends keyof ProfileState>(
key: K,
value: ProfileState[K]
) {
setForm((f) => ({ ...f, [key]: value }));
setErrors((prev) => {
if (!prev[key]) return prev;
const next = { ...prev };
delete next[key];
return next;
});
}
const dirty = useMemo(
() => JSON.stringify(form) !== JSON.stringify(baseline),
[form, baseline]
);
const initials = useMemo(() => {
const parts = form.displayName.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "?";
return (parts[0][0] + (parts[1]?.[0] ?? "")).toUpperCase();
}, [form.displayName]);
const bioLeft = BIO_MAX - form.bio.length;
// Debounced username availability check.
useEffect(() => {
const u = form.username.trim();
if (u === baseline.username) {
setAvailability("idle");
return;
}
if (!/^[a-z0-9_]{3,20}$/.test(u)) {
setAvailability(u.length > 0 ? "invalid" : "idle");
return;
}
setAvailability("checking");
const t = window.setTimeout(() => {
setAvailability(TAKEN.has(u) ? "taken" : "available");
}, 600);
return () => window.clearTimeout(t);
}, [form.username, baseline.username]);
// Auto-dismiss the success toast.
useEffect(() => {
if (!showToast) return;
const t = window.setTimeout(() => setShowToast(false), 3800);
return () => window.clearTimeout(t);
}, [showToast]);
function focusFirst(errs: Partial<Record<keyof ProfileState, string>>) {
const order: [keyof ProfileState, HTMLElement | null][] = [
["displayName", nameRef.current],
["username", usernameRef.current],
["email", emailRef.current],
["website", websiteRef.current],
["bio", bioRef.current],
];
for (const [key, el] of order) {
if (errs[key] && el) {
el.focus();
break;
}
}
}
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const errs = validate(form);
setErrors(errs);
if (Object.keys(errs).length > 0) {
focusFirst(errs);
return;
}
setSaving(true);
window.setTimeout(() => {
setSaving(false);
setBaseline(form);
setShowToast(true);
}, 700);
}
function handleReset() {
setForm(baseline);
setErrors({});
setShowToast(false);
}
function onAccentKey(e: ReactKeyboardEvent<HTMLButtonElement>, idx: number) {
let next = idx;
if (e.key === "ArrowRight" || e.key === "ArrowDown")
next = (idx + 1) % ACCENT_KEYS.length;
else if (e.key === "ArrowLeft" || e.key === "ArrowUp")
next = (idx - 1 + ACCENT_KEYS.length) % ACCENT_KEYS.length;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = ACCENT_KEYS.length - 1;
else return;
e.preventDefault();
setField("accent", ACCENT_KEYS[next]);
swatchRefs.current[next]?.focus();
}
const usernameStatusId = fid("username-status");
const usernameErrId = fid("username-err");
const usernameDescribedBy =
[errors.username ? usernameErrId : null, usernameStatusId]
.filter(Boolean)
.join(" ") || undefined;
const availabilityMessage =
availability === "checking"
? "Checking availability…"
: availability === "available"
? `@${form.username} is available.`
: availability === "taken"
? "That username is already taken."
: availability === "invalid"
? "Use 3–20 lowercase letters, numbers, or underscores."
: "";
const activeTz = TIMEZONES.find((t) => t.value === form.timezone);
return (
<section className="relative w-full overflow-hidden bg-zinc-50 px-4 py-16 text-zinc-900 sm:px-6 sm:py-20 lg:px-8 dark:bg-zinc-950 dark:text-zinc-100">
<style>{`
@keyframes fp-spin { to { transform: rotate(360deg); } }
@keyframes fp-pop {
0% { transform: scale(.5); opacity: 0; }
60% { transform: scale(1.12); }
100% { transform: scale(1); opacity: 1; }
}
.fp-spin { animation: fp-spin .8s linear infinite; }
.fp-pop { animation: fp-pop .28s ease-out both; }
@media (prefers-reduced-motion: reduce) {
.fp-spin, .fp-pop { animation: none !important; }
}
`}</style>
{/* soft background accents */}
<div
aria-hidden="true"
className="pointer-events-none absolute -left-24 -top-24 h-72 w-72 rounded-full bg-indigo-200/40 blur-3xl dark:bg-indigo-500/10"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute -bottom-24 -right-16 h-72 w-72 rounded-full bg-violet-200/40 blur-3xl dark:bg-violet-500/10"
/>
<div className="relative mx-auto max-w-5xl">
<header className="mb-8 sm:mb-10">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
Account
</p>
<h2 className="mt-2 text-2xl font-bold tracking-tight text-zinc-900 sm:text-3xl dark:text-zinc-50">
Profile settings
</h2>
<p className="mt-2 max-w-2xl text-sm text-zinc-600 dark:text-zinc-400">
Update how you appear across the workspace. Changes are visible to
teammates once you save.
</p>
</header>
<form
onSubmit={handleSubmit}
noValidate
className="grid gap-8 lg:grid-cols-[300px_minmax(0,1fr)]"
>
{/* live preview */}
<aside className="lg:sticky lg:top-8 lg:self-start">
<div className="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900/60">
<div
className={`h-20 bg-gradient-to-r ${ACCENTS[form.accent].grad}`}
aria-hidden="true"
/>
<div className="px-5 pb-5">
<div
className={`-mt-10 flex h-20 w-20 items-center justify-center rounded-2xl bg-gradient-to-br ${ACCENTS[form.accent].grad} text-2xl font-bold text-white shadow-md ring-4 ring-white dark:ring-zinc-900`}
aria-hidden="true"
>
{initials}
</div>
<p className="mt-3 truncate text-lg font-semibold text-zinc-900 dark:text-zinc-50">
{form.displayName || "Your name"}
</p>
<p className="truncate text-sm text-zinc-500 dark:text-zinc-400">
@{form.username || "username"}
</p>
{form.headline ? (
<p className="mt-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
{form.headline}
</p>
) : null}
{form.bio ? (
<p className="mt-3 line-clamp-4 text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">
{form.bio}
</p>
) : null}
<dl className="mt-4 space-y-2 border-t border-zinc-100 pt-4 text-xs dark:border-zinc-800">
<div className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400">
<ClockIcon className="h-4 w-4 shrink-0" />
<span className="truncate">
{activeTz ? activeTz.label : form.timezone}
</span>
</div>
{form.website.trim() &&
/^https?:\/\//.test(form.website.trim()) ? (
<div className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400">
<GlobeIcon className="h-4 w-4 shrink-0" />
<a
href={form.website}
target="_blank"
rel="noopener"
className="truncate rounded text-indigo-600 underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-indigo-400"
>
{form.website.replace(/^https?:\/\//, "")}
</a>
</div>
) : null}
<div className="pt-1">
<span className="inline-flex items-center gap-1.5 rounded-full bg-zinc-100 px-2.5 py-1 font-medium text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
<span
className={`h-1.5 w-1.5 rounded-full ${
form.visibility === "public"
? "bg-emerald-500"
: form.visibility === "members"
? "bg-amber-500"
: "bg-zinc-400 dark:bg-zinc-500"
}`}
/>
{VISIBILITY.find((v) => v.value === form.visibility)?.label}
</span>
</div>
</dl>
</div>
</div>
<p className="mt-3 px-1 text-xs text-zinc-500 dark:text-zinc-400">
Live preview — this is how your card looks to others.
</p>
</aside>
{/* form fields */}
<div className="space-y-6">
<Card
title="Public profile"
description="Your name, handle, and a short bio shown across the workspace."
>
<div className="space-y-5">
<TextField
id={fid("name")}
label="Display name"
value={form.displayName}
onChange={(v) => setField("displayName", v)}
error={errors.displayName}
placeholder="e.g. Lena Kowalski"
autoComplete="name"
inputRef={nameRef}
/>
{/* username with availability */}
<div>
<label htmlFor={fid("username")} className={labelClass}>
Username
</label>
<div className="relative">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-zinc-400 dark:text-zinc-500">
@
</span>
<input
id={fid("username")}
ref={usernameRef}
type="text"
value={form.username}
onChange={(e) =>
setField(
"username",
e.target.value.toLowerCase().replace(/\s+/g, "")
)
}
autoComplete="username"
aria-invalid={errors.username ? true : undefined}
aria-describedby={usernameDescribedBy}
className={`${inputClass(!!errors.username)} pl-7 pr-10`}
/>
<span className="absolute right-3 top-1/2 -translate-y-1/2">
{availability === "checking" ? (
<Spinner className="h-4 w-4 text-zinc-400" />
) : availability === "available" ? (
<CheckIcon className="h-4 w-4 text-emerald-500" />
) : availability === "taken" ||
availability === "invalid" ? (
<XIcon className="h-4 w-4 text-rose-500" />
) : null}
</span>
</div>
{errors.username ? (
<p
id={usernameErrId}
role="alert"
className="mt-1.5 flex items-center gap-1.5 text-xs font-medium text-rose-600 dark:text-rose-400"
>
<WarnIcon className="h-3.5 w-3.5 shrink-0" />
{errors.username}
</p>
) : null}
<p
id={usernameStatusId}
aria-live="polite"
className={`mt-1.5 text-xs ${
availability === "available"
? "text-emerald-600 dark:text-emerald-400"
: availability === "taken" || availability === "invalid"
? "text-rose-600 dark:text-rose-400"
: "text-zinc-500 dark:text-zinc-400"
}`}
>
{availabilityMessage ||
"Letters, numbers and underscores. This is your public @handle."}
</p>
</div>
<TextField
id={fid("headline")}
label="Headline"
value={form.headline}
onChange={(v) => setField("headline", v)}
placeholder="e.g. Product designer · design systems"
hint="A short line shown just under your name."
/>
{/* bio with counter */}
<div>
<div className="mb-1.5 flex items-baseline justify-between">
<label htmlFor={fid("bio")} className="text-sm font-medium text-zinc-800 dark:text-zinc-200">
Bio
</label>
<span
aria-live="polite"
className={`text-xs tabular-nums ${
bioLeft < 0
? "font-semibold text-rose-600 dark:text-rose-400"
: bioLeft < 20
? "text-amber-600 dark:text-amber-400"
: "text-zinc-400 dark:text-zinc-500"
}`}
>
{bioLeft} left
</span>
</div>
<textarea
id={fid("bio")}
ref={bioRef}
rows={4}
value={form.bio}
onChange={(e) => setField("bio", e.target.value)}
aria-invalid={errors.bio ? true : undefined}
aria-describedby={errors.bio ? fid("bio-err") : undefined}
className={`${inputClass(!!errors.bio)} resize-y`}
placeholder="Tell people what you work on."
/>
{errors.bio ? (
<p
id={fid("bio-err")}
role="alert"
className="mt-1.5 flex items-center gap-1.5 text-xs font-medium text-rose-600 dark:text-rose-400"
>
<WarnIcon className="h-3.5 w-3.5 shrink-0" />
{errors.bio}
</p>
) : null}
</div>
{/* accent picker (radiogroup) */}
<div>
<span className={labelClass} id={fid("accent-label")}>
Avatar color
</span>
<div
role="radiogroup"
aria-labelledby={fid("accent-label")}
className="flex flex-wrap gap-2.5"
>
{ACCENT_KEYS.map((key, i) => {
const active = form.accent === key;
return (
<button
key={key}
ref={(el) => {
swatchRefs.current[i] = el;
}}
type="button"
role="radio"
aria-checked={active}
aria-label={ACCENTS[key].label}
tabIndex={active ? 0 : -1}
onClick={() => setField("accent", key)}
onKeyDown={(e) => onAccentKey(e, i)}
className={`relative flex h-9 w-9 items-center justify-center rounded-full ${ACCENTS[key].swatch} outline-none transition focus-visible:ring-2 focus-visible:ring-offset-2 ${ACCENTS[key].ring} focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-900 ${
active
? `ring-2 ring-offset-2 ${ACCENTS[key].ring} ring-offset-white dark:ring-offset-zinc-900`
: "ring-1 ring-inset ring-black/10 hover:scale-105 dark:ring-white/15"
}`}
>
{active ? (
<CheckIcon className="fp-pop h-4 w-4 text-white" />
) : null}
</button>
);
})}
</div>
</div>
</div>
</Card>
<Card
title="Contact"
description="How teammates and visitors can reach you."
>
<div className="grid gap-5 sm:grid-cols-2">
<TextField
id={fid("email")}
label="Email address"
value={form.email}
onChange={(v) => setField("email", v)}
error={errors.email}
type="email"
inputMode="email"
autoComplete="email"
placeholder="you@company.com"
inputRef={emailRef}
/>
<TextField
id={fid("website")}
label="Website"
value={form.website}
onChange={(v) => setField("website", v)}
error={errors.website}
type="url"
inputMode="url"
autoComplete="url"
placeholder="https://example.com"
hint="Optional — shown as a link on your profile."
inputRef={websiteRef}
/>
</div>
</Card>
<Card
title="Visibility & timezone"
description="Control who can see your profile and how times are shown."
>
<div className="space-y-6">
<fieldset>
<legend className={labelClass}>Profile visibility</legend>
<div className="grid gap-3">
{VISIBILITY.map((opt) => {
const active = form.visibility === opt.value;
return (
<label
key={opt.value}
className={`flex cursor-pointer items-start gap-3 rounded-xl border p-3.5 transition ${
active
? "border-indigo-400 bg-indigo-50/70 dark:border-indigo-500/60 dark:bg-indigo-500/10"
: "border-zinc-200 hover:border-zinc-300 dark:border-zinc-800 dark:hover:border-zinc-700"
}`}
>
<input
type="radio"
name={fid("visibility")}
value={opt.value}
checked={active}
onChange={() => setField("visibility", opt.value)}
className="peer sr-only"
/>
<span
aria-hidden="true"
className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border transition peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-white dark:peer-focus-visible:ring-offset-zinc-900 ${
active
? "border-indigo-600 bg-indigo-600"
: "border-zinc-300 dark:border-zinc-600"
}`}
>
<span
className={`h-1.5 w-1.5 rounded-full bg-white transition-opacity ${
active ? "opacity-100" : "opacity-0"
}`}
/>
</span>
<span className="min-w-0">
<span className="block text-sm font-medium text-zinc-900 dark:text-zinc-100">
{opt.label}
</span>
<span className="block text-xs text-zinc-500 dark:text-zinc-400">
{opt.desc}
</span>
</span>
</label>
);
})}
</div>
</fieldset>
<div>
<label htmlFor={fid("tz")} className={labelClass}>
Timezone
</label>
<div className="relative">
<select
id={fid("tz")}
value={form.timezone}
onChange={(e) => setField("timezone", e.target.value)}
className={`${inputClass(false)} cursor-pointer appearance-none pr-10`}
>
{TIMEZONES.map((tz) => (
<option key={tz.value} value={tz.value}>
{tz.label}
</option>
))}
</select>
<ChevronIcon className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400" />
</div>
</div>
</div>
</Card>
<Card
title="Notifications"
description="Choose which emails land in your inbox."
>
<ul className="divide-y divide-zinc-100 dark:divide-zinc-800">
{(
[
{
key: "notifyProduct" as const,
title: "Product updates",
desc: "New features and meaningful changes to the platform.",
},
{
key: "notifyMentions" as const,
title: "Mentions & replies",
desc: "When someone @mentions you or replies to your comments.",
},
{
key: "notifyDigest" as const,
title: "Weekly digest",
desc: "A Monday summary of activity across your workspace.",
},
]
).map((n) => {
const lid = fid(`${n.key}-label`);
const did = fid(`${n.key}-desc`);
return (
<li
key={n.key}
className="flex items-center justify-between gap-4 py-3.5 first:pt-0 last:pb-0"
>
<div className="min-w-0">
<p
id={lid}
className="text-sm font-medium text-zinc-900 dark:text-zinc-100"
>
{n.title}
</p>
<p
id={did}
className="text-xs text-zinc-500 dark:text-zinc-400"
>
{n.desc}
</p>
</div>
<Toggle
checked={form[n.key]}
onChange={() => setField(n.key, !form[n.key])}
labelId={lid}
descId={did}
/>
</li>
);
})}
</ul>
</Card>
{/* action bar */}
<div className="sticky bottom-4 z-10 flex flex-col items-stretch gap-3 rounded-2xl border border-zinc-200 bg-white/85 p-3.5 shadow-lg backdrop-blur sm:flex-row sm:items-center sm:justify-between dark:border-zinc-800 dark:bg-zinc-900/85">
<p
aria-live="polite"
className="px-1 text-xs text-zinc-500 dark:text-zinc-400"
>
{dirty ? (
<span className="flex items-center gap-1.5 font-medium text-amber-600 dark:text-amber-400">
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" />
You have unsaved changes.
</span>
) : (
"All changes saved."
)}
</p>
<div className="flex items-center gap-2.5">
<button
type="button"
onClick={handleReset}
disabled={!dirty || saving}
className="rounded-lg border border-zinc-300 bg-white px-4 py-2.5 text-sm font-medium text-zinc-700 outline-none transition hover:bg-zinc-50 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700 dark:focus-visible:ring-offset-zinc-900"
>
Reset
</button>
<button
type="submit"
disabled={!dirty || saving}
className="inline-flex items-center justify-center gap-2 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white shadow-sm outline-none transition hover:bg-indigo-500 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60 dark:focus-visible:ring-offset-zinc-900"
>
{saving ? (
<>
<Spinner className="h-4 w-4" />
Saving…
</>
) : (
"Save changes"
)}
</button>
</div>
</div>
</div>
</form>
</div>
{/* success toast */}
<AnimatePresence>
{showToast ? (
<motion.div
role="status"
aria-live="polite"
initial={reduce ? false : { opacity: 0, y: 14, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: 14, scale: 0.98 }}
transition={{ duration: 0.28, ease: "easeOut" }}
className="fixed bottom-6 left-1/2 z-50 flex -translate-x-1/2 items-center gap-3 rounded-xl border border-emerald-200 bg-white px-4 py-3 shadow-xl sm:left-auto sm:right-6 sm:translate-x-0 dark:border-emerald-500/30 dark:bg-zinc-900"
>
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-emerald-100 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-400">
<CheckIcon className="h-5 w-5" />
</span>
<div>
<p className="text-sm font-semibold text-zinc-900 dark:text-zinc-50">
Profile updated
</p>
<p className="text-xs text-zinc-500 dark:text-zinc-400">
Your changes are now live for teammates.
</p>
</div>
</motion.div>
) : null}
</AnimatePresence>
</section>
);
}Dependencies
motion
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Login Form
Originalclean login form with social buttons

Signup Form
Originalsignup form with password strength

Contact Form
Originalcontact form with fields and message

Multistep Form
Originalmulti-step wizard form with progress

Payment Form
Originalcard payment form with formatting

Newsletter Form
Originalinline newsletter subscribe form

Search Filters Form
Originalfaceted search filter panel

OTP Verify Form
OriginalOTP verification form

Upload Dropzone Form
Originaldrag-and-drop file upload with preview

Feedback Form
Originalfeedback form with emoji rating

Survey Form
Originalshort survey with radios and scale

Booking Form
Originalbooking form with date and slots

