Cards

Profile Card

A person card with avatar, name, role, and action row on a depth-border surface.

Install

npx shadcn@latest add @paragon/profile-card

profile-card.tsx

"use client";

import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils";

/** "Priya Raghavan" → "PR"; single names take the first two letters. */
function getInitials(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();
}

export interface ProfileCardProps extends React.ComponentProps<"div"> {
  /** Person's display name. Also seeds the avatar fallback initials. */
  name?: string;
  /** Role or title line under the name. */
  role?: string;
  /** Avatar image URL. Falls back to initials while loading or on error. */
  src?: string;
  /** Override the derived initials (e.g. for non-Latin names). */
  initials?: string;
  /** Disables hover depth where the motion would distract. */
  static?: boolean;
  /** Action row content. Defaults to Message / View profile actions. */
  children?: React.ReactNode;
}

/**
 * A person card: avatar, name, role, and an action row on a depth-border
 * surface. Quiet by design — the only motion is hover depth on the surface
 * and press physics on the actions. Radii are concentric: the outer radius
 * equals the action radius plus the card padding, so the corners nest.
 */
function ProfileCard({
  name = "Priya Raghavan",
  role = "Engineering Manager",
  src,
  initials,
  static: isStatic = false,
  className,
  children,
  ...props
}: ProfileCardProps) {
  return (
    <div
      data-slot="profile-card"
      className={cn(
        // Concentric: outer 22px = action rounded-lg (10px) + p-3 (12px).
        "w-60 rounded-[calc(var(--radius)+12px)] bg-card p-3 shadow-border transition-[box-shadow] duration-150 ease-out",
        !isStatic && "hover:shadow-border-hover",
        className,
      )}
      {...props}
    >
      <div className="flex flex-col items-center px-3 pt-5 pb-4 text-center">
        <AvatarPrimitive.Root className="relative flex size-14 shrink-0 overflow-hidden rounded-full bg-muted select-none after:pointer-events-none after:absolute after:inset-0 after:rounded-full after:ring-1 after:ring-border after:ring-inset">
          {src ? (
            <AvatarPrimitive.Image
              src={src}
              alt=""
              className="aspect-square size-full object-cover"
            />
          ) : null}
          <AvatarPrimitive.Fallback className="flex size-full items-center justify-center text-sm font-medium tracking-wide text-muted-foreground">
            {initials ?? getInitials(name)}
          </AvatarPrimitive.Fallback>
        </AvatarPrimitive.Root>
        <p className="mt-3 text-sm font-medium text-card-foreground">{name}</p>
        <p className="mt-0.5 text-[13px] text-muted-foreground">{role}</p>
      </div>
      <div className="flex gap-2">
        {children ?? (
          <>
            <ProfileCardAction>Message</ProfileCardAction>
            <ProfileCardAction>View profile</ProfileCardAction>
          </>
        )}
      </div>
    </div>
  );
}

export interface ProfileCardActionProps
  extends React.ComponentProps<"button"> {
  /** Disables press-scale feedback where the motion would distract. */
  static?: boolean;
}

/** A quiet, equal-weight action for the card's footer row. */
function ProfileCardAction({
  className,
  static: isStatic = false,
  type = "button",
  ...props
}: ProfileCardActionProps) {
  return (
    <button
      data-slot="profile-card-action"
      type={type}
      className={cn(
        "inline-flex h-9 flex-1 items-center justify-center gap-1.5 rounded-lg bg-secondary px-3 text-[13px] font-medium whitespace-nowrap text-secondary-foreground transition-[scale,background-color] duration-150 ease-out outline-none hover:bg-secondary/80 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-3.5 [&_svg]:shrink-0",
        !isStatic && "active:not-disabled:scale-[0.97]",
        className,
      )}
      {...props}
    />
  );
}

export { ProfileCard, ProfileCardAction };