A person card with avatar, name, role, and action row on a depth-border surface.
npx shadcn@latest add @paragon/profile-card"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 };