# Actor Grid

Filterable people grid with Schema.org ItemList markup.

## Installation

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

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

## Preview

```tsx
import { sampleSuggestions } from "@/lib/social-sample-data";
import { ActorGrid } from "@/components/actor-grid";

export function Preview() {
  return (
    <div className="flex w-full flex-col gap-8">
      <ActorGrid
        heading="Followers"
        description="Everyone following this actor across the fediverse."
        columns={2}
        searchable
      />
      <ActorGrid
        actors={sampleSuggestions}
        heading="People you may know"
        variant="row"
        columns={1}
        includeJsonLd={false}
      />
    </div>
  );
}
```


## Source

### components/actor-grid.tsx

```tsx
"use client";

import { IconSearch, IconUsersGroup } from "@tabler/icons-react";
import * as React from "react";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";

import { ActorCard } from "@/components/ui/actor-card";
import type { FollowState } from "@/components/ui/follow-button";
import { JsonLd } from "@/components/json-ld";
import {
  formatCompactNumber,
  getActorDisplayName,
  getActorHandle,
  getCollectionCount,
  getCollectionItems,
  toPlainText,
  type ActivityPubActor,
  type ActivityPubCollection,
} from "@/lib/activitypub";
import { toActorListJsonLd } from "@/lib/schema-org";
import { sampleFollowers } from "@/lib/social-sample-data";

const columnClasses: Record<number, string> = {
  1: "grid-cols-1",
  2: "grid-cols-1 sm:grid-cols-2",
  3: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3",
  4: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4",
};

type ActorGridProps = Omit<React.ComponentProps<"section">, "children"> & {
  /** A `followers`, `following`, or suggestions collection. */
  actors?: ActivityPubCollection<ActivityPubActor> | readonly ActivityPubActor[];
  heading?: string;
  description?: string;
  /** Follow state per actor id, so the grid can show mixed relationships. */
  followStates?: Readonly<Record<string, FollowState>>;
  onFollowStateChange?: (actor: ActivityPubActor, state: FollowState) => void;
  onMessage?: (actor: ActivityPubActor) => void;
  /** Card layout: `card` stacks, `row` renders compact list rows. */
  variant?: "card" | "row";
  columns?: 1 | 2 | 3 | 4;
  /** Show the client-side name and handle filter. */
  searchable?: boolean;
  /** Actors shown before the "show all" control. */
  initialCount?: number;
  emptyState?: React.ReactNode;
  /** Emits a Schema.org `ItemList` of `Person` nodes. */
  includeJsonLd?: boolean;
  /** BCP 47 locale for dates and counts. Must match on server and client. */
  locale?: string;
};

/**
 * People grid for an ActivityPub actor collection.
 *
 * Works for followers, following, and suggestion lists. The total comes from the
 * collection's `totalItems` when present, so a paginated collection still
 * reports its real size, and the list is described as a Schema.org `ItemList`.
 */
function ActorGrid({
  actors = sampleFollowers,
  heading = "Followers",
  description,
  followStates,
  onFollowStateChange,
  onMessage,
  variant = "card",
  columns = 3,
  searchable = false,
  initialCount,
  emptyState,
  includeJsonLd = true,
  locale,
  className,
  ...props
}: ActorGridProps) {
  const [query, setQuery] = React.useState("");
  const [expanded, setExpanded] = React.useState(false);
  const searchId = React.useId();

  const items = React.useMemo(() => getCollectionItems(actors), [actors]);
  const total = getCollectionCount(actors);

  const filtered = React.useMemo(() => {
    const needle = query.trim().toLowerCase();

    if (!needle) {
      return items;
    }

    return items.filter((actor) =>
      [getActorDisplayName(actor), getActorHandle(actor), toPlainText(actor.summary)]
        .join(" ")
        .toLowerCase()
        .includes(needle),
    );
  }, [items, query]);

  const limit = initialCount ?? filtered.length;
  const visible = expanded ? filtered : filtered.slice(0, limit);
  const hidden = filtered.length - visible.length;

  return (
    <section
      aria-label={heading}
      className={cn("flex w-full flex-col gap-3", className)}
      {...props}
    >
      {includeJsonLd ? <JsonLd data={toActorListJsonLd(items, { name: heading })} /> : null}

      <div className="flex flex-wrap items-end justify-between gap-3">
        <div className="flex flex-col gap-1">
          <h2 className="m-0 flex items-center gap-2 text-lg font-semibold">
            <IconUsersGroup aria-hidden="true" className="size-5" />
            {heading}
            {total > 0 ? (
              <span className="text-sm font-normal text-muted-foreground tabular-nums">
                {formatCompactNumber(total, locale)}
              </span>
            ) : null}
          </h2>
          {description ? <p className="m-0 text-sm text-muted-foreground">{description}</p> : null}
        </div>

        {searchable ? (
          <div className="flex items-center gap-2">
            <label className="sr-only" htmlFor={searchId}>
              Filter {heading}
            </label>
            <div className="relative">
              <IconSearch
                aria-hidden="true"
                className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground"
              />
              <Input
                id={searchId}
                type="search"
                value={query}
                placeholder="Search people"
                className="w-48 pl-8"
                onChange={(event) => setQuery(event.target.value)}
              />
            </div>
          </div>
        ) : null}
      </div>

      {visible.length === 0 ? (
        <div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">
          {emptyState ?? (query ? `No people match "${query}".` : "No people to show yet.")}
        </div>
      ) : (
        <ul
          className={cn(
            "m-0 grid list-none gap-3 p-0",
            variant === "row" ? "grid-cols-1" : (columnClasses[columns] ?? columnClasses[3]),
          )}
        >
          {visible.map((actor) => (
            <li key={actor.id}>
              <ActorCard
                actor={actor}
                variant={variant}
                showFields={variant === "card"}
                includeJsonLd={false}
                locale={locale}
                followState={followStates?.[actor.id]}
                onFollowStateChange={(state) => onFollowStateChange?.(actor, state)}
                {...(onMessage ? { onMessage } : {})}
              />
            </li>
          ))}
        </ul>
      )}

      {hidden > 0 ? (
        <Button
          type="button"
          variant="outline"
          className="self-center"
          onClick={() => setExpanded(true)}
        >
          Show all {filtered.length} people
        </Button>
      ) : null}
    </section>
  );
}

export { ActorGrid, type ActorGridProps };
```



## Usage

One block for every people list: followers, following, mutuals, suggestions, and search results.

```tsx
import { ActorGrid } from "@/components/actor-grid";

const followers = await fetchFollowers(actor);

<ActorGrid
  actors={followers}
  heading="Followers"
  columns={3}
  searchable
  onFollowStateChange={(actor, state) => trackRelationship(actor, state)}
/>
```

Accepts a `Collection` or a plain array. When a collection carries `totalItems`, that is the count
shown in the heading, so a paginated followers list reports its real size instead of the page length.

### Layout

`variant="card"` uses the stacked card with profile fields; `variant="row"` renders compact rows and
ignores `columns`, which is the shape for a sidebar.

```tsx
<ActorGrid actors={suggestions} heading="People you may know" variant="row" columns={1} />
```

### Filtering

`searchable` adds a client-side filter over display name, handle, and bio. `initialCount` limits the
first render and adds a "show all" control:

```tsx
<ActorGrid actors={followers} initialCount={6} searchable />
```

The empty state distinguishes "no results for this query" from "nothing here yet"; override it with
`emptyState`.

### Relationships

Pass `followStates` keyed by actor id to show mixed relationships in one list:

```tsx
<ActorGrid actors={people} followStates={{ [actor.id]: "following" }} />
```

### Structured data

Emits one Schema.org `ItemList` of `Person` nodes for the whole list, and the cards inside skip
their own JSON-LD so the graph stays clean. Set `includeJsonLd={false}` when the page emits the list
itself.

