UIInteractive Buttons and Controlsmotion/react

LikeButton

A like toggle whose heart squashes and pops as it fills, firing a shockwave ring and a burst of particles while the counter rolls to the next number. Works as a pill with a count, a ghost button, or a bare icon.

Preview

Customize

10 props
128
12

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add like-button

Writes components/ui/like-button.tsx and components/styles/like-button.module.css.

Usage

Updates live as you change the controls — only props that differ from the defaults are included.

example.tsx
1import { LikeButton } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <LikeButton />6  );7}

Props

PropTypeDefault
liked

Controlled liked state.

booleanundefined
defaultLiked

Initial liked state when uncontrolled.

booleanfalse
onLikedChange

Called with the next liked state.

(liked: boolean) => voidundefined
count

Like count excluding the current user's like; the displayed number adds 1 while liked.

number128
showCount

Show the rolling counter (ignored for the icon variant).

booleantrue
label

Accessible label of the button.

string'Like'
icon

Icon shape.

'heart' | 'star' | 'thumb''heart'
variant

Bordered pill, borderless ghost, or round icon-only button.

'pill' | 'ghost' | 'icon''pill'
size

Size preset.

'sm' | 'md' | 'lg''md'
accentColor

Color of the filled icon, ring and particles.

string'#f43f5e'
particleCount

Number of burst particles (0 disables the burst, max 32).

number12
disabled

Disable the button.

booleanfalse
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

Accessibility

Native toggle <button> with aria-pressed. Its aria-label combines `label` and the current count (e.g. "Like, 129"); the visual icon, counter and particles are aria-hidden. Particles use a seeded layout, so server and client markup match. Under prefers-reduced-motion the pop, burst and rolling digits are replaced by an instant fill and a cross-fade.

Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.

Source

components/ui/like-button.tsx
1"use client";2 3import * as React from "react";4import { AnimatePresence, motion, useReducedMotion } from "motion/react";5import styles from "../styles/like-button.module.css";6 7export interface LikeButtonProps {8  /** Controlled liked state */9  liked?: boolean;10  /** Initial liked state when uncontrolled */11  defaultLiked?: boolean;12  /** Called with the next liked state */13  onLikedChange?: (liked: boolean) => void;14  /** Like count excluding the current user's like */15  count?: number;16  /** Show the rolling counter */17  showCount?: boolean;18  /** Accessible label of the button */19  label?: string;20  /** Icon shape */21  icon?: "heart" | "star" | "thumb";22  /** Visual style */23  variant?: "pill" | "ghost" | "icon";24  /** Size preset */25  size?: "sm" | "md" | "lg";26  /** Color of the filled icon and particles */27  accentColor?: string;28  /** Number of burst particles (0 disables the burst) */29  particleCount?: number;30  /** Disable the button */31  disabled?: boolean;32  /** Optional className merged onto the root */33  className?: string;34  /** Optional inline styles merged onto the root */35  style?: React.CSSProperties;36}37 38const ICONS = {39  heart: "M19 14c1.5-1.5 3-3.2 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.8 0-3 .5-4.5 2-1.5-1.5-2.7-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4 3 5.5l7 7z",40  star: "M12 2.5l2.9 6 6.6.9-4.8 4.6 1.2 6.5L12 17.4l-5.9 3.1 1.2-6.5-4.8-4.6 6.6-.9z",41  thumb: "M7 10v11M2 12v7a2 2 0 0 0 2 2h12.4a2 2 0 0 0 2-1.6l1.4-7A2 2 0 0 0 17.8 10H14V5a3 3 0 0 0-3-3l-4 8H4a2 2 0 0 0-2 2z",42} as const;43 44const rand = (n: number) => {45  const x = Math.sin(n * 127.1 + 311.7) * 43758.5453;46  return x - Math.floor(x);47};48 49const fmt = (n: number) => (n >= 10000 ? `${(n / 1000).toFixed(n >= 100000 ? 0 : 1)}k` : n.toLocaleString("en-US"));50 51export function LikeButton({52  liked: likedProp,53  defaultLiked = false,54  onLikedChange,55  count = 128,56  showCount = true,57  label = "Like",58  icon = "heart",59  variant = "pill",60  size = "md",61  accentColor = "#f43f5e",62  particleCount = 12,63  disabled = false,64  className = "",65  style,66}: LikeButtonProps) {67  const reduce = useReducedMotion();68  const [inner, setInner] = React.useState(defaultLiked);69  const liked = likedProp ?? inner;70  const [burst, setBurst] = React.useState(0);71  const total = Math.max(0, count) + (liked ? 1 : 0);72  const withCount = showCount && variant !== "icon";73 74  const toggle = () => {75    if (disabled) return;76    const next = !liked;77    if (likedProp === undefined) setInner(next);78    if (next) setBurst((b) => b + 1);79    onLikedChange?.(next);80  };81 82  const n = Math.max(0, Math.min(32, Math.round(particleCount)));83  const particles = Array.from({ length: n }, (_, i) => {84    const angle = (i / Math.max(1, n)) * Math.PI * 2 + rand(i) * 0.4;85    const dist = 22 + rand(i + 50) * 14;86    return { x: Math.cos(angle) * dist, y: Math.sin(angle) * dist, s: 3 + rand(i + 99) * 3, alt: i % 3 === 0 };87  });88 89  return (90    <button91      type="button"92      aria-pressed={liked}93      aria-label={withCount ? `${label}, ${total.toLocaleString("en-US")}` : label}94      disabled={disabled}95      onClick={toggle}96      data-liked={liked || undefined}97      className={[styles.root, styles[variant], styles[size], className].filter(Boolean).join(" ")}98      style={{ "--mf-lb-accent": accentColor, ...style } as React.CSSProperties}99    >100      <span className={styles.iconWrap} aria-hidden="true">101        {!reduce && burst > 0 && liked ? (102          <span key={`b-${burst}`} className={styles.burst}>103            <motion.span104              className={styles.ring}105              initial={{ scale: 0.2, opacity: 0.9 }}106              animate={{ scale: 1.6, opacity: 0 }}107              transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}108            />109            {particles.map((p, i) => (110              <motion.span111                key={i}112                className={`${styles.particle} ${p.alt ? styles.alt : ""}`.trim()}113                style={{ width: p.s, height: p.s, marginLeft: -p.s / 2, marginTop: -p.s / 2 }}114                initial={{ x: 0, y: 0, scale: 0, opacity: 1 }}115                animate={{ x: p.x, y: p.y, scale: [0, 1.2, 0], opacity: [1, 1, 0] }}116                transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1], delay: 0.04 }}117              />118            ))}119          </span>120        ) : null}121        <motion.svg122          viewBox="0 0 24 24"123          strokeWidth="2"124          strokeLinecap="round"125          strokeLinejoin="round"126          initial={false}127          animate={reduce ? { scale: 1 } : liked ? { scale: [1, 0.7, 1.25, 1] } : { scale: [1, 0.85, 1] }}128          transition={{ duration: reduce ? 0 : liked ? 0.45 : 0.25, ease: "easeOut" }}129        >130          <path d={ICONS[icon] ?? ICONS.heart} />131        </motion.svg>132      </span>133      {withCount ? (134        <span className={styles.count} aria-hidden="true">135          <AnimatePresence initial={false} mode="popLayout" custom={liked}>136            <motion.span137              key={total}138              className={styles.num}139              custom={liked}140              variants={{141                enter: (up: boolean) => (reduce ? { opacity: 0 } : { y: up ? "100%" : "-100%", opacity: 0 }),142                center: { y: "0%", opacity: 1 },143                exit: (up: boolean) => (reduce ? { opacity: 0 } : { y: up ? "-100%" : "100%", opacity: 0 }),144              }}145              initial="enter"146              animate="center"147              exit="exit"148              transition={reduce ? { duration: 0.1 } : { type: "spring", stiffness: 500, damping: 32 }}149            >150              {fmt(total)}151            </motion.span>152          </AnimatePresence>153        </span>154      ) : null}155    </button>156  );157}