Profile Settings
Original · freeaccount settings panel
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-settings.json"use client";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import type { ChangeEvent, KeyboardEvent, ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type SectionId = "profile" | "notifications" | "privacy" | "sessions";
type Visibility = "public" | "team" | "private";
type Frequency = "daily" | "weekly" | "monthly";
type Status = "idle" | "saving" | "saved";
type Settings = {
displayName: string;
handle: string;
bio: string;
timezone: string;
emailDigest: boolean;
digestFrequency: Frequency;
mentions: boolean;
productNews: boolean;
weeklyReport: boolean;
visibility: Visibility;
showActivity: boolean;
indexProfile: boolean;
};
type Session = {
id: string;
device: string;
browser: string;
where: string;
when: string;
current: boolean;
};
const INITIAL: Settings = {
displayName: "Nadia Ferreira",
handle: "nadiaf",
bio: "Design engineer at Northbeam. I maintain the component library and lose arguments about naming.",
timezone: "Europe/Lisbon",
emailDigest: true,
digestFrequency: "weekly",
mentions: true,
productNews: false,
weeklyReport: true,
visibility: "team",
showActivity: true,
indexProfile: false,
};
const SESSIONS: Session[] = [
{
id: "s1",
device: "MacBook Pro 16″",
browser: "Chrome 141",
where: "Lisbon, Portugal",
when: "Active now",
current: true,
},
{
id: "s2",
device: "iPhone 15 Pro",
browser: "Safari",
where: "Lisbon, Portugal",
when: "2 hours ago",
current: false,
},
{
id: "s3",
device: "ThinkPad X1",
browser: "Edge 141",
where: "Porto, Portugal",
when: "Yesterday at 18:42",
current: false,
},
{
id: "s4",
device: "iPad Air",
browser: "Safari",
where: "Madrid, Spain",
when: "9 March at 11:07",
current: false,
},
];
const TIMEZONES: { value: string; label: string }[] = [
{ value: "Europe/Lisbon", label: "Lisbon — WEST (UTC+1)" },
{ value: "Europe/Berlin", label: "Berlin — CEST (UTC+2)" },
{ value: "America/New_York", label: "New York — EDT (UTC−4)" },
{ value: "Asia/Karachi", label: "Karachi — PKT (UTC+5)" },
{ value: "Asia/Tokyo", label: "Tokyo — JST (UTC+9)" },
];
const VISIBILITY_OPTIONS: { value: Visibility; title: string; detail: string }[] = [
{ value: "public", title: "Public", detail: "Anyone with the link, including logged-out visitors." },
{ value: "team", title: "Workspace", detail: "The 34 people in Northbeam. Not indexed anywhere else." },
{ value: "private", title: "Private", detail: "Only you and workspace admins can open your profile." },
];
const SECTION_FIELDS: Record<SectionId, (keyof Settings)[]> = {
profile: ["displayName", "handle", "bio", "timezone"],
notifications: ["emailDigest", "digestFrequency", "mentions", "productNews", "weeklyReport"],
privacy: ["visibility", "showActivity", "indexProfile"],
sessions: [],
};
const SECTIONS: { id: SectionId; label: string; hint: string; icon: ReactNode }[] = [
{
id: "profile",
label: "Profile",
hint: "Name, handle, bio",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" aria-hidden="true" className="h-4 w-4">
<circle cx="12" cy="8" r="3.6" />
<path d="M4.5 19.2a7.5 7.5 0 0 1 15 0" strokeLinecap="round" />
</svg>
),
},
{
id: "notifications",
label: "Notifications",
hint: "Email and digests",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" aria-hidden="true" className="h-4 w-4">
<path d="M6 10a6 6 0 1 1 12 0c0 3.2.7 4.9 1.6 5.9.4.4.1 1.1-.5 1.1H4.9c-.6 0-.9-.7-.5-1.1C5.3 14.9 6 13.2 6 10Z" strokeLinejoin="round" />
<path d="M10 20.2a2.3 2.3 0 0 0 4 0" strokeLinecap="round" />
</svg>
),
},
{
id: "privacy",
label: "Privacy",
hint: "Who sees what",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" aria-hidden="true" className="h-4 w-4">
<path d="M12 3.2 19 6v5.4c0 4.3-2.9 7.6-7 9.4-4.1-1.8-7-5.1-7-9.4V6l7-2.8Z" strokeLinejoin="round" />
<path d="m9.2 12.1 2 2 3.6-3.9" strokeLinecap="round" strokeLinejoin="round" />
</svg>
),
},
{
id: "sessions",
label: "Sessions",
hint: "Signed-in devices",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" aria-hidden="true" className="h-4 w-4">
<rect x="3.2" y="5" width="17.6" height="11" rx="1.8" />
<path d="M2 19.4h20" strokeLinecap="round" />
</svg>
),
},
];
const RING =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-950";
function Toggle({
checked,
onChange,
labelId,
descId,
disabled = false,
}: {
checked: boolean;
onChange: (next: boolean) => void;
labelId: string;
descId: string;
disabled?: boolean;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-labelledby={labelId}
aria-describedby={descId}
disabled={disabled}
onClick={() => onChange(!checked)}
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition-colors duration-200 ${RING} ${
disabled
? "cursor-not-allowed border-slate-200 bg-slate-100 dark:border-slate-800 dark:bg-slate-800/60"
: checked
? "border-indigo-600 bg-indigo-600 dark:border-indigo-500 dark:bg-indigo-500"
: "border-slate-300 bg-slate-200 hover:bg-slate-300 dark:border-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
}`}
>
<span
className={`inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-200 ease-out ${
checked ? "translate-x-6" : "translate-x-1"
} ${disabled ? "opacity-60" : ""}`}
/>
</button>
);
}
function SwitchRow({
title,
detail,
checked,
onChange,
disabled = false,
}: {
title: string;
detail: string;
checked: boolean;
onChange: (next: boolean) => void;
disabled?: boolean;
}) {
const uid = useId();
const labelId = `${uid}-label`;
const descId = `${uid}-desc`;
return (
<div className="flex items-start justify-between gap-6 border-b border-slate-200 py-4 last:border-b-0 dark:border-slate-800">
<div className="min-w-0">
<p
id={labelId}
className={`text-sm font-medium ${disabled ? "text-slate-400 dark:text-slate-600" : "text-slate-900 dark:text-slate-100"}`}
>
{title}
</p>
<p id={descId} className="mt-1 text-sm leading-relaxed text-slate-500 dark:text-slate-400">
{detail}
</p>
</div>
<Toggle checked={checked} onChange={onChange} labelId={labelId} descId={descId} disabled={disabled} />
</div>
);
}
export default function ProfileSettings() {
const reduced = useReducedMotion();
const uid = useId();
const [active, setActive] = useState<SectionId>("profile");
const [settings, setSettings] = useState<Settings>(INITIAL);
const [saved, setSaved] = useState<Settings>(INITIAL);
const [status, setStatus] = useState<Status>("idle");
const [sessions, setSessions] = useState<Session[]>(SESSIONS);
const [announce, setAnnounce] = useState("");
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
const timers = useRef<number[]>([]);
useEffect(() => {
const list = timers.current;
return () => {
list.forEach((t) => window.clearTimeout(t));
};
}, []);
const changedKeys = useMemo(
() => (Object.keys(settings) as (keyof Settings)[]).filter((k) => settings[k] !== saved[k]),
[settings, saved],
);
const dirty = changedKeys.length > 0;
const dirtySections = useMemo(() => {
const out: Record<SectionId, boolean> = { profile: false, notifications: false, privacy: false, sessions: false };
(Object.keys(SECTION_FIELDS) as SectionId[]).forEach((id) => {
out[id] = SECTION_FIELDS[id].some((f) => changedKeys.includes(f));
});
return out;
}, [changedKeys]);
const update = useCallback(<K extends keyof Settings>(key: K, value: Settings[K]) => {
setSettings((prev) => ({ ...prev, [key]: value }));
}, []);
const handleSave = useCallback(() => {
if (!dirty || status === "saving") return;
setStatus("saving");
const t1 = window.setTimeout(() => {
setSaved(settings);
setStatus("saved");
setAnnounce(`${changedKeys.length} ${changedKeys.length === 1 ? "change" : "changes"} saved.`);
const t2 = window.setTimeout(() => setStatus("idle"), 2400);
timers.current.push(t2);
}, 700);
timers.current.push(t1);
}, [dirty, status, settings, changedKeys.length]);
const handleDiscard = useCallback(() => {
setSettings(saved);
setAnnounce("Changes discarded.");
}, [saved]);
const onTabKeyDown = useCallback(
(event: KeyboardEvent<HTMLButtonElement>, index: number) => {
const keys = ["ArrowDown", "ArrowRight", "ArrowUp", "ArrowLeft", "Home", "End"];
if (!keys.includes(event.key)) return;
event.preventDefault();
let next = index;
if (event.key === "ArrowDown" || event.key === "ArrowRight") next = (index + 1) % SECTIONS.length;
if (event.key === "ArrowUp" || event.key === "ArrowLeft") next = (index - 1 + SECTIONS.length) % SECTIONS.length;
if (event.key === "Home") next = 0;
if (event.key === "End") next = SECTIONS.length - 1;
setActive(SECTIONS[next].id);
tabRefs.current[next]?.focus();
},
[],
);
const revoke = useCallback((session: Session) => {
setSessions((prev) => prev.filter((s) => s.id !== session.id));
setAnnounce(`Signed out of ${session.device}.`);
}, []);
const otherSessions = sessions.filter((s) => !s.current).length;
const fade = {
initial: { opacity: 0, y: reduced ? 0 : 8 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: reduced ? 0 : -6 },
transition: { duration: reduced ? 0 : 0.22, ease: "easeOut" as const },
};
const bioLeft = 180 - settings.bio.length;
return (
<section className="relative w-full bg-slate-50 px-4 py-16 sm:px-6 sm:py-24 dark:bg-slate-950">
<style>{`
@keyframes psx-pop {
0% { transform: scale(.7); opacity: 0; }
60% { transform: scale(1.08); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
@keyframes psx-spin { to { transform: rotate(360deg); } }
@keyframes psx-breathe {
0%, 100% { opacity: .4; transform: scale(.82); }
50% { opacity: 1; transform: scale(1); }
}
.psx-pop { animation: psx-pop .34s cubic-bezier(.2,.8,.3,1) both; }
.psx-spin { animation: psx-spin .8s linear infinite; transform-origin: 50% 50%; }
.psx-breathe { animation: psx-breathe 2.4s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.psx-pop, .psx-spin, .psx-breathe { animation: none !important; }
}
`}</style>
<div className="mx-auto w-full max-w-5xl">
<header className="mb-8">
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-indigo-600 dark:text-indigo-400">
Northbeam account
</p>
<h2 className="mt-2 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
Settings
</h2>
<p className="mt-3 max-w-xl text-sm leading-relaxed text-slate-600 dark:text-slate-400">
Changes stay local until you save them. Signing a device out happens immediately and cannot be undone from
this panel.
</p>
</header>
<div className="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-sm shadow-slate-900/[0.03] dark:border-slate-800 dark:bg-slate-900 dark:shadow-none">
<div className="grid md:grid-cols-[228px_1fr]">
<nav
aria-label="Settings sections"
className="flex gap-1 overflow-x-auto border-b border-slate-200 p-3 md:flex-col md:overflow-visible md:border-b-0 md:border-r dark:border-slate-800"
>
<div role="tablist" aria-orientation="vertical" className="flex gap-1 md:w-full md:flex-col">
{SECTIONS.map((section, i) => {
const selected = active === section.id;
return (
<button
key={section.id}
ref={(el) => {
tabRefs.current[i] = el;
}}
role="tab"
id={`${uid}-tab-${section.id}`}
aria-selected={selected}
aria-controls={`${uid}-panel-${section.id}`}
tabIndex={selected ? 0 : -1}
onClick={() => setActive(section.id)}
onKeyDown={(e) => onTabKeyDown(e, i)}
className={`group relative flex shrink-0 items-center gap-2.5 rounded-xl px-3 py-2.5 text-left transition-colors md:w-full ${RING} ${
selected
? "bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900"
: "text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800"
}`}
>
<span className={selected ? "text-indigo-300 dark:text-indigo-600" : "text-slate-400 dark:text-slate-500"}>
{section.icon}
</span>
<span className="min-w-0">
<span className="block text-sm font-medium">{section.label}</span>
<span
className={`hidden text-xs md:block ${
selected ? "text-slate-400 dark:text-slate-500" : "text-slate-400 dark:text-slate-500"
}`}
>
{section.hint}
</span>
</span>
{dirtySections[section.id] ? (
<span
className="psx-breathe ml-auto h-1.5 w-1.5 shrink-0 rounded-full bg-amber-500"
aria-label="Unsaved changes in this section"
/>
) : null}
</button>
);
})}
</div>
</nav>
<div className="min-w-0 p-5 sm:p-7">
<AnimatePresence mode="wait" initial={false}>
{active === "profile" ? (
<motion.div key="profile" role="tabpanel" id={`${uid}-panel-profile`} aria-labelledby={`${uid}-tab-profile`} {...fade}>
<h3 className="text-base font-semibold text-slate-900 dark:text-slate-100">Public profile</h3>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
This is what teammates see next to your comments and pull requests.
</p>
<div className="mt-6 flex items-center gap-4 rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-800/40">
<div
aria-hidden="true"
className="grid h-12 w-12 shrink-0 place-items-center rounded-full bg-gradient-to-br from-indigo-500 to-violet-600 text-base font-semibold text-white"
>
NF
</div>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-slate-900 dark:text-slate-100">
nadia@northbeam.dev
</p>
<p className="mt-0.5 flex items-center gap-1.5 text-xs text-emerald-600 dark:text-emerald-400">
<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" className="h-3.5 w-3.5">
<path
fillRule="evenodd"
d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.86-9.79a.75.75 0 0 0-1.22-.87l-3.24 4.53-1.62-1.62a.75.75 0 0 0-1.06 1.06l2.24 2.24a.75.75 0 0 0 1.14-.09l3.76-5.25Z"
clipRule="evenodd"
/>
</svg>
Verified 14 January 2024
</p>
</div>
</div>
<div className="mt-6 grid gap-5 sm:grid-cols-2">
<div>
<label htmlFor={`${uid}-name`} className="block text-sm font-medium text-slate-800 dark:text-slate-200">
Display name
</label>
<input
id={`${uid}-name`}
type="text"
value={settings.displayName}
onChange={(e: ChangeEvent<HTMLInputElement>) => update("displayName", e.target.value)}
className={`mt-2 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100 ${RING}`}
/>
</div>
<div>
<label htmlFor={`${uid}-handle`} className="block text-sm font-medium text-slate-800 dark:text-slate-200">
Handle
</label>
<div className="mt-2 flex rounded-xl border border-slate-300 bg-white focus-within:ring-2 focus-within:ring-indigo-500 dark:border-slate-700 dark:bg-slate-950 dark:focus-within:ring-indigo-400">
<span className="grid place-items-center pl-3 text-sm text-slate-400 dark:text-slate-500">@</span>
<input
id={`${uid}-handle`}
type="text"
value={settings.handle}
aria-describedby={`${uid}-handle-help`}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
update("handle", e.target.value.replace(/\s/g, "").toLowerCase())
}
className="w-full rounded-r-xl bg-transparent px-2 py-2 text-sm text-slate-900 focus:outline-none dark:text-slate-100"
/>
</div>
<p id={`${uid}-handle-help`} className="mt-1.5 text-xs text-slate-500 dark:text-slate-400">
northbeam.dev/@{settings.handle || "…"}
</p>
</div>
</div>
<div className="mt-5">
<div className="flex items-baseline justify-between">
<label htmlFor={`${uid}-bio`} className="block text-sm font-medium text-slate-800 dark:text-slate-200">
Bio
</label>
<span
id={`${uid}-bio-count`}
className={`text-xs tabular-nums ${
bioLeft < 20 ? "text-rose-600 dark:text-rose-400" : "text-slate-400 dark:text-slate-500"
}`}
>
{bioLeft} left
</span>
</div>
<textarea
id={`${uid}-bio`}
rows={3}
maxLength={180}
value={settings.bio}
aria-describedby={`${uid}-bio-count`}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => update("bio", e.target.value)}
className={`mt-2 w-full resize-y rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm leading-relaxed text-slate-900 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100 ${RING}`}
/>
</div>
<div className="mt-5 max-w-sm">
<label htmlFor={`${uid}-tz`} className="block text-sm font-medium text-slate-800 dark:text-slate-200">
Timezone
</label>
<div className="relative mt-2">
<select
id={`${uid}-tz`}
value={settings.timezone}
onChange={(e: ChangeEvent<HTMLSelectElement>) => update("timezone", e.target.value)}
className={`w-full appearance-none rounded-xl border border-slate-300 bg-white py-2 pl-3 pr-9 text-sm text-slate-900 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100 ${RING}`}
>
{TIMEZONES.map((tz) => (
<option key={tz.value} value={tz.value}>
{tz.label}
</option>
))}
</select>
<svg
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
aria-hidden="true"
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400"
>
<path d="m5.5 8 4.5 4.5L14.5 8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
</div>
</motion.div>
) : null}
{active === "notifications" ? (
<motion.div
key="notifications"
role="tabpanel"
id={`${uid}-panel-notifications`}
aria-labelledby={`${uid}-tab-notifications`}
{...fade}
>
<h3 className="text-base font-semibold text-slate-900 dark:text-slate-100">Notifications</h3>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
We send to nadia@northbeam.dev. Security alerts are always sent and can't be turned off.
</p>
<div className="mt-5">
<SwitchRow
title="Activity digest"
detail="One email summarising everything you missed, instead of a message per event."
checked={settings.emailDigest}
onChange={(v) => update("emailDigest", v)}
/>
<div
className={`flex flex-wrap items-center gap-3 border-b border-slate-200 py-4 transition-opacity dark:border-slate-800 ${
settings.emailDigest ? "opacity-100" : "opacity-50"
}`}
>
<label
htmlFor={`${uid}-freq`}
className="text-sm font-medium text-slate-800 dark:text-slate-200"
>
Digest frequency
</label>
<div className="relative">
<select
id={`${uid}-freq`}
value={settings.digestFrequency}
disabled={!settings.emailDigest}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
update("digestFrequency", e.target.value as Frequency)
}
className={`appearance-none rounded-lg border border-slate-300 bg-white py-1.5 pl-3 pr-8 text-sm text-slate-900 disabled:cursor-not-allowed dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100 ${RING}`}
>
<option value="daily">Every morning</option>
<option value="weekly">Mondays at 09:00</option>
<option value="monthly">First of the month</option>
</select>
<svg
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
aria-hidden="true"
className="pointer-events-none absolute right-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400"
>
<path d="m5.5 8 4.5 4.5L14.5 8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
</div>
<SwitchRow
title="Mentions and replies"
detail="Someone @-mentions you or replies in a thread you started."
checked={settings.mentions}
onChange={(v) => update("mentions", v)}
/>
<SwitchRow
title="Weekly report"
detail="Review load, merged pull requests, and open threads older than five days."
checked={settings.weeklyReport}
onChange={(v) => update("weeklyReport", v)}
/>
<SwitchRow
title="Product news"
detail="Roughly one email a month when we ship something worth reading about."
checked={settings.productNews}
onChange={(v) => update("productNews", v)}
/>
</div>
</motion.div>
) : null}
{active === "privacy" ? (
<motion.div
key="privacy"
role="tabpanel"
id={`${uid}-panel-privacy`}
aria-labelledby={`${uid}-tab-privacy`}
{...fade}
>
<h3 className="text-base font-semibold text-slate-900 dark:text-slate-100">Privacy</h3>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
Controls who can open your profile page and what shows up on it.
</p>
<fieldset className="mt-5">
<legend className="text-sm font-medium text-slate-800 dark:text-slate-200">
Profile visibility
</legend>
<div className="mt-3 grid gap-2">
{VISIBILITY_OPTIONS.map((opt) => (
<label key={opt.value} className="relative block cursor-pointer">
<input
type="radio"
name={`${uid}-visibility`}
value={opt.value}
checked={settings.visibility === opt.value}
onChange={() => update("visibility", opt.value)}
className="peer sr-only"
/>
<span className="flex items-start gap-3 rounded-2xl border border-slate-200 bg-white p-4 transition-colors peer-checked:border-indigo-500 peer-checked:bg-indigo-50/60 peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-white hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-950 dark:peer-checked:border-indigo-400 dark:peer-checked:bg-indigo-500/10 dark:peer-focus-visible:ring-offset-slate-900 dark:hover:bg-slate-800/50">
<span
aria-hidden="true"
className={`mt-0.5 grid h-4 w-4 shrink-0 place-items-center rounded-full border transition-colors ${
settings.visibility === opt.value
? "border-indigo-600 dark:border-indigo-400"
: "border-slate-300 dark:border-slate-600"
}`}
>
<span
className={`h-2 w-2 rounded-full transition-transform ${
settings.visibility === opt.value
? "scale-100 bg-indigo-600 dark:bg-indigo-400"
: "scale-0 bg-transparent"
}`}
/>
</span>
<span className="min-w-0">
<span className="block text-sm font-medium text-slate-900 dark:text-slate-100">
{opt.title}
</span>
<span className="mt-0.5 block text-sm text-slate-500 dark:text-slate-400">
{opt.detail}
</span>
</span>
</span>
</label>
))}
</div>
</fieldset>
<div className="mt-5">
<SwitchRow
title="Show activity graph"
detail="The contribution grid on your profile. Private repositories stay unnamed either way."
checked={settings.showActivity}
onChange={(v) => update("showActivity", v)}
/>
<SwitchRow
title="Allow search engines"
detail="Lets Google index your profile. Only has an effect while visibility is public."
checked={settings.indexProfile}
onChange={(v) => update("indexProfile", v)}
disabled={settings.visibility !== "public"}
/>
</div>
</motion.div>
) : null}
{active === "sessions" ? (
<motion.div
key="sessions"
role="tabpanel"
id={`${uid}-panel-sessions`}
aria-labelledby={`${uid}-tab-sessions`}
{...fade}
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-slate-900 dark:text-slate-100">Signed-in devices</h3>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
{otherSessions === 0
? "This is the only device signed in."
: `${otherSessions} other ${otherSessions === 1 ? "device is" : "devices are"} signed in.`}
</p>
</div>
<button
type="button"
disabled={otherSessions === 0}
onClick={() => {
setSessions((prev) => prev.filter((s) => s.current));
setAnnounce("Signed out of all other devices.");
}}
className={`rounded-xl border border-rose-200 px-3 py-1.5 text-sm font-medium text-rose-700 transition-colors hover:bg-rose-50 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent dark:border-rose-500/30 dark:text-rose-400 dark:hover:bg-rose-500/10 ${RING}`}
>
Sign out everywhere else
</button>
</div>
<ul className="mt-5 grid gap-2">
{sessions.map((s) => (
<li
key={s.id}
className="flex items-center gap-4 rounded-2xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-950"
>
<span
aria-hidden="true"
className={`grid h-9 w-9 shrink-0 place-items-center rounded-lg ${
s.current
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-400"
: "bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-400"
}`}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" className="h-4 w-4">
<rect x="3.2" y="5" width="17.6" height="11" rx="1.8" />
<path d="M2 19.4h20" strokeLinecap="round" />
</svg>
</span>
<div className="min-w-0 flex-1">
<p className="flex flex-wrap items-center gap-2 text-sm font-medium text-slate-900 dark:text-slate-100">
{s.device}
{s.current ? (
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-400">
This device
</span>
) : null}
</p>
<p className="mt-0.5 truncate text-xs text-slate-500 dark:text-slate-400">
{s.browser} · {s.where} · {s.when}
</p>
</div>
{s.current ? (
<span className="text-xs text-slate-400 dark:text-slate-500">—</span>
) : (
<button
type="button"
onClick={() => revoke(s)}
className={`rounded-lg px-2.5 py-1.5 text-sm font-medium text-slate-600 transition-colors hover:bg-rose-50 hover:text-rose-700 dark:text-slate-400 dark:hover:bg-rose-500/10 dark:hover:text-rose-400 ${RING}`}
>
Sign out
<span className="sr-only"> of {s.device} in {s.where}</span>
</button>
)}
</li>
))}
</ul>
</motion.div>
) : null}
</AnimatePresence>
</div>
</div>
<AnimatePresence initial={false}>
{dirty || status === "saved" ? (
<motion.div
key="savebar"
initial={{ opacity: 0, height: 0, y: reduced ? 0 : 8 }}
animate={{ opacity: 1, height: "auto", y: 0 }}
exit={{ opacity: 0, height: 0, y: reduced ? 0 : 8 }}
transition={{ duration: reduced ? 0 : 0.24, ease: "easeOut" }}
className="overflow-hidden border-t border-slate-200 bg-slate-50 dark:border-slate-800 dark:bg-slate-800/40"
>
<div className="flex flex-wrap items-center justify-between gap-3 px-5 py-4 sm:px-7">
<p className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300">
{status === "saved" ? (
<>
<svg
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
className="psx-pop h-4 w-4 text-emerald-600 dark:text-emerald-400"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.86-9.79a.75.75 0 0 0-1.22-.87l-3.24 4.53-1.62-1.62a.75.75 0 0 0-1.06 1.06l2.24 2.24a.75.75 0 0 0 1.14-.09l3.76-5.25Z"
clipRule="evenodd"
/>
</svg>
All changes saved
</>
) : (
<>
<span className="psx-breathe h-1.5 w-1.5 rounded-full bg-amber-500" aria-hidden="true" />
{changedKeys.length} unsaved {changedKeys.length === 1 ? "change" : "changes"}
</>
)}
</p>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleDiscard}
disabled={!dirty || status === "saving"}
className={`rounded-xl px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-200 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent dark:text-slate-300 dark:hover:bg-slate-700 ${RING}`}
>
Discard
</button>
<button
type="button"
onClick={handleSave}
disabled={!dirty || status === "saving"}
className={`inline-flex items-center gap-2 rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-slate-900 dark:bg-indigo-500 dark:hover:bg-indigo-400 dark:disabled:hover:bg-indigo-500 ${RING}`}
>
{status === "saving" ? (
<>
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="psx-spin h-4 w-4">
<circle cx="10" cy="10" r="7.5" stroke="currentColor" strokeOpacity="0.3" strokeWidth="2.5" />
<path
d="M17.5 10a7.5 7.5 0 0 0-7.5-7.5"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
/>
</svg>
Saving
</>
) : (
"Save changes"
)}
</button>
</div>
</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
<p aria-live="polite" className="sr-only">
{announce}
</p>
</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 User Card
Originalcompact user profile card

Profile Stats
Originalprofile with stats and badges

Profile Edit
Originaledit profile form

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.

