Profile User Card
Original · freecompact user profile card
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-user-card.json"use client";
import { useCallback, useId, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type TabId = "activity" | "stack" | "hours";
type Tab = {
id: TabId;
label: string;
};
type ActivityRow = {
repo: string;
action: string;
when: string;
additions: number;
deletions: number;
};
type StackItem = {
name: string;
level: number;
note: string;
};
const TABS: Tab[] = [
{ id: "activity", label: "Activity" },
{ id: "stack", label: "Stack" },
{ id: "hours", label: "Hours" },
];
const ACTIVITY: ActivityRow[] = [
{
repo: "orbital/edge-router",
action: "merged pull request #412 — cache warm-up on cold start",
when: "2h ago",
additions: 184,
deletions: 96,
},
{
repo: "orbital/design-tokens",
action: "published v3.1.0 — contrast fixes for dark surfaces",
when: "Yesterday",
additions: 61,
deletions: 12,
},
{
repo: "kettle/observability",
action: "opened issue #77 — trace sampling drops on retry",
when: "3 days ago",
additions: 0,
deletions: 0,
},
];
const STACK: StackItem[] = [
{ name: "TypeScript", level: 92, note: "6 yrs · strict everywhere" },
{ name: "Rust", level: 68, note: "2 yrs · router internals" },
{ name: "PostgreSQL", level: 81, note: "5 yrs · query tuning" },
{ name: "Terraform", level: 54, note: "1 yr · staging only" },
];
const HOURS: number[] = [1, 0, 2, 5, 8, 6, 3, 4, 7, 9, 6, 2];
const HOUR_LABELS = ["8a", "9a", "10a", "11a", "12p", "1p", "2p", "3p", "4p", "5p", "6p", "7p"];
export default function ProfileUserCard() {
const reduce = useReducedMotion();
const [tab, setTab] = useState<TabId>("activity");
const [following, setFollowing] = useState(false);
const [copied, setCopied] = useState(false);
const tabRefs = useRef<Record<TabId, HTMLButtonElement | null>>({
activity: null,
stack: null,
hours: null,
});
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const baseId = useId();
const followers = useMemo(() => (following ? 2431 : 2430), [following]);
const onTabKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>) => {
const index = TABS.findIndex((t) => t.id === tab);
let next = -1;
if (event.key === "ArrowRight") next = (index + 1) % TABS.length;
else if (event.key === "ArrowLeft") next = (index - 1 + TABS.length) % TABS.length;
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = TABS.length - 1;
if (next < 0) return;
event.preventDefault();
const nextId = TABS[next].id;
setTab(nextId);
tabRefs.current[nextId]?.focus();
},
[tab],
);
const copyHandle = useCallback(() => {
const write = navigator.clipboard?.writeText("@nadia.brar");
if (copyTimer.current) clearTimeout(copyTimer.current);
const done = () => {
setCopied(true);
copyTimer.current = setTimeout(() => setCopied(false), 1800);
};
if (write) write.then(done).catch(done);
else done();
}, []);
const peak = Math.max(...HOURS);
return (
<section className="relative w-full bg-slate-50 px-4 py-20 sm:px-6 sm:py-28 dark:bg-slate-950">
<style>{`
@keyframes puc-pulse-ring {
0% { transform: scale(0.85); opacity: 0.75; }
70% { transform: scale(1.9); opacity: 0; }
100% { transform: scale(1.9); opacity: 0; }
}
@keyframes puc-sheen {
0% { transform: translateX(-120%); }
100% { transform: translateX(220%); }
}
.puc-ring::after {
content: "";
position: absolute;
inset: 0;
border-radius: 9999px;
background-color: rgb(16 185 129 / 0.55);
animation: puc-pulse-ring 2.6s ease-out infinite;
}
.puc-sheen::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
width: 40%;
background: linear-gradient(90deg, transparent, rgb(255 255 255 / 0.18), transparent);
animation: puc-sheen 3.4s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.puc-ring::after,
.puc-sheen::before {
animation: none;
}
.puc-sheen::before { display: none; }
}
`}</style>
<div className="mx-auto w-full max-w-sm">
<motion.article
initial={reduce ? false : { opacity: 0, y: 14 }}
whileInView={reduce ? undefined : { opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.4 }}
transition={{ duration: 0.45, ease: [0.22, 1, 0.36, 1] }}
aria-labelledby={`${baseId}-name`}
className="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-[0_1px_2px_rgb(15_23_42_/_0.04),0_12px_36px_-12px_rgb(15_23_42_/_0.14)] dark:border-slate-800 dark:bg-slate-900 dark:shadow-[0_12px_40px_-16px_rgb(0_0_0_/_0.8)]"
>
<div className="puc-sheen relative h-24 overflow-hidden bg-gradient-to-br from-indigo-500 via-violet-500 to-sky-400">
<svg
aria-hidden="true"
className="absolute inset-0 h-full w-full opacity-30"
viewBox="0 0 400 96"
preserveAspectRatio="none"
>
<path
d="M0 72 C 60 40, 100 88, 160 60 S 280 24, 340 56 S 400 80, 400 80 L400 96 L0 96 Z"
fill="rgb(255 255 255 / 0.25)"
/>
<path
d="M0 84 C 70 60, 120 96, 190 74 S 300 48, 400 72 L400 96 L0 96 Z"
fill="rgb(255 255 255 / 0.2)"
/>
</svg>
</div>
<div className="px-6 pb-6">
<div className="-mt-10 flex items-end justify-between gap-3">
<div className="relative">
<div
aria-hidden="true"
className="grid h-20 w-20 place-items-center rounded-full border-4 border-white bg-gradient-to-br from-violet-600 to-indigo-600 text-2xl font-semibold tracking-tight text-white select-none dark:border-slate-900"
>
NB
</div>
<span className="absolute right-0.5 bottom-0.5 grid h-5 w-5 place-items-center rounded-full border-2 border-white bg-white dark:border-slate-900 dark:bg-slate-900">
<span className="puc-ring relative block h-2.5 w-2.5 rounded-full bg-emerald-500" />
<span className="sr-only">Online now</span>
</span>
</div>
<div className="flex items-center gap-2 pb-1">
<button
type="button"
onClick={copyHandle}
className="inline-flex h-9 items-center gap-1.5 rounded-full border border-slate-200 bg-white px-3 text-xs font-medium text-slate-600 transition-colors hover:bg-slate-50 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white focus-visible:outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700 dark:focus-visible:ring-offset-slate-900"
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
className="h-3.5 w-3.5"
>
{copied ? (
<path d="M20 6 9 17l-5-5" />
) : (
<>
<rect x="9" y="9" width="11" height="11" rx="2" />
<path d="M5 15V5a2 2 0 0 1 2-2h10" />
</>
)}
</svg>
{copied ? "Copied" : "Handle"}
</button>
<button
type="button"
aria-pressed={following}
onClick={() => setFollowing((v) => !v)}
className={[
"inline-flex h-9 items-center rounded-full px-4 text-xs font-semibold transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white focus-visible:outline-none dark:focus-visible:ring-offset-slate-900",
following
? "border border-slate-200 bg-slate-100 text-slate-700 hover:bg-slate-200 focus-visible:ring-slate-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700"
: "bg-indigo-600 text-white hover:bg-indigo-500 focus-visible:ring-indigo-500",
].join(" ")}
>
{following ? "Following" : "Follow"}
</button>
</div>
</div>
<div className="mt-4">
<div className="flex items-center gap-1.5">
<h2
id={`${baseId}-name`}
className="text-lg font-semibold tracking-tight text-slate-900 dark:text-slate-50"
>
Nadia Brar
</h2>
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className="h-4 w-4 shrink-0 text-sky-500"
fill="currentColor"
>
<path d="M12 1.6l2.3 2.06 3.06-.35 1.06 2.9 2.9 1.06-.35 3.06L23 12l-2.03 2.67.35 3.06-2.9 1.06-1.06 2.9-3.06-.35L12 22.4l-2.3-2.06-3.06.35-1.06-2.9-2.9-1.06.35-3.06L1 12l2.03-2.67-.35-3.06 2.9-1.06 1.06-2.9 3.06.35L12 1.6zm-1.2 13.4l5.3-5.3-1.42-1.42-3.88 3.88-1.76-1.76L7.6 11.8l3.2 3.2z" />
</svg>
<span className="sr-only">Verified maintainer</span>
</div>
<p className="mt-0.5 text-sm text-slate-500 dark:text-slate-400">
Staff engineer, Edge Platform · @nadia.brar
</p>
<p className="mt-3 text-sm leading-relaxed text-slate-600 dark:text-slate-300">
I keep request latency boring. Currently rewriting the router cache layer in Rust and
writing far too much about cold starts.
</p>
<p className="mt-3 flex items-center gap-1.5 text-xs text-slate-500 dark:text-slate-400">
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
className="h-3.5 w-3.5"
>
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
<circle cx="12" cy="10" r="3" />
</svg>
Lisbon, Portugal · UTC+1 · usually online 09:00–18:00
</p>
</div>
<dl className="mt-5 grid grid-cols-3 divide-x divide-slate-200 rounded-2xl border border-slate-200 bg-slate-50 py-3 dark:divide-slate-800 dark:border-slate-800 dark:bg-slate-800/40">
{[
{ label: "Followers", value: followers.toLocaleString("en-US") },
{ label: "Repos", value: "38" },
{ label: "Merged", value: "1,204" },
].map((stat) => (
<div key={stat.label} className="px-2 text-center">
<dt className="text-[11px] tracking-wide text-slate-500 uppercase dark:text-slate-400">
{stat.label}
</dt>
<dd className="mt-0.5 text-sm font-semibold tabular-nums text-slate-900 dark:text-slate-100">
{stat.value}
</dd>
</div>
))}
</dl>
<div
role="tablist"
aria-label="Profile details"
className="mt-5 flex gap-1 rounded-full bg-slate-100 p-1 dark:bg-slate-800"
>
{TABS.map((t) => {
const active = t.id === tab;
return (
<button
key={t.id}
ref={(el) => {
tabRefs.current[t.id] = el;
}}
role="tab"
type="button"
id={`${baseId}-tab-${t.id}`}
aria-selected={active}
aria-controls={`${baseId}-panel-${t.id}`}
tabIndex={active ? 0 : -1}
onClick={() => setTab(t.id)}
onKeyDown={onTabKeyDown}
className="relative flex-1 rounded-full px-3 py-1.5 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-100 focus-visible:outline-none dark:focus-visible:ring-offset-slate-800"
>
{active ? (
<motion.span
layoutId={`${baseId}-pill`}
transition={
reduce ? { duration: 0 } : { type: "spring", stiffness: 420, damping: 34 }
}
className="absolute inset-0 rounded-full bg-white shadow-sm dark:bg-slate-950"
/>
) : null}
<span
className={
active
? "relative text-slate-900 dark:text-slate-50"
: "relative text-slate-500 dark:text-slate-400"
}
>
{t.label}
</span>
</button>
);
})}
</div>
<div className="mt-4 min-h-[188px]">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={tab}
initial={reduce ? false : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 1 } : { opacity: 0, y: -6 }}
transition={{ duration: 0.22, ease: "easeOut" }}
role="tabpanel"
id={`${baseId}-panel-${tab}`}
aria-labelledby={`${baseId}-tab-${tab}`}
tabIndex={0}
className="rounded-xl focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:outline-none"
>
{tab === "activity" ? (
<ul className="space-y-3">
{ACTIVITY.map((row) => (
<li key={row.repo} className="flex gap-3">
<span
aria-hidden="true"
className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-violet-500"
/>
<div className="min-w-0">
<p className="truncate text-xs font-medium text-slate-900 dark:text-slate-100">
{row.repo}
</p>
<p className="mt-0.5 text-xs leading-relaxed text-slate-500 dark:text-slate-400">
{row.action}
</p>
<p className="mt-1 flex items-center gap-2 text-[11px] tabular-nums">
<span className="text-slate-400 dark:text-slate-500">{row.when}</span>
{row.additions > 0 ? (
<span className="text-emerald-600 dark:text-emerald-400">
+{row.additions}
</span>
) : null}
{row.deletions > 0 ? (
<span className="text-rose-600 dark:text-rose-400">
−{row.deletions}
</span>
) : null}
</p>
</div>
</li>
))}
</ul>
) : null}
{tab === "stack" ? (
<ul className="space-y-3.5">
{STACK.map((item) => (
<li key={item.name}>
<div className="flex items-baseline justify-between gap-3">
<span className="text-xs font-medium text-slate-900 dark:text-slate-100">
{item.name}
</span>
<span className="text-[11px] text-slate-400 dark:text-slate-500">
{item.note}
</span>
</div>
<div
role="meter"
aria-label={`${item.name} proficiency`}
aria-valuenow={item.level}
aria-valuemin={0}
aria-valuemax={100}
className="mt-1.5 h-1.5 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800"
>
<motion.div
initial={reduce ? false : { width: 0 }}
animate={{ width: `${item.level}%` }}
transition={{ duration: 0.5, ease: "easeOut" }}
className="h-full rounded-full bg-gradient-to-r from-indigo-500 to-violet-500"
/>
</div>
</li>
))}
</ul>
) : null}
{tab === "hours" ? (
<div>
<p className="text-xs text-slate-500 dark:text-slate-400">
Commits by hour, last 30 days. Peak at 5pm ({peak} commits).
</p>
<div className="mt-4 flex h-28 items-end gap-1.5">
{HOURS.map((value, i) => (
<div key={HOUR_LABELS[i]} className="flex flex-1 flex-col items-center gap-1.5">
<motion.div
initial={reduce ? false : { height: 0 }}
animate={{ height: `${Math.max((value / peak) * 100, 4)}%` }}
transition={{
duration: 0.4,
delay: reduce ? 0 : i * 0.03,
ease: "easeOut",
}}
title={`${HOUR_LABELS[i]}: ${value} commits`}
className="w-full rounded-sm bg-sky-400 dark:bg-sky-500"
/>
<span className="text-[9px] text-slate-400 tabular-nums dark:text-slate-500">
{HOUR_LABELS[i]}
</span>
</div>
))}
</div>
<p className="sr-only">
{HOURS.map((v, i) => `${HOUR_LABELS[i]}: ${v} commits`).join(", ")}
</p>
</div>
) : null}
</motion.div>
</AnimatePresence>
</div>
<div className="mt-5 flex items-center justify-between border-t border-slate-200 pt-4 dark:border-slate-800">
<span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-50 px-2.5 py-1 text-[11px] font-medium text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400">
<span aria-hidden="true" className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
Open to mentoring
</span>
<a
href="https://example.com/nadia-brar"
target="_blank"
rel="noopener"
className="inline-flex items-center gap-1 rounded-md text-xs font-medium text-indigo-600 hover:text-indigo-500 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white focus-visible:outline-none dark:text-indigo-400 dark:focus-visible:ring-offset-slate-900"
>
Full profile
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-3 w-3"
>
<path d="M7 17 17 7M9 7h8v8" />
</svg>
</a>
</div>
</div>
</motion.article>
<p aria-live="polite" className="sr-only">
{copied ? "Handle @nadia.brar copied to clipboard" : ""}
</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 Settings
Originalaccount settings panel

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.

