# Follow Button

Follow control that emits ActivityPub Follow and Undo activities.

## Installation

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

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

## Preview

```tsx
import * as React from "react";

import { getActorHandle } from "@/lib/activitypub";
import {
  sampleAuthor,
  sampleFollowers,
  sampleViewer,
} from "@/lib/social-sample-data";
import { FollowButton, type FollowState } from "@/components/ui/follow-button";

const targets = [sampleAuthor, sampleFollowers[2]];

export function Preview() {
  const [log, setLog] = React.useState<string[]>([]);

  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      {targets.map((actor) => (
        <div
          key={actor.id}
          className="flex items-center justify-between gap-3 rounded-md border p-3"
        >
          <span className="flex flex-col text-sm">
            <span className="font-medium">{actor.name}</span>
            <span className="text-xs text-muted-foreground">{getActorHandle(actor)}</span>
          </span>
          <FollowButton
            actor={actor}
            viewer={sampleViewer}
            onFollow={(activity) => setLog((l) => [`${activity.type} -> ${activity.object}`, ...l])}
            onUnfollow={(activity) =>
              setLog((l) => [`${activity.type} ${activity.object.type}`, ...l])
            }
            onStateChange={(state: FollowState) => setLog((l) => [`state: ${state}`, ...l])}
          />
        </div>
      ))}
      <p className="m-0 text-xs text-muted-foreground">
        {log[0] ?? "Follow an actor to emit an activity."}
      </p>
    </div>
  );
}
```


## Source

### ui/follow-button.tsx

```tsx
"use client";

import { IconCheck, IconClock, IconPlus, IconUserMinus } from "@tabler/icons-react";
import * as React from "react";

import { Button } from "@/components/ui/button";

import {
  createFollowActivity,
  createUndoActivity,
  getActorDisplayName,
  type ActivityPubActivity,
  type ActivityPubActor,
} from "@/lib/activitypub";

/** Mirrors the ActivityPub follow lifecycle: Follow -> Accept, or Undo. */
type FollowState = "none" | "requested" | "following";

type FollowButtonProps = Omit<
  React.ComponentProps<typeof Button>,
  "children" | "onClick" | "value" | "defaultValue"
> & {
  /** The actor being followed. */
  actor: ActivityPubActor;
  /** The signed-in actor. Required to build a deliverable activity. */
  viewer?: ActivityPubActor;
  /** Controlled state. Omit to let the button manage its own state. */
  state?: FollowState;
  defaultState?: FollowState;
  onStateChange?: (state: FollowState) => void;
  /**
   * Called with the `Follow` activity to POST to the viewer's outbox. Resolve to
   * `false` to roll the optimistic state back.
   */
  onFollow?: (activity: ActivityPubActivity<string>) => void | boolean | Promise<void | boolean>;
  /** Called with the `Undo` activity that retracts the follow. */
  onUnfollow?: (
    activity: ActivityPubActivity<ActivityPubActivity<string>>,
  ) => void | boolean | Promise<void | boolean>;
  /** Swaps the following label for "Unfollow" on hover and focus. */
  showUnfollowOnHover?: boolean;
};

const stateLabels: Record<FollowState, string> = {
  none: "Follow",
  requested: "Requested",
  following: "Following",
};

/**
 * Follow control for an ActivityPub actor.
 *
 * Follows are optimistic: the label updates immediately, then reverts if the
 * handler resolves to `false`. Actors with `manuallyApprovesFollowers` move to
 * `requested` instead of `following`, matching the server's Accept flow.
 */
function FollowButton({
  actor,
  viewer,
  state: controlledState,
  defaultState = "none",
  onStateChange,
  onFollow,
  onUnfollow,
  showUnfollowOnHover = true,
  className,
  disabled,
  size = "sm",
  ...props
}: FollowButtonProps) {
  const [uncontrolledState, setUncontrolledState] = React.useState<FollowState>(defaultState);
  const [pending, setPending] = React.useState(false);
  const [intentVisible, setIntentVisible] = React.useState(false);

  const state = controlledState ?? uncontrolledState;
  const isFollowing = state === "following" || state === "requested";
  /** Track the sent Follow so Undo can reference the exact activity id. */
  const followActivityRef = React.useRef<ActivityPubActivity<string> | null>(null);

  const commitState = React.useCallback(
    (next: FollowState) => {
      if (controlledState === undefined) {
        setUncontrolledState(next);
      }

      onStateChange?.(next);
    },
    [controlledState, onStateChange],
  );

  const handleClick = React.useCallback(async () => {
    if (pending) {
      return;
    }

    const previous = state;
    const next: FollowState = isFollowing
      ? "none"
      : actor.manuallyApprovesFollowers
        ? "requested"
        : "following";

    commitState(next);
    setPending(true);

    try {
      if (!viewer) {
        return;
      }

      if (isFollowing) {
        const follow =
          followActivityRef.current ?? createFollowActivity({ actor: viewer, object: actor });
        const result = await onUnfollow?.(createUndoActivity({ actor: viewer, activity: follow }));

        followActivityRef.current = null;

        if (result === false) {
          commitState(previous);
        }

        return;
      }

      const follow = createFollowActivity({ actor: viewer, object: actor });

      followActivityRef.current = follow;

      const result = await onFollow?.(follow);

      if (result === false) {
        followActivityRef.current = null;
        commitState(previous);
      }
    } finally {
      setPending(false);
    }
  }, [actor, commitState, isFollowing, onFollow, onUnfollow, pending, state, viewer]);

  const showUnfollowIntent = showUnfollowOnHover && isFollowing && intentVisible;
  const label = showUnfollowIntent ? "Unfollow" : stateLabels[state];
  const Icon = showUnfollowIntent
    ? IconUserMinus
    : state === "following"
      ? IconCheck
      : state === "requested"
        ? IconClock
        : IconPlus;

  return (
    <Button
      type="button"
      size={size}
      variant={isFollowing ? "outline" : "default"}
      aria-pressed={isFollowing}
      aria-label={`${label} ${getActorDisplayName(actor)}`}
      disabled={disabled || pending}
      className={className}
      onClick={() => void handleClick()}
      onMouseEnter={() => setIntentVisible(true)}
      onMouseLeave={() => setIntentVisible(false)}
      onFocus={() => setIntentVisible(true)}
      onBlur={() => setIntentVisible(false)}
      {...props}
    >
      <Icon aria-hidden="true" />
      {label}
    </Button>
  );
}

export { FollowButton, type FollowButtonProps, type FollowState };
```



## Usage

Emits real activities rather than calling an opaque callback: `onFollow` receives a `Follow` and
`onUnfollow` receives an `Undo` wrapping the exact `Follow` that was sent, which is what an
ActivityPub server needs to retract the relationship.

```tsx
import { FollowButton } from "@/components/ui/follow-button";

<FollowButton
  actor={actor}
  viewer={viewer}
  onFollow={(activity) => postToOutbox(viewer, activity)}
  onUnfollow={(activity) => postToOutbox(viewer, activity)}
/>
```

### Optimistic state

The label changes immediately. Return `false` from either handler to roll it back:

```tsx
<FollowButton
  actor={actor}
  viewer={viewer}
  onFollow={async (activity) => {
    const response = await postToOutbox(viewer, activity);
    return response.ok;
  }}
/>
```

### Follow requests

Actors with `manuallyApprovesFollowers` go to `requested` instead of `following`, matching the
server's pending `Accept`. Once the `Accept` arrives, drive the button as controlled:

```tsx
<FollowButton actor={actor} viewer={viewer} state={relationship} onStateChange={setRelationship} />
```

Hovering or focusing a followed actor swaps the label to "Unfollow"; pass
`showUnfollowOnHover={false}` to keep it static. `aria-pressed` always reflects the relationship.

