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…

"use client"

import * as React from "react"
import { cva } from "class-variance-authority"
import { Heart, Star, ThumbsUp } from "lucide-react"
import { cn } from "@/lib/utils"

/**
 * Icon pop, particle burst, halo ring and the two-way count roll.
 * React 19 hoisted <style> — dedupes by href, no Tailwind config edits.
 */
const KEYFRAMES = `@keyframes lb-pop{0%{transform:scale(1)}35%{transform:scale(0.82)}70%{transform:scale(1.22)}100%{transform:scale(1)}}
@keyframes lb-burst{from{transform:translate(-50%,-50%) scale(1);opacity:1}to{transform:translate(calc(-50% + var(--lb-x)),calc(-50% + var(--lb-y))) scale(0);opacity:0}}
@keyframes lb-ring{from{transform:translate(-50%,-50%) scale(0.35);opacity:0.55}to{transform:translate(-50%,-50%) scale(2.2);opacity:0}}

Installation

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

Prompt

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 countcount is rendered verbatim rather than derived from liked, 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 onAnimationEnd and the outgoing digits clear the roll the same way; no timers means unmounting mid-animation leaks nothing.
  • Reduced-motion honesty — under prefers-reduced-motion the 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 statearia-pressed is 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.

On This Page