Buttons
Like Button
A social toggle that fills its icon, bursts a ring of particles and rolls the counter on every tap.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/like-button.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "LikeButton" component using lucide-react
icons and class-variance-authority (cva) for sizing.
Contract
- Export a forwardRef <button> extending React.ButtonHTMLAttributes plus:
liked?: boolean controlled state; when present the button never
flips itself, it only reports intent
defaultLiked?: boolean uncontrolled seed (default false)
onLikedChange?: (liked: boolean) => void
count?: number rendered verbatim; omit it for an icon-only button
size?: "sm" | "md" | "lg" (default "md")
icon?: "heart" | "star" | "thumbs-up" (default "heart")
- The count is NOT derived: the consumer owns it and applies the optimistic ±1
in onLikedChange, so a server refresh can correct it at any time and the
digits will still roll.
- Consumer onClick still fires; internal toggle logic runs after it.
Behavior
- Activation flips the state (internally when uncontrolled), calls
onLikedChange, and triggers three independent decorations:
1. icon pop — a 420ms squash-then-overshoot keyframe on a wrapper that is
re-keyed by an incrementing counter so it restarts on every tap;
2. particle burst — only when turning ON. Eight particles at fixed, evenly
spaced angles (no Math.random, so nothing depends on render timing) plus a
halo ring that scales 0.35→2.2 and fades. The ring is the longest-running
layer and owns cleanup: it clears the burst in onAnimationEnd, never a
timer, so unmounting mid-burst leaks nothing;
3. count roll — an effect watches `count`. On any change it records the old
value and the direction; the old digits translate out (up when the number
grew, down when it shrank) while the new digits slide in from the opposite
side inside an overflow-hidden box. The outgoing layer removes itself in
onAnimationEnd.
- prefers-reduced-motion: no burst is spawned, no pop counter is bumped, and the
roll effect returns early so the number swaps instantly. The pressed state,
the count and the accessible name behave identically.
- Icons stroke-only when off, fill-current when on, with a colour transition.
Rendering & styling
- Semantic tokens only: bg-primary/10 + text-primary for the active pill,
text-muted-foreground → hover:bg-muted hover:text-foreground when idle,
bg-primary / border-primary for particles and ring, ring-ring for focus.
No hardcoded colours — light and dark both come free.
- Ship the seven @keyframes inside the component through a React 19 hoisted
<style href="..." precedence="medium"> tag; duplicate buttons dedupe to one.
- Accessibility: aria-pressed carries the state, so the accessible name must NOT
flip with it — a name that reads "Unlike" while aria-pressed is true announces
two contradictory things. Keep one stable per-icon verb and fold the count into
it ("Like, 129 likes" / "Star" / "Upvote, 1284 upvotes"). The visible count is
aria-hidden so it is not announced twice. Decorative layers are aria-hidden and
pointer-events-none. focus-visible ring, disabled:opacity-50.
- cva drives size (height, gap, padding, text size); a parallel record maps the
same key to the icon size so they scale together.
Customization levers
- Verb set: extend the icon record with another lucide glyph plus its
verb/noun wording — the contract and every animation stay untouched.
- Burst intensity: particle count, the alternating travel distances and the
ring's end scale. Keep particle travel below the ring radius or the ring
reads as a second, unrelated effect.
- Roll speed: the 260ms in/out pair; keep both identical or the two digit
layers cross-fade unevenly.
- Colour role: swap bg-primary/10 + text-primary for a chart token
(var(--chart-1)) if likes should read warm rather than brand-coloured.
- Density: add or drop a cva size row; the icon-size record is the only other
place that needs the new key.Concepts
- Controlled or not, one path — the button computes the next value once, writes it to internal state only when uncontrolled, and always reports it through
onLikedChange; call sites can adopt server state later without touching markup. - Optimistic count —
countis rendered verbatim rather than derived fromliked, so the UI can jump ahead of the network and a later server value simply re-triggers the roll instead of fighting the component. - Digit roll — a value change is treated as a direction, not just a number: the outgoing digits leave the way the value moved and the incoming digits arrive from the opposite edge, which is what makes an increment feel different from a correction.
- Deterministic burst — particle angles are fixed at authoring time instead of randomised, so the celebration looks identical on every tap and never depends on render timing.
- Animation owns its own cleanup — the longest layer (the halo ring) clears the burst in
onAnimationEndand the outgoing digits clear the roll the same way; no timers means unmounting mid-animation leaks nothing. - Reduced-motion honesty — under
prefers-reduced-motionthe component skips spawning decorations entirely rather than hiding them after the fact, so the state change, the count and the announcement all still land. - Stable name, moving state —
aria-pressedis the state channel, so the accessible name stays one verb ("Like, 129 likes") instead of flipping to "Unlike"; a name that describes the next action while the state says "pressed" makes the two contradict each other.