Web InnoventixFreeCode

Profile Card

Original · free

A user profile card with avatar, stats and a follow toggle.

byWeb InnoventixReact + Tailwind
cardprofilecards
Open

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/card-profile.json
card-profile.tsx
"use client";

import { useState } from "react";
import { useReducedMotion } from "motion/react";

type Person = {
  id: string;
  name: string;
  handle: string;
  role: string;
  bio: string;
  avatar: string;
  cover: string;
  verified: boolean;
  followsYou: boolean;
  location: string;
  stats: { posts: number; followers: number; following: number };
  accent: "indigo" | "emerald" | "rose";
};

const PEOPLE: Person[] = [
  {
    id: "amelia",
    name: "Amelia Hart",
    handle: "ameliabuilds",
    role: "Staff Design Engineer",
    bio: "Prototyping design systems at Vercel. I write about motion, tokens, and the boring parts of good UI.",
    avatar: "/img/gallery/g14.webp",
    cover: "/img/gallery/g03.webp",
    verified: true,
    followsYou: true,
    location: "Lisbon, PT",
    stats: { posts: 486, followers: 28400, following: 512 },
    accent: "indigo",
  },
  {
    id: "marcus",
    name: "Marcus Ilori",
    handle: "marcusml",
    role: "ML Research · Latent Labs",
    bio: "Training small models that punch above their weight. Ex-DeepMind. Currently obsessed with retrieval.",
    avatar: "/img/gallery/g21.webp",
    cover: "/img/gallery/g09.webp",
    verified: true,
    followsYou: false,
    location: "Berlin, DE",
    stats: { posts: 1203, followers: 91200, following: 340 },
    accent: "emerald",
  },
  {
    id: "priya",
    name: "Priya Nair",
    handle: "priyaships",
    role: "Founder · Cadence Analytics",
    bio: "Building the reporting layer for indie SaaS. Bootstrapped to $40k MRR. Sharing the messy middle.",
    avatar: "/img/gallery/g27.webp",
    cover: "/img/gallery/g18.webp",
    verified: false,
    followsYou: true,
    location: "Bengaluru, IN",
    stats: { posts: 742, followers: 15800, following: 889 },
    accent: "rose",
  },
];

const ACCENTS: Record<
  Person["accent"],
  {
    ring: string;
    text: string;
    grad: string;
    btn: string;
    btnFollowing: string;
    softBg: string;
  }
> = {
  indigo: {
    ring: "ring-indigo-500/40 dark:ring-indigo-400/40",
    text: "text-indigo-600 dark:text-indigo-400",
    grad: "from-indigo-500/25 via-violet-500/10 to-transparent",
    btn: "bg-indigo-600 hover:bg-indigo-500 text-white focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-400",
    btnFollowing:
      "bg-white text-slate-700 ring-1 ring-inset ring-slate-300 hover:ring-rose-300 hover:text-rose-600 dark:bg-slate-800 dark:text-slate-200 dark:ring-slate-600 dark:hover:text-rose-400 focus-visible:outline-slate-400",
    softBg: "bg-indigo-50 dark:bg-indigo-500/10",
  },
  emerald: {
    ring: "ring-emerald-500/40 dark:ring-emerald-400/40",
    text: "text-emerald-600 dark:text-emerald-400",
    grad: "from-emerald-500/25 via-sky-500/10 to-transparent",
    btn: "bg-emerald-600 hover:bg-emerald-500 text-white focus-visible:outline-emerald-600 dark:focus-visible:outline-emerald-400",
    btnFollowing:
      "bg-white text-slate-700 ring-1 ring-inset ring-slate-300 hover:ring-rose-300 hover:text-rose-600 dark:bg-slate-800 dark:text-slate-200 dark:ring-slate-600 dark:hover:text-rose-400 focus-visible:outline-slate-400",
    softBg: "bg-emerald-50 dark:bg-emerald-500/10",
  },
  rose: {
    ring: "ring-rose-500/40 dark:ring-rose-400/40",
    text: "text-rose-600 dark:text-rose-400",
    grad: "from-rose-500/25 via-amber-500/10 to-transparent",
    btn: "bg-rose-600 hover:bg-rose-500 text-white focus-visible:outline-rose-600 dark:focus-visible:outline-rose-400",
    btnFollowing:
      "bg-white text-slate-700 ring-1 ring-inset ring-slate-300 hover:ring-rose-300 hover:text-rose-600 dark:bg-slate-800 dark:text-slate-200 dark:ring-slate-600 dark:hover:text-rose-400 focus-visible:outline-slate-400",
    softBg: "bg-rose-50 dark:bg-rose-500/10",
  },
};

function formatCount(n: number): string {
  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
  if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}K`;
  return `${n}`;
}

function VerifiedBadge({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      aria-hidden="true"
      className={className}
      fill="currentColor"
    >
      <path d="M12 2.2l2.14 1.56 2.64-.2.9 2.5 2.29 1.34-.63 2.58 1.06 2.42-1.86 1.9.2 2.65-2.55.72-1.45 2.22-2.6-.55L12 21.8l-2.14-1.86-2.6.55-1.45-2.22-2.55-.72.2-2.65-1.86-1.9L2.66 9.4l-.63-2.58L4.32 5.5l.9-2.5 2.64.2L12 2.2z" />
      <path
        d="M8.6 12.2l2.2 2.2 4.4-4.6"
        fill="none"
        stroke="#fff"
        strokeWidth="1.8"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function ProfileCard({ person }: { person: Person }) {
  const [following, setFollowing] = useState(false);
  const [hovering, setHovering] = useState(false);
  const [pulseKey, setPulseKey] = useState(0);
  const reduce = useReducedMotion();
  const a = ACCENTS[person.accent];

  const liveFollowers =
    person.stats.followers + (following ? 1 : 0);

  const toggle = () => {
    setFollowing((v) => !v);
    setPulseKey((k) => k + 1);
  };

  const btnLabel = following
    ? hovering
      ? "Unfollow"
      : "Following"
    : "Follow";

  return (
    <article className="group relative flex w-full max-w-sm flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm ring-1 ring-slate-900/5 transition-shadow duration-300 hover:shadow-xl dark:border-slate-800 dark:bg-slate-900 dark:ring-white/10">
      {/* Cover */}
      <div className="relative h-24 w-full overflow-hidden">
        {/* eslint-disable-next-line @next/next/no-img-element */}
        <img
          src={person.cover}
          alt=""
          loading="lazy"
          draggable={false}
          className="h-full w-full object-cover"
        />
        <div
          className={`absolute inset-0 bg-gradient-to-br ${a.grad} mix-blend-multiply dark:mix-blend-screen`}
        />
        <div className="absolute inset-0 bg-gradient-to-t from-white/60 to-transparent dark:from-slate-900/70" />
      </div>

      {/* Avatar */}
      <div className="relative -mt-10 px-5">
        <div className="flex items-end justify-between">
          <div
            className={`cpx-float relative h-20 w-20 shrink-0 overflow-hidden rounded-full bg-white ring-4 ring-white dark:bg-slate-900 dark:ring-slate-900`}
          >
            <span
              className={`absolute inset-0 rounded-full ring-2 ring-inset ${a.ring}`}
              aria-hidden="true"
            />
            {/* eslint-disable-next-line @next/next/no-img-element */}
            <img
              src={person.avatar}
              alt={`${person.name}'s avatar`}
              loading="lazy"
              draggable={false}
              className="h-full w-full object-cover"
            />
            <span
              className="absolute bottom-1 right-1 h-3.5 w-3.5 rounded-full border-2 border-white bg-emerald-500 dark:border-slate-900"
              aria-label="Online"
              role="img"
            />
          </div>

          <button
            type="button"
            onClick={toggle}
            onMouseEnter={() => setHovering(true)}
            onMouseLeave={() => setHovering(false)}
            onBlur={() => setHovering(false)}
            aria-pressed={following}
            aria-label={
              following
                ? `Following ${person.name}. Activate to unfollow.`
                : `Follow ${person.name}`
            }
            className={`relative mb-1 inline-flex h-9 items-center gap-1.5 overflow-hidden rounded-full px-4 text-sm font-semibold transition-all duration-200 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 active:scale-95 ${
              following ? a.btnFollowing : a.btn
            }`}
          >
            {!reduce && pulseKey > 0 && (
              <span
                key={pulseKey}
                aria-hidden="true"
                className="cpx-ripple pointer-events-none absolute inset-0 rounded-full"
              />
            )}
            <svg
              viewBox="0 0 24 24"
              aria-hidden="true"
              className="h-4 w-4"
              fill="none"
              stroke="currentColor"
              strokeWidth="2.2"
              strokeLinecap="round"
              strokeLinejoin="round"
            >
              {following ? (
                <path d="M20 6L9 17l-5-5" />
              ) : (
                <>
                  <path d="M12 5v14M5 12h14" />
                </>
              )}
            </svg>
            <span className="relative">{btnLabel}</span>
          </button>
        </div>
      </div>

      {/* Identity */}
      <div className="px-5 pt-3">
        <div className="flex items-center gap-1.5">
          <h3 className="truncate text-base font-semibold text-slate-900 dark:text-white">
            {person.name}
          </h3>
          {person.verified && (
            <VerifiedBadge
              className={`h-4 w-4 shrink-0 ${a.text}`}
            />
          )}
        </div>
        <div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm">
          <span className="text-slate-500 dark:text-slate-400">
            @{person.handle}
          </span>
          {person.followsYou && (
            <span className="rounded bg-slate-100 px-1.5 py-0.5 text-[11px] font-medium text-slate-500 dark:bg-slate-800 dark:text-slate-400">
              Follows you
            </span>
          )}
        </div>

        <p className={`mt-1.5 text-xs font-medium ${a.text}`}>
          {person.role}
        </p>
        <p className="mt-2 text-sm leading-relaxed text-slate-600 dark:text-slate-300">
          {person.bio}
        </p>

        <p className="mt-3 inline-flex items-center gap-1.5 text-xs text-slate-500 dark:text-slate-400">
          <svg
            viewBox="0 0 24 24"
            aria-hidden="true"
            className="h-3.5 w-3.5"
            fill="none"
            stroke="currentColor"
            strokeWidth="2"
            strokeLinecap="round"
            strokeLinejoin="round"
          >
            <path d="M12 21s-7-5.2-7-11a7 7 0 0114 0c0 5.8-7 11-7 11z" />
            <circle cx="12" cy="10" r="2.5" />
          </svg>
          {person.location}
        </p>
      </div>

      {/* Stats */}
      <div className="mt-4 grid grid-cols-3 divide-x divide-slate-200 border-t border-slate-200 dark:divide-slate-800 dark:border-slate-800">
        <Stat label="Posts" value={formatCount(person.stats.posts)} />
        <Stat
          label="Followers"
          value={formatCount(liveFollowers)}
          live={following}
          accent={a}
        />
        <Stat
          label="Following"
          value={formatCount(person.stats.following)}
        />
      </div>
    </article>
  );
}

function Stat({
  label,
  value,
  live,
  accent,
}: {
  label: string;
  value: string;
  live?: boolean;
  accent?: (typeof ACCENTS)[Person["accent"]];
}) {
  return (
    <div className="flex flex-col items-center py-3">
      <span
        className={`text-sm font-semibold tabular-nums transition-colors ${
          live && accent
            ? accent.text
            : "text-slate-900 dark:text-white"
        }`}
      >
        {value}
      </span>
      <span className="mt-0.5 text-[11px] font-medium uppercase tracking-wide text-slate-400 dark:text-slate-500">
        {label}
      </span>
    </div>
  );
}

export default function CardProfile() {
  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 cpx-float-kf {
          0%, 100% { transform: translateY(0); }
          50% { transform: translateY(-4px); }
        }
        @keyframes cpx-ripple-kf {
          0% { transform: scale(0.2); opacity: 0.5; }
          100% { transform: scale(2.4); opacity: 0; }
        }
        .cpx-float { animation: cpx-float-kf 5s ease-in-out infinite; }
        .cpx-ripple {
          background: radial-gradient(circle, currentColor 0%, transparent 60%);
          animation: cpx-ripple-kf 0.6s ease-out forwards;
        }
        @media (prefers-reduced-motion: reduce) {
          .cpx-float { animation: none; }
          .cpx-ripple { animation: none; display: none; }
        }
      `}</style>

      <div className="mx-auto max-w-6xl">
        <header className="mx-auto mb-12 max-w-2xl text-center">
          <span className="inline-flex items-center rounded-full bg-indigo-50 px-3 py-1 text-xs font-semibold text-indigo-600 ring-1 ring-inset ring-indigo-500/20 dark:bg-indigo-500/10 dark:text-indigo-400">
            Profile Cards
          </span>
          <h2 className="mt-4 text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
            Follow the makers
          </h2>
          <p className="mt-3 text-base text-slate-600 dark:text-slate-400">
            Tap follow and the count updates live. Accessible toggles, real
            focus rings, and motion that respects your settings.
          </p>
        </header>

        <div className="grid grid-cols-1 justify-items-center gap-8 sm:grid-cols-2 lg:grid-cols-3">
          {PEOPLE.map((p) => (
            <ProfileCard key={p.id} person={p} />
          ))}
        </div>
      </div>
    </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 quote

Similar components

Browse all →