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…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/follow-button.jsonPrompt
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.
Print Button
A print trigger that prepares the page before the dialog opens — a cancellable async hook, a print scope class, and a restore raced between afterprint, the print media query and a timeout.
Vote Buttons
An up and down vote pair with the score between them — exclusive directions, optimistic score maths where a swap is worth two points, and a rollback when the server refuses.