UserCard

One member, summarised. The Network lists (followers, following, connections, find investors), the activity feed's bylines, and the dashboard's recommendation and profile-snapshot widgets each render a flavour of this card. UserCard standardises the two halves they share:

  • Header — always shown. Avatar, name with badges, one line of quiet facts (location · mutual connections · member since), an optional recent-activity line, and the actions (Connect, Message, Follow, an overflow menu).
  • Details — opt-in. The standardized "more about this member" block: stat tiles (posts, votes, doors, mutuals), titled sections of link rows (latest forum posts, portfolio), or a disclosure that keeps all of that behind a "Show more" trigger. In the card layout it sits under a hairline divider.

In atomic-design terms it is a molecule: it composes Avatar, Button, Menu, and Lucide glyphs, and owns only layout and the interactive parts react-aria provides (Name and Item are RAC Links; Disclosure wraps RAC's Disclosure).

import { UserCard } from "@pockets-ui/components"

Anatomy

PartRole
UserCard.RootThe surface (article). Takes size (normal, compact), layout (card, inline), and variant (outline, elevated).
UserCard.HeaderThe always-shown row: Avatar · Identity · Actions.
UserCard.AvatarFrame for an Avatar.Root, sized to match the card.
UserCard.IdentityThe flexible middle: NameRow on the first line, Metas inline on the second, Activity on the third.
UserCard.NameRowName followed by Badges; wraps.
UserCard.NameRAC Link to the profile. Plain at rest, underlined on hover.
UserCard.BadgeStatic chip beside the name: role, tier, relationship. Takes intent.
UserCard.MetaOne quiet fact. Adjacent Metas share a line, separated by a middle dot.
UserCard.ActivityRecent-activity line on its own row. Blue glyph; text truncates to one line.
UserCard.ActionsButtons and an overflow menu, pinned to the trailing edge.
UserCard.DetailsThe standardized "more" block: a hairline-divided block (card) or an indented continuation (inline).
UserCard.StatsHairline grid of Stat tiles.
UserCard.StatOne tile: StatValue over StatLabel, optional glyph pinned to the corner.
UserCard.StatValueThe pre-formatted number.
UserCard.StatLabelWhat it counts.
UserCard.SectionA titled group of Item rows.
UserCard.SectionTitleThe section's eyebrow (a p, not a heading).
UserCard.ItemRAC Link row: a thread, a property, a shared interest.
UserCard.DisclosureRAC Disclosure; owns the expanded state and stamps data-expanded.
UserCard.DisclosureTriggerRAC Button (slot="trigger") rendered as a text button with a chevron. Defaults to "Show more".
UserCard.DisclosurePanelRAC DisclosurePanel; hidden while collapsed.

Basic

The default card: size="normal", layout="card". Pair it with an md avatar and leave the avatar's alt empty — the name is right beside it. Put each fact in its own Meta; they flow onto one line with dots between them, so the card stays three lines tall. Keep Actions to the two or three things a member does from a list. Destructive actions (Unfollow, Remove connection) belong in an overflow Menu, where they take a second, more deliberate click.

AL

Denver, CO

3 mutual connections

Replied to 4 threads this week

Code
import { Avatar, Button, Menu, UserCard } from "@pockets-ui/components"
import { Activity, Ellipsis } from "lucide-react"
import { button } from "@/styled-system/recipes"

// Menu.Trigger renders its own element, so style it with the button recipe.
const overflow = button({ variant: "ghost", size: "sm" })

export default function UserCardBasic() {
  return (
    <UserCard.Root css={{ maxWidth: "2xl" }}>
      <UserCard.Header>
        <UserCard.Avatar>
          <Avatar.Root>
            <Avatar.Image
              src="https://api.dicebear.com/9.x/shapes/svg?seed=ada"
              alt=""
            />
            <Avatar.Fallback>AL</Avatar.Fallback>
          </Avatar.Root>
        </UserCard.Avatar>
        <UserCard.Identity>
          <UserCard.NameRow>
            <UserCard.Name href="#ada">Ada Lovelace</UserCard.Name>
            <UserCard.Badge intent="warning">Agent</UserCard.Badge>
          </UserCard.NameRow>
          <UserCard.Meta>Denver, CO</UserCard.Meta>
          <UserCard.Meta>3 mutual connections</UserCard.Meta>
          <UserCard.Activity>
            <Activity />
            <span>Replied to 4 threads this week</span>
          </UserCard.Activity>
        </UserCard.Identity>
        <UserCard.Actions>
          <Button size="sm" variant="outline">
            Connect
          </Button>
          <Button size="sm" variant="outline">
            Message
          </Button>
          <Menu.Root>
            <Menu.Trigger
              className={overflow}
              aria-label="More actions for Ada Lovelace"
            >
              <Ellipsis aria-hidden="true" size={16} />
            </Menu.Trigger>
            <Menu.Content aria-label="More actions">
              <Menu.Item id="unfollow">Unfollow</Menu.Item>
              <Menu.Item id="remove">Remove connection</Menu.Item>
            </Menu.Content>
          </Menu.Root>
        </UserCard.Actions>
      </UserCard.Header>
    </UserCard.Root>
  )
}

Badges

Badge is a static chip. intent picks the wash from the semantic intents — the suggested mapping is roles (Agent, Lender) → warning, Pro → premium, a business account → brand, a relationship to the viewer (Connected, Following) → success. Keep to two or three per card; the row wraps but the eye doesn't.

GH
Grace HopperProLenderConnected

Commercial lender at Hopper Capital

Arlington, VA

Code
import { Avatar, UserCard } from "@pockets-ui/components"

export default function UserCardBadges() {
  return (
    <UserCard.Root css={{ maxWidth: "2xl" }}>
      <UserCard.Header>
        <UserCard.Avatar>
          <Avatar.Root>
            <Avatar.Fallback>GH</Avatar.Fallback>
          </Avatar.Root>
        </UserCard.Avatar>
        <UserCard.Identity>
          <UserCard.NameRow>
            <UserCard.Name href="#grace">Grace Hopper</UserCard.Name>
            <UserCard.Badge intent="premium">Pro</UserCard.Badge>
            <UserCard.Badge intent="warning">Lender</UserCard.Badge>
            <UserCard.Badge intent="success">Connected</UserCard.Badge>
          </UserCard.NameRow>
          <UserCard.Meta>Commercial lender at Hopper Capital</UserCard.Meta>
          <UserCard.Meta>Arlington, VA</UserCard.Meta>
        </UserCard.Identity>
      </UserCard.Header>
    </UserCard.Root>
  )
}

Details

Details is the standardized place for everything beyond the header, so richer cards on the dashboard and the basic cards in a list stay recognisably the same component. In the card layout it sits under a hairline divider. It stacks Stats (a hairline grid of the profile's posts / votes / doors / rank tiles) and Sections of Item rows (the latest forum posts, a portfolio strip). Pre-format numbers yourself.

Code
import { Avatar, Button, UserCard } from "@pockets-ui/components"
import {
  ArrowBigUp,
  Building2,
  FileText,
  Home,
  MessageSquare,
  Trophy,
} from "lucide-react"

const posts = [
  { id: 1, title: "Is a 6% cap rate still worth it in Denver?" },
  { id: 2, title: "How I screened my first out-of-state PM" },
]

export default function UserCardDetails() {
  return (
    <UserCard.Root css={{ maxWidth: "2xl" }}>
      <UserCard.Header>
        <UserCard.Avatar>
          <Avatar.Root>
            <Avatar.Fallback>KJ</Avatar.Fallback>
          </Avatar.Root>
        </UserCard.Avatar>
        <UserCard.Identity>
          <UserCard.NameRow>
            <UserCard.Name href="#katherine">Katherine Johnson</UserCard.Name>
            <UserCard.Badge intent="premium">Pro</UserCard.Badge>
          </UserCard.NameRow>
          <UserCard.Meta>Hampton, VA</UserCard.Meta>
          <UserCard.Meta>Member since 2016</UserCard.Meta>
        </UserCard.Identity>
        <UserCard.Actions>
          <Button size="sm">Connect</Button>
        </UserCard.Actions>
      </UserCard.Header>
      <UserCard.Details>
        <UserCard.Stats>
          <UserCard.Stat>
            <MessageSquare />
            <UserCard.StatValue>1,204</UserCard.StatValue>
            <UserCard.StatLabel>Posts</UserCard.StatLabel>
          </UserCard.Stat>
          <UserCard.Stat>
            <ArrowBigUp />
            <UserCard.StatValue>3,918</UserCard.StatValue>
            <UserCard.StatLabel>Votes</UserCard.StatLabel>
          </UserCard.Stat>
          <UserCard.Stat>
            <Home />
            <UserCard.StatValue>14</UserCard.StatValue>
            <UserCard.StatLabel>Doors</UserCard.StatLabel>
          </UserCard.Stat>
          <UserCard.Stat>
            <Trophy />
            <UserCard.StatValue>#3</UserCard.StatValue>
            <UserCard.StatLabel>Contributor</UserCard.StatLabel>
          </UserCard.Stat>
        </UserCard.Stats>
        <UserCard.Section>
          <UserCard.SectionTitle>Latest forum posts</UserCard.SectionTitle>
          {posts.map((post) => (
            <UserCard.Item key={post.id} href={`#post-${post.id}`}>
              <FileText />
              {post.title}
            </UserCard.Item>
          ))}
        </UserCard.Section>
        <UserCard.Section>
          <UserCard.SectionTitle>Portfolio</UserCard.SectionTitle>
          <UserCard.Item href="#portfolio">
            <Building2 />
            Added a fourplex in Norfolk, VA · Mar 2026
          </UserCard.Item>
        </UserCard.Section>
      </UserCard.Details>
    </UserCard.Root>
  )
}

Disclosure

When a list would get too tall with every card's details open, keep them behind a Disclosure. RAC owns the expanded state — uncontrolled with defaultExpanded, or controlled with isExpanded + onExpandedChange — and wires aria-expanded / aria-controls on the trigger. The chevron rotates on the disclosure's data-expanded.

Code
import { Avatar, Button, UserCard } from "@pockets-ui/components"
import { Activity, FileText } from "lucide-react"

export default function UserCardDisclosure() {
  return (
    <UserCard.Root css={{ maxWidth: "2xl" }}>
      <UserCard.Header>
        <UserCard.Avatar>
          <Avatar.Root>
            <Avatar.Fallback>MH</Avatar.Fallback>
          </Avatar.Root>
        </UserCard.Avatar>
        <UserCard.Identity>
          <UserCard.NameRow>
            <UserCard.Name href="#margaret">Margaret Hamilton</UserCard.Name>
            <UserCard.Badge intent="warning">Property manager</UserCard.Badge>
          </UserCard.NameRow>
          <UserCard.Meta>Boston, MA</UserCard.Meta>
          <UserCard.Activity>
            <Activity />
            <span>Posted 2 days ago</span>
          </UserCard.Activity>
        </UserCard.Identity>
        <UserCard.Actions>
          <Button size="sm" variant="outline">
            Follow
          </Button>
        </UserCard.Actions>
      </UserCard.Header>
      <UserCard.Details>
        <UserCard.Disclosure>
          <UserCard.DisclosureTrigger>
            More about Margaret
          </UserCard.DisclosureTrigger>
          <UserCard.DisclosurePanel>
            <UserCard.Stats>
              <UserCard.Stat>
                <UserCard.StatValue>412</UserCard.StatValue>
                <UserCard.StatLabel>Posts</UserCard.StatLabel>
              </UserCard.Stat>
              <UserCard.Stat>
                <UserCard.StatValue>86</UserCard.StatValue>
                <UserCard.StatLabel>Best replies</UserCard.StatLabel>
              </UserCard.Stat>
              <UserCard.Stat>
                <UserCard.StatValue>7</UserCard.StatValue>
                <UserCard.StatLabel>Mutuals</UserCard.StatLabel>
              </UserCard.Stat>
            </UserCard.Stats>
            <UserCard.Section>
              <UserCard.SectionTitle>Latest forum posts</UserCard.SectionTitle>
              <UserCard.Item href="#post-1">
                <FileText />
                Tenant screening checklist for small multifamily
              </UserCard.Item>
              <UserCard.Item href="#post-2">
                <FileText />
                What does a good PM report look like?
              </UserCard.Item>
            </UserCard.Section>
          </UserCard.DisclosurePanel>
        </UserCard.Disclosure>
      </UserCard.Details>
    </UserCard.Root>
  )
}

Sizes and layouts

Two sizes and two layouts, freely combined:

  • size="compact" tightens padding and type for dense lists and grids. Pair it with an sm avatar and size="sm" buttons; normal pairs with md.
  • layout="inline" drops the frame for a list of members inside a surface that already has one (a Card, a popover, a feed). Adjacent inline cards draw a hairline between themselves, and Details indents to the identity column so it reads as part of the same entry.
  • variant (outline, elevated) picks the card surface and does nothing for layout="inline".
RF

Austin, TX

12 mutual connections

Posted in Multifamily yesterday

128

Posts

6

Doors

9 yrs

Investing

DH

Oxford, UK

Buy & hold

Replied to your thread

128

Posts

6

Doors

9 yrs

Investing

Code
import { Avatar, Button, Card, UserCard } from "@pockets-ui/components"
import { Activity } from "lucide-react"
import { css } from "@/styled-system/css"

const stack = css({
  display: "flex",
  flexDirection: "column",
  gap: "6",
  width: "full",
  maxWidth: "2xl",
})

const members = [
  {
    id: "rosalind",
    initials: "RF",
    name: "Rosalind Franklin",
    badge: { intent: "brand", label: "Business" },
    meta: ["Austin, TX", "12 mutual connections"],
    activity: "Posted in Multifamily yesterday",
  },
  {
    id: "dorothy",
    initials: "DH",
    name: "Dorothy Hodgkin",
    badge: { intent: "success", label: "Connected" },
    meta: ["Oxford, UK", "Buy & hold"],
    activity: "Replied to your thread",
  },
  {
    id: "lise",
    initials: "LM",
    name: "Lise Meitner",
    badge: null,
    meta: ["Stockholm, SE", "2 mutual connections"],
    activity: "Added a duplex last week",
  },
] as const

export default function UserCardVariants() {
  return (
    <div className={stack}>
      {/* Compact card: for a dense grid of members, paired with a sm avatar. */}
      <UserCard.Root size="compact" variant="elevated">
        <UserCard.Header>
          <UserCard.Avatar>
            <Avatar.Root size="sm">
              <Avatar.Fallback>RF</Avatar.Fallback>
            </Avatar.Root>
          </UserCard.Avatar>
          <UserCard.Identity>
            <UserCard.NameRow>
              <UserCard.Name href="#rosalind">Rosalind Franklin</UserCard.Name>
              <UserCard.Badge intent="brand">Business</UserCard.Badge>
            </UserCard.NameRow>
            <UserCard.Meta>Austin, TX</UserCard.Meta>
            <UserCard.Meta>12 mutual connections</UserCard.Meta>
          </UserCard.Identity>
          <UserCard.Actions>
            <Button size="sm">Connect</Button>
          </UserCard.Actions>
        </UserCard.Header>
      </UserCard.Root>

      {/* Inline, compact: a list of members inside a surface that already has
          a frame. Adjacent cards draw their own hairline dividers. */}
      <Card padding="none" css={{ paddingInline: "4" }}>
        {members.map((member) => (
          <UserCard.Root key={member.id} size="compact" layout="inline">
            <UserCard.Header>
              <UserCard.Avatar>
                <Avatar.Root size="sm">
                  <Avatar.Fallback>{member.initials}</Avatar.Fallback>
                </Avatar.Root>
              </UserCard.Avatar>
              <UserCard.Identity>
                <UserCard.NameRow>
                  <UserCard.Name href={`#${member.id}`}>
                    {member.name}
                  </UserCard.Name>
                  {member.badge ? (
                    <UserCard.Badge intent={member.badge.intent}>
                      {member.badge.label}
                    </UserCard.Badge>
                  ) : null}
                </UserCard.NameRow>
                {member.meta.map((line) => (
                  <UserCard.Meta key={line}>{line}</UserCard.Meta>
                ))}
              </UserCard.Identity>
              <UserCard.Actions>
                <Button size="sm" variant="outline">
                  Follow
                </Button>
              </UserCard.Actions>
            </UserCard.Header>
          </UserCard.Root>
        ))}
      </Card>

      {/* Inline, normal: a feed. The details block indents to the identity
          column so it reads as part of the same entry. */}
      <div>
        {members.slice(0, 2).map((member) => (
          <UserCard.Root key={member.id} layout="inline">
            <UserCard.Header>
              <UserCard.Avatar>
                <Avatar.Root>
                  <Avatar.Fallback>{member.initials}</Avatar.Fallback>
                </Avatar.Root>
              </UserCard.Avatar>
              <UserCard.Identity>
                <UserCard.NameRow>
                  <UserCard.Name href={`#${member.id}`}>
                    {member.name}
                  </UserCard.Name>
                  {member.badge ? (
                    <UserCard.Badge intent={member.badge.intent}>
                      {member.badge.label}
                    </UserCard.Badge>
                  ) : null}
                </UserCard.NameRow>
                {member.meta.map((line) => (
                  <UserCard.Meta key={line}>{line}</UserCard.Meta>
                ))}
                <UserCard.Activity>
                  <Activity />
                  <span>{member.activity}</span>
                </UserCard.Activity>
              </UserCard.Identity>
              <UserCard.Actions>
                <Button size="sm" variant="outline">
                  Message
                </Button>
              </UserCard.Actions>
            </UserCard.Header>
            <UserCard.Details>
              <UserCard.Stats>
                <UserCard.Stat>
                  <UserCard.StatValue>128</UserCard.StatValue>
                  <UserCard.StatLabel>Posts</UserCard.StatLabel>
                </UserCard.Stat>
                <UserCard.Stat>
                  <UserCard.StatValue>6</UserCard.StatValue>
                  <UserCard.StatLabel>Doors</UserCard.StatLabel>
                </UserCard.Stat>
                <UserCard.Stat>
                  <UserCard.StatValue>9 yrs</UserCard.StatValue>
                  <UserCard.StatLabel>Investing</UserCard.StatLabel>
                </UserCard.Stat>
              </UserCard.Stats>
            </UserCard.Details>
          </UserCard.Root>
        ))}
      </div>
    </div>
  )
}

Current user

A card for the viewer themselves drops the things that only make sense about someone else: Actions, the mutual-connections line, the recent-activity line. Omit href on Name and RAC renders it as a non-navigating span with link semantics removed.

One-off overrides

Every part takes a css prop for style overrides that don't deserve a recipe variant. A badge can be rethemed through its two custom properties without forking the recipe (its hairline ring is mixed from the foreground, so it follows):

<UserCard.Badge
  css={{
    "--pk-user-card-badge-bg": "token(colors.purple.100)",
    "--pk-user-card-badge-fg": "token(colors.purple.800)",
  }}
>
  Pro
</UserCard.Badge>