Buttons

Follow Button

A follow / unfollow toggle with the hover-to-unfollow reveal, an optimistic follower count that rolls back when the request is refused, and a busy state that never uses native disabled.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, Loader2, UserMinus, UserPlus, type LucideIcon } from "lucide-react"
import { cn } from "@/lib/utils"

/** How long an outcome or a refusal stays announced, and tinted, before it clears itself. */
const FEEDBACK_MS = 4000

export type FollowButtonSize = "sm" | "md" | "lg"

const SIZES: Record<FollowButtonSize, { gap: string; icon: string; root: string }> = {
  sm: { gap: "gap-1.5", icon: "size-3.5", root: "h-8 px-3 text-xs" },
  md: { gap: "gap-2", icon: "size-4", root: "h-9 px-4 text-sm" },

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/follow-button.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "FollowButton" component using lucide-react
icons (UserPlus / Check / UserMinus / Loader2). No other runtime dependency.

Contract
- forwardRef<HTMLButtonElement, FollowButtonProps> where FollowButtonProps extends
  React.ButtonHTMLAttributes<HTMLButtonElement>. It returns a fragment: the
  <button>, plus an sr-only role="status" aria-live="polite" span as its sibling.
  Merge className with cn(), spread the remaining native props on the button,
  default type="button".
- Props (defaults in brackets):
  accountName: string          required; the middle of every sentence the button says
  following?: boolean          controlled state; when set the button never flips itself
  defaultFollowing?: boolean   uncontrolled seed [false]
  onFollowChange?: (next: boolean) => void | PromiseLike<unknown>
  onFollowError?: (error: unknown) => void
  count?: number               follower count as YOUR source of truth reports it
  compactCount?: boolean       1.2M instead of 1,204,318 [false]
  locale?: string              ["en"] — explicit on purpose, see below
  size?: "sm" | "md" | "lg"    ["md"]
  disabled?: boolean           [false] aria-disabled + handler guard, never the attribute
  disabledReason?: string      the sentence a refused activation gets back
  followLabel / followingLabel / unfollowLabel  ["Follow" / "Following" / "Unfollow"]
- The count belongs to the app, exactly as it does in a like button: the button
  never mutates it, it only adds the optimistic ±1 for as long as a request is in
  flight. Move your own number when the request succeeds.
- onFollowChange returning nothing means the flip is the whole transaction.
  Returning a promise buys the optimistic treatment: flip now, roll back if it rejects.

Behavior
- Activation order: call the consumer's onClick first, then run the machine.
- Refusal (disabled): announce disabledReason, paint the destructive ring, fire
  no request, keep the tab stop. Never set the native disabled attribute — the
  browser blurs a node the instant it is disabled and this control goes inert
  underneath the user on every press.
- One shot per transition: an `inFlight` ref is read AND written in the same
  synchronous turn as the click. A double click, an auto-repeating Enter and a
  click landing before React re-renders all arrive while it is already true, and
  state would be a frame too late to stop the second request.
- Optimistic flip: store the in-flight target as `flight = { next }`. The rendered
  state is `flight ? flight.next : committed`, so aria-pressed, the label and the
  count all move at once. On resolve, commit (internal state when uncontrolled)
  and clear the flight. On reject, clear the flight only — that single setState is
  the whole rollback, because nothing derived was stored.
- Count arithmetic: rendered = max(0, count + (flight ? (flight.next ? 1 : −1) :
  0)). Nothing derived is stored, so the rollback is the same single setState as
  the state rollback, and a server number arriving on success REPLACES the
  optimistic ±1 instead of stacking on top of it — the classic double-count bug
  (2,048 → optimistic 2,049 → server says 2,051 → screen shows 2,052) cannot
  happen here. Clamp at 0: a follower count is never negative, whatever
  arithmetic the consumer hands in.
- Controlled mode: apply your state update — the flag AND the count — before
  resolving the promise (`await api(next); setFollowing(next); setCount(n)`), so
  clearing the flight and adopting the new props land in the same React batch.
  Resolve first and the button visibly snaps back until your state catches up.
- Keyboard: it is a real <button>, so Tab focuses it and Enter / Space activate
  it; auto-repeat is absorbed by the one-shot ref. Nothing unmounts and focus
  never moves, so there is no successor to hand focus to.
- Screen reader contract: aria-pressed carries the state, so the accessible name
  stays ONE stable verb ("Follow Ada Lovelace, 8,421 followers") — a name reading
  "Unfollow" while aria-pressed is true announces two contradictory things. The
  spoken count is always the exact Intl number, never the compact one ("1.2M" is
  read out as one point two M). aria-busy while in flight; aria-disabled when
  disabled or busy. Every visual layer, including the count, is aria-hidden.
- Announcements go to the sr-only live region: "Following X." / "No longer
  following X." / "Could not follow X. <detail>", where detail is error.message
  or "Please try again.". The region clears itself after 4s, which is what lets an
  identical refusal be announced a second time instead of being swallowed as a
  no-change.
- Cleanup: the feedback timeout lives in a ref and is cleared on unmount together
  with a mounted flag that every promise callback checks before touching state.
  onFollowError is called BEFORE that check — the toast belongs to the app, not to
  whichever row happened to still be mounted.
- Edge cases: no count prop (the follower clause drops out of the name); compact
  rounding (one follower may not move the visible digits, the spoken number still
  does); the count clamped at 0; a press while busy (dropped silently, aria-busy
  already says why); disabled flipped mid-flight (the request already left, so it
  still commits).

Rendering & styling
- Semantic tokens only: bg-primary / text-primary-foreground for the un-followed
  state; border + bg-background + text-foreground once following; the hover and
  focus-visible reveal swaps in border-destructive/40 + bg-destructive/10 +
  text-destructive; refusals paint ring-destructive/60; the count is
  text-muted-foreground (following) or text-primary-foreground/80 (primary fill);
  focus-visible:ring-ring with ring-offset-background. No hardcoded colours, so
  dark mode is free.
- Width stability: the three labels (Follow / Following / Unfollow) sit in ONE
  inline-grid cell (col-start-1 row-start-1) and are toggled with opacity, so the
  button is always as wide as its widest word and the hover swap cannot reflow the
  row it lives in. The un-followed fill carries border-transparent as a placeholder
  for the border the following state paints, so following cannot widen it either.
- The reveal is pure CSS on a named group: group-hover/follow and
  group-focus-visible/follow. Keyboard users get the same discoverable exit as
  mouse users. Suppress it while busy or disabled — revealing an action that the
  handler would drop is a lie.
- While busy, every layer's leading icon becomes Loader2 with animate-spin plus
  motion-reduce:animate-none; the opacity transitions carry
  motion-reduce:transition-none. Under reduced motion the state, the count and the
  announcement all still land — only the decoration stops.
- locale is a prop with an explicit default rather than the runtime default,
  because an implicit locale formats differently on the server and in the browser
  and hydration then mismatches.

Customization levers
- Wording: followLabel / followingLabel / unfollowLabel take any string; pass
  aria-label to replace the whole accessible sentence when you localize (native
  props are spread last, so it wins).
- Exit affordance: drop the unfollow layer entirely for a softer product, or swap
  its tone from destructive to muted — the state machine does not care which
  layer is visible.
- Density: the SIZES record is three rows of height / padding / text size / gap /
  icon size; add a row for an xs pill and nothing else needs to change.
- Colour role: the un-followed fill is bg-primary; a chart token
  (var(--chart-1)) reads warmer if follow should not look like your main CTA.
- Feedback dwell: FEEDBACK_MS (4000) controls both the destructive ring and how
  long a sentence stays in the live region; keep it above ~2s or screen readers
  can miss it.
- Confirmation: for a heavier unfollow, wrap the button in an AlertDialog trigger
  and call onFollowChange from the confirm action — do not add a second mode here.

Concepts

  • Optimistic flip with a one-setState rollback — the in-flight target is the only thing stored; the pressed state, the label and the count are all derived from it, so a rejection is undone by dropping it rather than by restoring three remembered values.
  • Optimism with an expiry — the ±1 on the follower count lives exactly as long as the request does, so the number your data layer writes on success replaces it rather than stacking on top of it; the double count that turns 2,048 into 2,052 is unreachable by construction.
  • One shot per transition — the guard is a ref read and written in the same synchronous turn as the click, because a double press, an auto-repeating Enter and a click that beats the next render all arrive before state could have caught up.
  • Discoverable exit — “Following” becomes “Unfollow” in a destructive tone on hover and on keyboard focus, so the way out is found the same way by both, and the three labels share one grid cell so revealing it never resizes the button.
  • Busy is not disabled — a request in flight sets aria-busy and aria-disabled and refuses presses in the handler; the native attribute would blur the button under the user the instant they pressed it.
  • Self-clearing announcement — outcomes and refusals go to a polite live region that empties itself after four seconds, which is the only way an identical refusal announces twice instead of reading as a no-change.

On This Page