Profile Edit
Original · freeedit profile form
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/profile-edit.json"use client";
import type { FormEvent } from "react";
import { useCallback, useId, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type FieldKey =
| "displayName"
| "handle"
| "role"
| "location"
| "website"
| "bio";
type ProfileForm = Record<FieldKey, string> & {
visibility: Visibility;
timezone: string;
mentions: boolean;
digest: boolean;
};
type Visibility = "public" | "team" | "private";
type Errors = Partial<Record<FieldKey | "timezone", string>>;
const TAKEN_HANDLES = ["admin", "support", "root", "maya", "billing"];
const BIO_LIMIT = 240;
const INITIAL: ProfileForm = {
displayName: "Maya Okonjo",
handle: "mayao",
role: "Staff Infrastructure Engineer",
location: "Lisbon, Portugal",
website: "https://mayao.dev",
bio: "I keep build pipelines under four minutes and write about the boring parts of distributed systems. Currently rewriting our deploy orchestrator in Rust.",
visibility: "team",
timezone: "Europe/Lisbon",
mentions: true,
digest: false,
};
const TIMEZONES = [
"Europe/Lisbon",
"Europe/Berlin",
"America/New_York",
"America/Los_Angeles",
"Asia/Singapore",
"Australia/Sydney",
];
const VISIBILITY_OPTIONS: ReadonlyArray<{
value: Visibility;
label: string;
hint: string;
}> = [
{
value: "public",
label: "Public",
hint: "Anyone on the internet can see this profile and your public repositories.",
},
{
value: "team",
label: "Workspace",
hint: "Only the 42 people in your workspace can open your profile.",
},
{
value: "private",
label: "Private",
hint: "Nobody but you and workspace admins. You won't appear in search or mentions.",
},
];
function validateField(key: FieldKey, value: string): string | undefined {
const trimmed = value.trim();
switch (key) {
case "displayName":
if (trimmed.length < 2) return "Use at least 2 characters.";
if (trimmed.length > 60) return "Keep it under 60 characters.";
return undefined;
case "handle":
if (trimmed.length < 3) return "Handles need at least 3 characters.";
if (!/^[a-z0-9_-]+$/.test(trimmed))
return "Lowercase letters, numbers, hyphen and underscore only.";
if (TAKEN_HANDLES.includes(trimmed) && trimmed !== INITIAL.handle)
return `@${trimmed} is already taken.`;
return undefined;
case "role":
if (trimmed.length > 80) return "Keep your role under 80 characters.";
return undefined;
case "website":
if (trimmed === "") return undefined;
if (!/^https?:\/\/[^\s.]+\.[^\s]{2,}$/.test(trimmed))
return "Enter a full URL, e.g. https://example.com";
return undefined;
case "bio":
if (value.length > BIO_LIMIT)
return `${value.length - BIO_LIMIT} characters over the limit.`;
return undefined;
default:
return undefined;
}
}
function initials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "?";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
function CheckIcon() {
return (
<svg
viewBox="0 0 20 20"
fill="none"
aria-hidden="true"
className="h-3.5 w-3.5"
>
<path
d="M4.5 10.5 8 14l7.5-8"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function AlertIcon() {
return (
<svg
viewBox="0 0 20 20"
fill="none"
aria-hidden="true"
className="h-3.5 w-3.5 shrink-0"
>
<circle cx="10" cy="10" r="7.25" stroke="currentColor" strokeWidth="1.5" />
<path
d="M10 6.25v4.5"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
/>
<circle cx="10" cy="13.75" r="0.9" fill="currentColor" />
</svg>
);
}
function GlobeIcon() {
return (
<svg
viewBox="0 0 20 20"
fill="none"
aria-hidden="true"
className="h-4 w-4 shrink-0"
>
<circle cx="10" cy="10" r="7.25" stroke="currentColor" strokeWidth="1.5" />
<path
d="M2.75 10h14.5M10 2.75c1.9 2.1 2.9 4.6 2.9 7.25s-1 5.15-2.9 7.25c-1.9-2.1-2.9-4.6-2.9-7.25S8.1 4.85 10 2.75Z"
stroke="currentColor"
strokeWidth="1.5"
/>
</svg>
);
}
function UndoIcon() {
return (
<svg
viewBox="0 0 20 20"
fill="none"
aria-hidden="true"
className="h-4 w-4"
>
<path
d="M6.5 7.5H12a4 4 0 0 1 0 8H8"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M8.75 4.75 5.5 7.5l3.25 2.75"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function SpinnerIcon() {
return (
<svg
viewBox="0 0 20 20"
fill="none"
aria-hidden="true"
className="pfed-spin h-4 w-4"
>
<circle
cx="10"
cy="10"
r="7"
stroke="currentColor"
strokeWidth="2"
strokeOpacity="0.25"
/>
<path
d="M17 10a7 7 0 0 0-7-7"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
);
}
export default function ProfileEdit() {
const uid = useId();
const reduce = useReducedMotion();
const [form, setForm] = useState<ProfileForm>(INITIAL);
const [touched, setTouched] = useState<Partial<Record<FieldKey, boolean>>>({});
const [status, setStatus] = useState<"idle" | "saving" | "saved">("idle");
const [banner, setBanner] = useState<string | null>(null);
const saveTimer = useRef<number | null>(null);
const nameRef = useRef<HTMLInputElement>(null);
const errors: Errors = useMemo(() => {
const next: Errors = {};
(Object.keys(INITIAL) as Array<keyof ProfileForm>).forEach((k) => {
if (
k === "visibility" ||
k === "timezone" ||
k === "mentions" ||
k === "digest"
)
return;
const message = validateField(k, form[k]);
if (message) next[k] = message;
});
return next;
}, [form]);
const dirtyKeys = useMemo(
() =>
(Object.keys(INITIAL) as Array<keyof ProfileForm>).filter(
(k) => form[k] !== INITIAL[k],
),
[form],
);
const isDirty = dirtyKeys.length > 0;
const hasErrors = Object.keys(errors).length > 0;
const setField = useCallback(
<K extends keyof ProfileForm>(key: K, value: ProfileForm[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
setStatus("idle");
setBanner(null);
},
[],
);
const blur = useCallback((key: FieldKey) => {
setTouched((prev) => ({ ...prev, [key]: true }));
}, []);
const showError = (key: FieldKey): string | undefined =>
touched[key] ? errors[key] : undefined;
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setTouched({
displayName: true,
handle: true,
role: true,
location: true,
website: true,
bio: true,
});
if (hasErrors) {
setBanner("Fix the highlighted fields before saving.");
nameRef.current?.focus();
return;
}
setBanner(null);
setStatus("saving");
if (saveTimer.current !== null) window.clearTimeout(saveTimer.current);
saveTimer.current = window.setTimeout(() => {
setStatus("saved");
}, 900);
};
const handleReset = () => {
setForm(INITIAL);
setTouched({});
setStatus("idle");
setBanner(null);
nameRef.current?.focus();
};
const bioLeft = BIO_LIMIT - form.bio.length;
const activeVisibility =
VISIBILITY_OPTIONS.find((v) => v.value === form.visibility) ??
VISIBILITY_OPTIONS[1];
const inputBase =
"w-full rounded-lg border bg-white px-3 py-2.5 text-sm text-slate-900 outline-none transition-colors placeholder:text-slate-400 focus-visible:ring-2 focus-visible:ring-indigo-500/60 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-slate-950 dark:text-slate-100 dark:placeholder:text-slate-500 dark:focus-visible:ring-offset-slate-950";
const borderFor = (key: FieldKey) =>
showError(key)
? "border-rose-400 dark:border-rose-500/70"
: "border-slate-300 hover:border-slate-400 focus-visible:border-indigo-500 dark:border-slate-700 dark:hover:border-slate-600 dark:focus-visible:border-indigo-400";
const labelCls =
"block text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400";
return (
<section className="relative w-full bg-slate-50 px-4 py-16 sm:px-6 sm:py-20 dark:bg-slate-950">
<style>{`
@keyframes pfed-spin-rot { to { transform: rotate(360deg); } }
@keyframes pfed-pulse-ring {
0% { box-shadow: 0 0 0 0 rgba(99,102,241,0.35); }
70% { box-shadow: 0 0 0 10px rgba(99,102,241,0); }
100% { box-shadow: 0 0 0 0 rgba(99,102,241,0); }
}
.pfed-spin { animation: pfed-spin-rot 0.75s linear infinite; }
.pfed-ring { animation: pfed-pulse-ring 2.4s ease-out infinite; }
@media (prefers-reduced-motion: reduce) {
.pfed-spin, .pfed-ring { animation: none !important; }
}
`}</style>
<div className="mx-auto w-full max-w-3xl">
<header className="mb-8">
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-indigo-600 dark:text-indigo-400">
Account settings
</p>
<h2 className="mt-2 text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-slate-50">
Edit your profile
</h2>
<p className="mt-2 max-w-xl text-sm leading-relaxed text-slate-600 dark:text-slate-400">
This is what teammates see when they hover your avatar or find you in
search. Changes take effect the moment you save.
</p>
</header>
<form
onSubmit={handleSubmit}
noValidate
className="rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
>
<AnimatePresence initial={false}>
{banner !== null && (
<motion.div
key="banner"
initial={reduce ? false : { opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="overflow-hidden"
>
<p
role="alert"
className="flex items-center gap-2 border-b border-rose-200 bg-rose-50 px-5 py-3 text-sm text-rose-700 sm:px-7 dark:border-rose-900/60 dark:bg-rose-950/40 dark:text-rose-300"
>
<AlertIcon />
{banner}
</p>
</motion.div>
)}
</AnimatePresence>
<div className="flex flex-col gap-5 border-b border-slate-200 px-5 py-6 sm:flex-row sm:items-center sm:px-7 dark:border-slate-800">
<div
aria-hidden="true"
className={`grid h-20 w-20 shrink-0 place-items-center rounded-full bg-gradient-to-br from-indigo-500 via-violet-500 to-sky-500 text-2xl font-semibold text-white ${
status === "saving" ? "pfed-ring" : ""
}`}
>
{initials(form.displayName)}
</div>
<div className="min-w-0">
<p className="truncate text-base font-semibold text-slate-900 dark:text-slate-50">
{form.displayName.trim() === "" ? "Unnamed" : form.displayName}
</p>
<p className="truncate text-sm text-slate-500 dark:text-slate-400">
@{form.handle || "handle"}
{form.role.trim() !== "" ? ` · ${form.role}` : ""}
</p>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() =>
setBanner(
"Avatar uploads are handled by your workspace admin for now.",
)
}
className="rounded-md border border-slate-300 px-3 py-1.5 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/60 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
>
Change avatar
</button>
<span className="inline-flex items-center rounded-md bg-emerald-50 px-2.5 py-1.5 text-xs font-medium text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-400">
Verified since 2023
</span>
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-5 px-5 py-6 sm:grid-cols-2 sm:px-7">
<div>
<label htmlFor={`${uid}-name`} className={labelCls}>
Display name
</label>
<input
id={`${uid}-name`}
ref={nameRef}
type="text"
value={form.displayName}
onChange={(e) => setField("displayName", e.target.value)}
onBlur={() => blur("displayName")}
aria-invalid={showError("displayName") ? true : undefined}
aria-describedby={
showError("displayName") ? `${uid}-name-err` : undefined
}
className={`mt-1.5 ${inputBase} ${borderFor("displayName")}`}
/>
{showError("displayName") && (
<p
id={`${uid}-name-err`}
className="mt-1.5 flex items-center gap-1.5 text-xs text-rose-600 dark:text-rose-400"
>
<AlertIcon />
{showError("displayName")}
</p>
)}
</div>
<div>
<label htmlFor={`${uid}-handle`} className={labelCls}>
Handle
</label>
<div className="relative mt-1.5">
<span
aria-hidden="true"
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-slate-400 dark:text-slate-500"
>
@
</span>
<input
id={`${uid}-handle`}
type="text"
inputMode="text"
autoCapitalize="none"
spellCheck={false}
value={form.handle}
onChange={(e) =>
setField("handle", e.target.value.toLowerCase())
}
onBlur={() => blur("handle")}
aria-invalid={showError("handle") ? true : undefined}
aria-describedby={
showError("handle") ? `${uid}-handle-err` : `${uid}-handle-hint`
}
className={`${inputBase} ${borderFor("handle")} pl-7`}
/>
</div>
{showError("handle") ? (
<p
id={`${uid}-handle-err`}
className="mt-1.5 flex items-center gap-1.5 text-xs text-rose-600 dark:text-rose-400"
>
<AlertIcon />
{showError("handle")}
</p>
) : (
<p
id={`${uid}-handle-hint`}
className="mt-1.5 text-xs text-slate-500 dark:text-slate-500"
>
People mention you as @{form.handle || "handle"}
</p>
)}
</div>
<div>
<label htmlFor={`${uid}-role`} className={labelCls}>
Role
</label>
<input
id={`${uid}-role`}
type="text"
value={form.role}
onChange={(e) => setField("role", e.target.value)}
onBlur={() => blur("role")}
placeholder="What you do here"
aria-invalid={showError("role") ? true : undefined}
aria-describedby={
showError("role") ? `${uid}-role-err` : undefined
}
className={`mt-1.5 ${inputBase} ${borderFor("role")}`}
/>
{showError("role") && (
<p
id={`${uid}-role-err`}
className="mt-1.5 flex items-center gap-1.5 text-xs text-rose-600 dark:text-rose-400"
>
<AlertIcon />
{showError("role")}
</p>
)}
</div>
<div>
<label htmlFor={`${uid}-location`} className={labelCls}>
Location
</label>
<input
id={`${uid}-location`}
type="text"
value={form.location}
onChange={(e) => setField("location", e.target.value)}
onBlur={() => blur("location")}
placeholder="City, Country"
className={`mt-1.5 ${inputBase} ${borderFor("location")}`}
/>
</div>
<div className="sm:col-span-2">
<label htmlFor={`${uid}-site`} className={labelCls}>
Website
</label>
<div className="relative mt-1.5">
<span
aria-hidden="true"
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 dark:text-slate-500"
>
<GlobeIcon />
</span>
<input
id={`${uid}-site`}
type="url"
spellCheck={false}
value={form.website}
onChange={(e) => setField("website", e.target.value)}
onBlur={() => blur("website")}
placeholder="https://your-site.com"
aria-invalid={showError("website") ? true : undefined}
aria-describedby={
showError("website") ? `${uid}-site-err` : undefined
}
className={`${inputBase} ${borderFor("website")} pl-9`}
/>
</div>
{showError("website") && (
<p
id={`${uid}-site-err`}
className="mt-1.5 flex items-center gap-1.5 text-xs text-rose-600 dark:text-rose-400"
>
<AlertIcon />
{showError("website")}
</p>
)}
</div>
<div className="sm:col-span-2">
<div className="flex items-baseline justify-between gap-3">
<label htmlFor={`${uid}-bio`} className={labelCls}>
Bio
</label>
<span
aria-live="polite"
className={`text-xs tabular-nums ${
bioLeft < 0
? "font-medium text-rose-600 dark:text-rose-400"
: bioLeft <= 30
? "text-amber-600 dark:text-amber-400"
: "text-slate-400 dark:text-slate-500"
}`}
>
{bioLeft} left
</span>
</div>
<textarea
id={`${uid}-bio`}
rows={4}
value={form.bio}
onChange={(e) => setField("bio", e.target.value)}
onBlur={() => blur("bio")}
placeholder="A couple of sentences about what you work on."
aria-invalid={showError("bio") ? true : undefined}
aria-describedby={showError("bio") ? `${uid}-bio-err` : undefined}
className={`mt-1.5 resize-y ${inputBase} ${borderFor("bio")}`}
/>
{showError("bio") && (
<p
id={`${uid}-bio-err`}
className="mt-1.5 flex items-center gap-1.5 text-xs text-rose-600 dark:text-rose-400"
>
<AlertIcon />
{showError("bio")}
</p>
)}
</div>
<div className="sm:col-span-2">
<label htmlFor={`${uid}-tz`} className={labelCls}>
Timezone
</label>
<select
id={`${uid}-tz`}
value={form.timezone}
onChange={(e) => setField("timezone", e.target.value)}
className={`mt-1.5 ${inputBase} border-slate-300 hover:border-slate-400 focus-visible:border-indigo-500 dark:border-slate-700 dark:hover:border-slate-600`}
>
{TIMEZONES.map((tz) => (
<option key={tz} value={tz}>
{tz.replace("_", " ")}
</option>
))}
</select>
<p className="mt-1.5 text-xs text-slate-500 dark:text-slate-500">
Used to show your local time on your profile card.
</p>
</div>
</div>
<fieldset className="border-t border-slate-200 px-5 py-6 sm:px-7 dark:border-slate-800">
<legend className={labelCls}>Profile visibility</legend>
<div
role="radiogroup"
aria-label="Profile visibility"
className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-3"
>
{VISIBILITY_OPTIONS.map((option) => {
const active = form.visibility === option.value;
return (
<label
key={option.value}
className={`relative flex cursor-pointer items-center gap-2.5 rounded-lg border px-3 py-2.5 text-sm transition-colors ${
active
? "border-indigo-500 bg-indigo-50 text-indigo-900 dark:border-indigo-400 dark:bg-indigo-950/40 dark:text-indigo-100"
: "border-slate-300 text-slate-700 hover:bg-slate-50 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800/60"
} focus-within:ring-2 focus-within:ring-indigo-500/60 focus-within:ring-offset-2 focus-within:ring-offset-white dark:focus-within:ring-offset-slate-900`}
>
<input
type="radio"
name={`${uid}-visibility`}
value={option.value}
checked={active}
onChange={() => setField("visibility", option.value)}
className="sr-only"
/>
<span
aria-hidden="true"
className={`grid h-4 w-4 shrink-0 place-items-center rounded-full border-2 transition-colors ${
active
? "border-indigo-600 bg-indigo-600 text-white dark:border-indigo-400 dark:bg-indigo-400 dark:text-slate-950"
: "border-slate-400 dark:border-slate-500"
}`}
>
{active && (
<span className="h-1.5 w-1.5 rounded-full bg-white dark:bg-slate-950" />
)}
</span>
<span className="font-medium">{option.label}</span>
</label>
);
})}
</div>
<p
aria-live="polite"
className="mt-3 text-xs leading-relaxed text-slate-500 dark:text-slate-400"
>
{activeVisibility.hint}
</p>
</fieldset>
<fieldset className="border-t border-slate-200 px-5 py-6 sm:px-7 dark:border-slate-800">
<legend className={labelCls}>Notifications</legend>
<div className="mt-3 divide-y divide-slate-200 dark:divide-slate-800">
{(
[
{
key: "mentions" as const,
label: "Email me when I'm mentioned",
hint: "Only for direct @mentions, not channel-wide pings.",
},
{
key: "digest" as const,
label: "Weekly activity digest",
hint: "Monday mornings, one email, everything you missed.",
},
] satisfies ReadonlyArray<{
key: "mentions" | "digest";
label: string;
hint: string;
}>
).map((row) => {
const on = form[row.key];
return (
<div
key={row.key}
className="flex items-start justify-between gap-4 py-3.5 first:pt-0 last:pb-0"
>
<span className="min-w-0">
<span
id={`${uid}-${row.key}-label`}
className="block text-sm font-medium text-slate-800 dark:text-slate-200"
>
{row.label}
</span>
<span
id={`${uid}-${row.key}-hint`}
className="mt-0.5 block text-xs text-slate-500 dark:text-slate-400"
>
{row.hint}
</span>
</span>
<button
type="button"
role="switch"
aria-checked={on}
aria-labelledby={`${uid}-${row.key}-label`}
aria-describedby={`${uid}-${row.key}-hint`}
onClick={() => setField(row.key, !on)}
className={`relative mt-0.5 inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/60 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
on
? "border-indigo-600 bg-indigo-600 dark:border-indigo-500 dark:bg-indigo-500"
: "border-slate-300 bg-slate-200 dark:border-slate-600 dark:bg-slate-700"
}`}
>
<motion.span
aria-hidden="true"
className="block rounded-full bg-white shadow-sm"
style={{ height: 18, width: 18 }}
animate={{ x: on ? 23 : 3 }}
transition={
reduce
? { duration: 0 }
: { type: "spring", stiffness: 550, damping: 34 }
}
/>
</button>
</div>
);
})}
</div>
</fieldset>
<div className="flex flex-col-reverse items-stretch gap-3 border-t border-slate-200 px-5 py-5 sm:flex-row sm:items-center sm:justify-between sm:px-7 dark:border-slate-800">
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleReset}
disabled={!isDirty}
className="inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 disabled:pointer-events-none disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/60 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-300 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
>
<UndoIcon />
Discard changes
</button>
<p
aria-live="polite"
className="text-xs text-slate-500 dark:text-slate-400"
>
{status === "saved"
? "Profile saved."
: isDirty
? `${dirtyKeys.length} unsaved ${
dirtyKeys.length === 1 ? "change" : "changes"
}`
: "No changes yet"}
</p>
</div>
<button
type="submit"
disabled={status === "saving" || (!isDirty && status !== "idle")}
className="inline-flex items-center justify-center gap-2 rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-indigo-500 disabled:opacity-60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/60 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-indigo-500 dark:hover:bg-indigo-400 dark:focus-visible:ring-offset-slate-900"
>
{status === "saving" && <SpinnerIcon />}
{status === "saved" && <CheckIcon />}
{status === "saving"
? "Saving…"
: status === "saved"
? "Saved"
: "Save profile"}
</button>
</div>
</form>
</div>
</section>
);
}Dependencies
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 →
Profile Header
Originalprofile header with cover and avatar

Profile Settings
Originalaccount settings panel

Profile User Card
Originalcompact user profile card

Profile Stats
Originalprofile with stats and badges

Profile Team
Originalteam member profile grid

Profile Social
Originalsocial profile with follow

Profile Tabs
Originalprofile page with tabbed content

Profile Bio
Originalbio card with links

Profile Cover
Originallarge cover profile hero

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

