# Actor Card

Profile summary card for an ActivityPub actor with Schema.org Person markup.

## Installation

```bash
npx shadcn@latest add https://ui.uptoolkit.com/r/actor-card.json
```

[Registry JSON](https://ui.uptoolkit.com/r/actor-card.json)

## Preview

```tsx
import {
  sampleAuthor,
  sampleCounts,
  sampleFollowers,
  sampleViewer,
} from "@/lib/social-sample-data";
import { ActorCard } from "@/components/ui/actor-card";

export function Preview() {
  return (
    <div className="flex w-full max-w-2xl flex-col gap-4">
      <ActorCard
        actor={sampleAuthor}
        viewer={sampleViewer}
        counts={sampleCounts.author}
        showFields
        includeJsonLd
        onMessage={() => undefined}
      />
      <ActorCard actor={sampleFollowers[2]} viewer={sampleViewer} variant="row" />
    </div>
  );
}
```


## Source

### ui/actor-card.tsx

```tsx
"use client";

import { IconCalendar, IconLink, IconMessage } from "@tabler/icons-react";
import * as React from "react";

import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import { cn } from "@/lib/utils";

import {
  formatCompactNumber,
  formatPublishedDate,
  getActorDisplayName,
  getActorHandle,
  toPlainText,
  type ActivityPubActor,
} from "@/lib/activitypub";
import { toPersonJsonLd, type SocialInteractionCounts } from "@/lib/schema-org";
import { ActorAvatar } from "@/components/ui/actor-avatar";
import { FollowButton, type FollowButtonProps } from "@/components/ui/follow-button";
import { JsonLd } from "@/components/json-ld";

type ActorCardProps = Omit<React.ComponentProps<typeof Card>, "children"> & {
  actor: ActivityPubActor;
  /** The signed-in actor, forwarded to the follow button. */
  viewer?: ActivityPubActor;
  /** Follower, following, and post counts shown in the stats row. */
  counts?: SocialInteractionCounts;
  followState?: FollowButtonProps["state"];
  defaultFollowState?: FollowButtonProps["defaultState"];
  onFollow?: FollowButtonProps["onFollow"];
  onUnfollow?: FollowButtonProps["onUnfollow"];
  onFollowStateChange?: FollowButtonProps["onStateChange"];
  onMessage?: (actor: ActivityPubActor) => void;
  /** `card` stacks the profile; `row` is a compact list item. */
  variant?: "card" | "row";
  /** Show the actor's `attachment` PropertyValue metadata rows. */
  showFields?: boolean;
  /** Emits a Schema.org `Person` (or `Organization`) JSON-LD script. */
  includeJsonLd?: boolean;
  hideFollow?: boolean;
  /** BCP 47 locale for dates and counts. Must match on server and client. */
  locale?: string;
};

function ActorStats({ counts, locale }: { counts: SocialInteractionCounts; locale?: string }) {
  const entries: [string, number | undefined][] = [
    ["Posts", counts.posts],
    ["Followers", counts.followers],
    ["Following", counts.following],
  ];
  const visible = entries.filter((entry): entry is [string, number] => entry[1] !== undefined);

  if (visible.length === 0) {
    return null;
  }

  return (
    <dl className="m-0 flex flex-wrap gap-x-4 gap-y-1 text-sm">
      {visible.map(([label, value]) => (
        <div key={label} className="flex flex-row-reverse gap-1">
          <dt className="text-muted-foreground">{label}</dt>
          <dd className="m-0 font-semibold tabular-nums">{formatCompactNumber(value, locale)}</dd>
        </div>
      ))}
    </dl>
  );
}

/**
 * Profile summary card for an ActivityPub actor.
 *
 * Renders the actor's identity, bio, verified profile fields, and counts, with
 * Schema.org `Person`/`Organization` microdata inline. Verification comes from
 * the `verifiedAt` flag on the actor's `attachment` fields, matching how
 * Mastodon marks a confirmed `rel="me"` link.
 */
function ActorCard({
  actor,
  viewer,
  counts,
  followState,
  defaultFollowState,
  onFollow,
  onUnfollow,
  onFollowStateChange,
  onMessage,
  variant = "card",
  showFields = false,
  includeJsonLd = false,
  hideFollow = false,
  locale,
  className,
  ...props
}: ActorCardProps) {
  const isRow = variant === "row";
  const bio = toPlainText(actor.summary);
  const joined = formatPublishedDate(actor.published, locale === undefined ? {} : { locale });
  const isOrganization = actor.type === "Organization" || actor.type === "Group";
  const fields = showFields ? (actor.attachment ?? []) : [];
  const verified = (actor.attachment ?? []).some((field) => Boolean(field.verifiedAt));

  const follow = hideFollow ? null : (
    <FollowButton
      actor={actor}
      viewer={viewer}
      state={followState}
      defaultState={defaultFollowState}
      onFollow={onFollow}
      onUnfollow={onUnfollow}
      onStateChange={onFollowStateChange}
    />
  );

  return (
    <Card
      className={cn("w-full", isRow ? "gap-0 py-3" : "gap-3 py-4", className)}
      itemScope
      itemType={isOrganization ? "https://schema.org/Organization" : "https://schema.org/Person"}
      itemID={actor.id}
      {...props}
    >
      {includeJsonLd ? <JsonLd data={toPersonJsonLd(actor, { counts })} /> : null}
      <meta itemProp="identifier" content={getActorHandle(actor).slice(1)} />

      <CardContent
        className={cn("flex gap-3", isRow ? "items-center" : "flex-col items-start gap-2")}
      >
        <ActorAvatar actor={actor} size="lg" verified={verified} showLock />

        <div className="flex min-w-0 flex-1 flex-col gap-1">
          <a href={actor.url ?? actor.id} className="font-semibold hover:underline" itemProp="url">
            <span itemProp="name">{getActorDisplayName(actor)}</span>
          </a>
          <span className="truncate text-sm text-muted-foreground" itemProp="alternateName">
            {getActorHandle(actor)}
          </span>

          {bio ? (
            <p className={cn("m-0 text-sm", isRow && "line-clamp-2")} itemProp="description">
              {bio}
            </p>
          ) : null}

          {counts ? <ActorStats counts={counts} locale={locale} /> : null}

          {fields.length > 0 ? (
            <dl className="m-0 mt-1 grid gap-1 text-sm">
              {fields.map((field) => (
                <div key={field.name} className="flex flex-wrap gap-x-2">
                  <dt className="text-muted-foreground">{field.name}</dt>
                  <dd
                    className={cn(
                      "m-0 flex items-center gap-1",
                      field.verifiedAt && "text-emerald-600 dark:text-emerald-400",
                    )}
                  >
                    {field.verifiedAt ? (
                      <IconLink aria-label="Verified link" role="img" className="size-3.5" />
                    ) : null}
                    {toPlainText(field.value)}
                  </dd>
                </div>
              ))}
            </dl>
          ) : null}

          {joined && !isRow ? (
            <p className="m-0 flex items-center gap-1 text-xs text-muted-foreground">
              <IconCalendar aria-hidden="true" className="size-3.5" />
              Joined {joined}
            </p>
          ) : null}
        </div>

        {isRow ? <div className="flex shrink-0 items-center gap-2">{follow}</div> : null}
      </CardContent>

      {isRow ? null : (
        <CardFooter className="gap-2">
          {follow}
          {onMessage ? (
            <Button
              type="button"
              variant="outline"
              size="sm"
              onClick={() => onMessage(actor)}
              aria-label={`Message ${getActorDisplayName(actor)}`}
            >
              <IconMessage aria-hidden="true" />
              Message
            </Button>
          ) : null}
        </CardFooter>
      )}
    </Card>
  );
}

export { ActorCard, type ActorCardProps };
```



## Usage

Profile summary for an ActivityPub actor: identity, bio, counts, profile fields, and a follow
control. Use it in follower lists, suggestion rails, and search results.

```tsx
import { ActorCard } from "@/components/ui/actor-card";

<ActorCard
  actor={actor}
  viewer={viewer}
  counts={{ posts: 1204, followers: 18940, following: 342 }}
  showFields
  onMessage={(actor) => openThread(actor)}
/>
```

### Variants

`card` stacks avatar, identity, and actions vertically. `row` is a compact list item with the follow
button trailing — the right shape for a sidebar or a long list.

```tsx
<ActorCard actor={actor} variant="row" />
```

### Verification

`showFields` renders the actor's `attachment` PropertyValue rows. Fields with `verifiedAt` set — how
Mastodon marks a confirmed `rel="me"` link — get a link icon and a verified accent, and the avatar
picks up a check badge.

### Structured data

The card emits `Person` or `Organization` microdata depending on the actor's `type`, including the
WebFinger handle as `identifier`. Add `includeJsonLd` for a JSON-LD script; leave it off when the
card is inside a list that already emits an `ItemList`, to avoid duplicate nodes.

Follow state is controlled or uncontrolled, forwarding to
[`follow-button`](/components/follow-button) — pass `hideFollow` to drop the control entirely.

