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 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add like-buttonWrites 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.
1import { LikeButton } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <LikeButton />6 );7}Props
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
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}