User Profile Card in React: Avatar, Stats, Follow Button, Bio
Build a polished React user profile card with avatar, bio, stats counter, and a follow button. Fully typed, accessible, and ready to drop in.
What You're Actually Building
A user profile card sounds dead simple — slap an avatar and a name on a card and you're done, right? In practice, the moment you add follow state, a bio that might be 10 words or 300, and a stats row that needs to handle zero-state gracefully, you've got a real component on your hands.
We're building a self-contained ProfileCard component in React 18 with TypeScript. It'll take an optional onFollow callback, manage its own optimistic follow state locally, render a <figure> for the avatar (yes, that's the right semantic element), and handle truncated bios without reaching for a third-party library.
That said, the visual style matters as much as the logic. This guide uses Tailwind CSS throughout — you can swap it for CSS Modules, but honestly the utility approach makes one-off tweaks a lot faster here. If you want to push the design further, check out Empire UI's glassmorphism components for a frosted-glass card variant that looks genuinely good with this layout.
One more thing — we're not using any UI library primitives for this. Zero dependencies beyond React itself. That means you own every pixel and every aria label.
Component Structure and TypeScript Types
Before writing JSX, get your types right. A rushed interface is a gift to future-you's debugging sessions — not in a good way.
interface ProfileStats {
posts: number;
followers: number;
following: number;
}
interface ProfileCardProps {
avatarUrl: string;
name: string;
username: string;
bio?: string;
stats: ProfileStats;
isFollowing?: boolean;
onFollow?: (username: string, nowFollowing: boolean) => void;
className?: string;
}The isFollowing prop is intentionally optional — it lets you use this card in read-only contexts (search results, mentions list) without wiring up a handler. When onFollow is absent, the button just won't render. That's cleaner than disabling it or passing a no-op.
Worth noting: bio is optional because a lot of real users never fill it in. Design for the empty state from the start or you'll be patching layout shifts in production at 11pm.
The className pass-through is one of those small things that looks unnecessary until you're trying to add w-full from a parent grid and suddenly you're fighting specificity. Always include it on card-level components.
The Avatar with Fallback
Broken avatar images are embarrassing. Every social product has them, and almost all of them could've been avoided with 12 lines of code written once.
import { useState } from 'react';
function Avatar({ src, name, size = 80 }: { src: string; name: string; size?: number }) {
const [errored, setErrored] = useState(false);
const initials = name
.split(' ')
.slice(0, 2)
.map((w) => w[0])
.join('')
.toUpperCase();
if (errored) {
return (
<figure
className="rounded-full bg-gradient-to-br from-violet-500 to-fuchsia-500 flex items-center justify-center font-bold text-white select-none"
style={{ width: size, height: size, fontSize: size * 0.35 }}
aria-label={`Avatar for ${name}`}
>
{initials}
</figure>
);
}
return (
<figure style={{ width: size, height: size }}>
<img
src={src}
alt={`Profile photo of ${name}`}
width={size}
height={size}
className="rounded-full object-cover w-full h-full"
onError={() => setErrored(true)}
/>
</figure>
);
}The initials fallback uses a gradient instead of a flat color — takes 2 seconds and looks a lot less placeholder-ish. In 2026 there's really no excuse for a grey circle.
Honestly, the font-size calculation at size * 0.35 is a minor hack but it scales surprisingly well between 40px and 120px avatars. Test it at both extremes before you ship.
Quick aside: set object-cover on the img or square avatars of non-square source images will look stretched. This is probably the most common avatar bug in the wild.
Stats Row and Follow Button
The stats row is where most implementations make a mistake — they hardcode labels and numbers without thinking about what 1,200,000 followers looks like in a 60px column.
function StatItem({ value, label }: { value: number; label: string }) {
const formatted = Intl.NumberFormat('en-US', { notation: 'compact' }).format(value);
return (
<div className="flex flex-col items-center gap-0.5">
<span className="text-lg font-bold text-gray-900 dark:text-white">{formatted}</span>
<span className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">{label}</span>
</div>
);
}Intl.NumberFormat with notation: 'compact' gives you 1.2M for free, no library needed, and it's locale-aware. If you're still manually doing value > 1000 ? (value / 1000).toFixed(1) + 'k' : value in 2026, stop.
For the follow button, you want optimistic UI — nobody wants to wait for a network round-trip before the button text changes.
function FollowButton({
username,
isFollowing: initialFollowing,
onFollow,
}: {
username: string;
isFollowing: boolean;
onFollow: (username: string, nowFollowing: boolean) => void;
}) {
const [following, setFollowing] = useState(initialFollowing);
const [loading, setLoading] = useState(false);
async function handleClick() {
const next = !following;
setFollowing(next); // optimistic
setLoading(true);
try {
await onFollow(username, next);
} catch {
setFollowing(!next); // revert on error
} finally {
setLoading(false);
}
}
return (
<button
onClick={handleClick}
disabled={loading}
aria-pressed={following}
className={`px-6 py-2 rounded-full text-sm font-semibold transition-all duration-150
${
following
? 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 hover:bg-red-50 hover:text-red-600'
: 'bg-violet-600 text-white hover:bg-violet-700'
} disabled:opacity-60`}
>
{loading ? '...' : following ? 'Following' : 'Follow'}
</button>
);
}The hover state on the "Following" button — turning it red-ish to imply "Unfollow" — is a pattern Twitter popularized around 2014 and every social app has copied it since. It works because it's expected. Don't invent something clever here; just do the expected thing.
Putting It All Together
Now assemble the full ProfileCard component. Keep it under 80 lines — if it's getting longer, you've probably mixed concerns.
export function ProfileCard({
avatarUrl,
name,
username,
bio,
stats,
isFollowing = false,
onFollow,
className = '',
}: ProfileCardProps) {
const MAX_BIO_LENGTH = 160;
const truncatedBio = bio && bio.length > MAX_BIO_LENGTH
? bio.slice(0, MAX_BIO_LENGTH) + '…'
: bio;
return (
<article
className={`bg-white dark:bg-gray-900 rounded-2xl p-6 flex flex-col items-center gap-4 shadow-md w-full max-w-sm ${className}`}
>
<Avatar src={avatarUrl} name={name} size={88} />
<div className="text-center">
<h2 className="text-xl font-bold text-gray-900 dark:text-white">{name}</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">@{username}</p>
</div>
{truncatedBio && (
<p className="text-sm text-gray-600 dark:text-gray-300 text-center leading-relaxed">
{truncatedBio}
</p>
)}
<div className="flex w-full justify-around border-t border-gray-100 dark:border-gray-800 pt-4">
<StatItem value={stats.posts} label="Posts" />
<StatItem value={stats.followers} label="Followers" />
<StatItem value={stats.following} label="Following" />
</div>
{onFollow && (
<FollowButton
username={username}
isFollowing={isFollowing}
onFollow={onFollow}
/>
)}
</article>
);
}Note the max-w-sm on the card — that's 384px. It's the sweet spot for a standalone profile card: wide enough to breathe, narrow enough to work in sidebars and modals without any extra config. You can override it via className if you need something wider.
The <article> element is semantically correct here. A profile card is a standalone piece of content — it makes sense both in isolation and in a feed. Don't use <div> for things that have proper HTML equivalents.
Look, the gap between a card that looks good in a demo and one that holds up in a real app is usually just a handful of edge cases: long names that break layout, bios with newlines you didn't expect, follower counts that hit seven digits. Handle those at the component level and you won't be fixing them in five different places later. For design inspiration on card layouts, the glassmorphism card design article has solid patterns you can adapt to this same structure.
Dark Mode and Styling Variants
Dark mode on a profile card is table stakes in 2026. The Tailwind dark: utilities in the code above handle it automatically if you've got darkMode: 'class' in your tailwind.config.js.
If you want a glassmorphism variant — frosted glass background, subtle border — it's a one-line className swap. Head to the glassmorphism generator to dial in the exact backdrop-filter and background values you want, then replace the bg-white dark:bg-gray-900 with something like:
// Glassmorphism variant className override
<ProfileCard
className="bg-white/10 backdrop-blur-md border border-white/20 shadow-xl"
// ... rest of props
/>That said, there's a real design decision here. Glassmorphism profile cards look stunning against gradient or image backgrounds — they're a mess against plain white. Only reach for it if you control the page background. Browse the Empire UI component library for context on how these styles are typically combined.
For a neumorphism take on card inputs and controls, the neumorphism button design article shows how to carry that texture through interactive elements. Mixing card styles across a page looks bad — pick one aesthetic and commit.
Accessibility Checklist Before You Ship
Profile cards are often overlooked in accessibility audits because they look simple. They're not. Let's run through the non-negotiable stuff fast.
The avatar <img> needs a descriptive alt — not alt="avatar", not alt="". Something like Profile photo of Jane Smith. If the image fails and you show initials, your fallback <figure> needs an aria-label (already in the code above).
The follow button uses aria-pressed to communicate state to screen readers. Without it, a screen reader user just hears "button" with no indication of current state. That's a WCAG 2.1 Level A failure — not a nice-to-have fix.
// Also add this to ProfileCard:
<article aria-label={`Profile card for ${name}`}>One more thing — keyboard focus. Tab through your card and make sure the follow button has a visible focus ring. Tailwind removes focus outlines by default in some setups. Add focus-visible:ring-2 focus-visible:ring-violet-500 focus-visible:ring-offset-2 to the button's className if you stripped it.
FAQ
The FollowButton component above already does this — it updates state optimistically, calls your onFollow async handler, and reverts if it throws. Just make your handler reject the promise on failure.
The card itself is a Client Component because it uses useState for follow state. Mark it with 'use client' at the top of the file and import it from Server Components as normal.
Sidebar: let the parent constrain it by passing className="w-full". Modal: max-w-sm (384px) is already the default and works well centered in a modal overlay.
Wrap the count in a useEffect that animates a local displayValue from old to new using requestAnimationFrame. Framer Motion's useSpring is overkill for a number counter but works too if you're already using it.