PulseHeart
Like button that contracts to a dot, flips colour at its smallest frame and pulses back while the count swaps one glyph.
Live preview
Loading demo…
Installation
npx shadcn@latest add https://reactbits.dev/r/PulseHeart-TS-TW.json
Dependencies: @hugeicons/core-free-icons
Source
'use client';
import React, { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import { FavouriteIcon, StarIcon, ThumbsUpIcon } from '@hugeicons/core-free-icons';
export type PulseHeartIcon = 'heart' | 'star' | 'thumb';
export interface PulseHeartProps {
liked?: boolean;
defaultLiked?: boolean;
count?: number;
onChange?: (liked: boolean, count: number) => void;
showCount?: boolean;
icon?: PulseHeartIcon | ReactNode;
idleOutline?: boolean;
size?: number;
corner?: number;
likedColor?: string;
idleColor?: string;
pillColor?: string;
textColor?: string;
duration?: number;
dotSize?: number;
overshoot?: number;
beat?: number;
rollDuration?: number;
disabled?: boolean;
label?: string;
className?: string;
}
type IconData = readonly (readonly [string, { readonly [key: string]: string | number }])[];
type Roll = { a: string; b: string; at: number; up: boolean };
type Cell = { ch: string } | { top: string; bottom: string };
const OUT = 0.4;
const ICONS: Record<PulseHeartIcon, IconData> = { heart: FavouriteIcon, star: StarIcon, thumb: ThumbsUpIcon };
const back = (k: number, c: number) => {
const u = k - 1;
return 1 + (c + 1) * u ** 3 + c * u ** 2;
};
const swellOf = (t: number, c: number) =>
t <= 0 ? 0 : t < OUT ? 1 - (1 - t / OUT) ** 3 : 1 - back((t - OUT) / (1 - OUT), c);
const format = (n: number) => new Intl.NumberFormat().format(n);
const reducedMotion = () =>
typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
const PulseHeart: React.FC<PulseHeartProps> = ({
liked: likedProp,
defaultLiked = false,
count = 0,
onChange,
showCount = true,
icon = 'heart',
idleOutline = true,
size = 40,
corner = 32,
likedColor = '#ff4d6d',
idleColor = '#8b8b93',
pillColor = '#232326',
textColor = '#f5f5f5',
duration = 560,
dotSize = 0.3,
overshoot = 1.7,
beat = 3,
rollDuration = 350,
disabled = false,
label = 'Like',
className = ''
}) => {
const controlled = likedProp !== undefined;
const [inner, setInner] = useState(defaultLiked);
const [total, setTotal] = useState(count);
const liked = controlled ? likedProp : inner;
const [shown, setShown] = useState({ liked, count });
const [roll, setRoll] = useState<Roll | null>(null);
const rootRef = useRef<HTMLButtonElement>(null);
const pillRef = useRef<HTMLSpanElement>(null);
const heartRef = useRef<HTMLSpanElement>(null);
const glyphRef = useRef<SVGGElement>(null);
const rollRef = useRef<HTMLSpanElement>(null);
const raf = useRef(0);
const rollTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const viaPointer = useRef(false);
const shownRef = useRef(shown);
const logical = useRef({ liked, count: total });
logical.current = { liked, count: total };
const cfg = useRef({ duration, dotSize, overshoot, beat, rollDuration });
cfg.current = { duration, dotSize, overshoot, beat, rollDuration };
useEffect(() => {
setTotal(count);
}, [count]);
useEffect(() => {
if (raf.current) return;
if (shownRef.current.liked === liked && shownRef.current.count === total) return;
shownRef.current = { liked, count: total };
setShown(shownRef.current);
}, [liked, total]);
useLayoutEffect(() => {
const root = rootRef.current;
if (!root || root.dataset.instant === undefined) return;
root.getBoundingClientRect();
delete root.dataset.instant;
}, [shown]);
useLayoutEffect(() => {
const el = rollRef.current;
if (!el || !roll) return;
el.style.transition = 'none';
el.style.transform = `translateY(${roll.up ? '0' : '-1em'})`;
el.getBoundingClientRect();
el.style.transition = '';
el.style.transform = `translateY(${roll.up ? '-1em' : '0'})`;
}, [roll]);
useEffect(
() => () => {
cancelAnimationFrame(raf.current);
clearTimeout(rollTimer.current);
},
[]
);
const startRoll = (from: number, to: number) => {
if (from === to) return;
const a = format(from);
const b = format(to);
const changed = a.length === b.length ? [...b].flatMap((ch, i) => (ch !== a[i] ? [i] : [])) : [];
setRoll({ a, b, at: changed.length === 1 ? changed[0] : -1, up: to > from });
clearTimeout(rollTimer.current);
rollTimer.current = setTimeout(() => setRoll(null), cfg.current.rollDuration);
};
const run = (nextLiked: boolean, nextCount: number) => {
const root = rootRef.current;
const heart = heartRef.current;
const pill = pillRef.current;
if (!root || !heart || !pill) return;
const glyph = glyphRef.current;
root.dataset.running = '';
let swapped = false;
let prev = 0;
const t0 = performance.now();
const tick = (now: number) => {
const { duration: D, dotSize: dot, overshoot: c, beat: B } = cfg.current;
const t = Math.min(1, (now - t0) / D);
const step = prev ? now - prev : 1000 / 60;
prev = now;
const s = swellOf(t, c);
const k = 1 - (1 - dot) * s;
if (glyph) glyph.setAttribute('transform', `translate(12 12) scale(${k}) translate(-12 -12)`);
else heart.style.transform = `scale(${k})`;
pill.style.transform = `scale(${1 - (B / 100) * s})`;
if (!swapped && t + step / 2 / D >= OUT) {
swapped = true;
root.dataset.liked = String(nextLiked);
startRoll(shownRef.current.count, nextCount);
shownRef.current = { liked: nextLiked, count: nextCount };
setShown(shownRef.current);
}
if (t < 1) {
raf.current = requestAnimationFrame(tick);
return;
}
raf.current = 0;
if (glyph) glyph.removeAttribute('transform');
heart.style.transform = '';
pill.style.transform = '';
delete root.dataset.running;
const l = logical.current;
if (l.liked !== shownRef.current.liked || l.count !== shownRef.current.count) {
shownRef.current = { liked: l.liked, count: l.count };
setShown(shownRef.current);
}
};
raf.current = requestAnimationFrame(tick);
};
const handlePointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
if (e.button !== 0 || disabled) return;
viaPointer.current = true;
if (!reducedMotion() && rootRef.current) rootRef.current.dataset.pressed = '';
};
const handlePointerUp = () => {
if (rootRef.current) delete rootRef.current.dataset.pressed;
};
const handlePointerCancel = () => {
viaPointer.current = false;
handlePointerUp();
};
const handleKeyDown = () => {
viaPointer.current = false;
};
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
if (disabled || raf.current) return;
const pointer = viaPointer.current && e.detail !== 0;
viaPointer.current = false;
const nextLiked = !liked;
const nextCount = total + (nextLiked ? 1 : -1);
if (!controlled) setInner(nextLiked);
setTotal(nextCount);
onChange?.(nextLiked, nextCount);
if (pointer && !reducedMotion()) run(nextLiked, nextCount);
else if (rootRef.current) rootRef.current.dataset.instant = '';
};
const paths = typeof icon === 'string' ? ICONS[icon as PulseHeartIcon] || ICONS.heart : null;
const text = format(shown.count);
const cells: Cell[] = roll
? roll.at === -1
? [{ top: roll.up ? roll.a : roll.b, bottom: roll.up ? roll.b : roll.a }]
: [...roll.b].map((ch, i) =>
i === roll.at ? { top: roll.up ? roll.a[i] : ch, bottom: roll.up ? ch : roll.a[i] } : { ch }
)
: [...text].map(ch => ({ ch }));
return (
<button
ref={rootRef}
type="button"
aria-pressed={liked}
disabled={disabled}
data-liked={String(shown.liked)}
data-solid={idleOutline ? undefined : ''}
data-no-count={showCount ? undefined : ''}
className={`group relative m-0 inline-flex cursor-pointer touch-manipulation select-none items-center justify-center border-0 bg-transparent p-0 outline-none [font-family:inherit] [color:var(--ph-text)] rounded-[var(--ph-corner)] [-webkit-tap-highlight-color:transparent] [-webkit-touch-callout:none] [transition:transform_160ms_var(--ph-ease-out)] data-[pressed]:[transform:scale(0.97)] focus-visible:outline-offset-[3px] focus-visible:[outline:2px_solid_color-mix(in_srgb,var(--ph-liked)_60%,transparent)] disabled:cursor-default disabled:opacity-[0.55] motion-reduce:transition-none [@media(pointer:coarse)]:min-h-11 [@media(pointer:coarse)]:min-w-11${className ? ` ${className}` : ''}`}
style={
{
'--ph-size': `${size}px`,
'--ph-corner': `${corner}px`,
'--ph-pill': pillColor,
'--ph-idle': idleColor,
'--ph-liked': likedColor,
'--ph-text': textColor,
'--ph-roll': `${rollDuration}ms`,
'--ph-stroke': `${(1.5 * size) / 24}px`,
'--ph-ease-out': 'cubic-bezier(0.23, 1, 0.32, 1)'
} as CSSProperties
}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
onPointerCancel={handlePointerCancel}
onKeyDown={handleKeyDown}
onClick={handleClick}
>
<span
ref={pillRef}
className="inline-flex origin-center items-center [gap:calc(var(--ph-size)*0.25)] [padding:calc(var(--ph-size)*0.3)] [padding-inline-end:calc(var(--ph-size)*0.4)] group-data-[no-count]:[padding-inline-end:calc(var(--ph-size)*0.3)] rounded-[var(--ph-corner)] [background:var(--ph-pill)] [@media(prefers-contrast:more)]:[box-shadow:inset_0_0_0_1px_color-mix(in_srgb,var(--ph-text)_25%,transparent)]"
>
<span
ref={heartRef}
className="block h-[var(--ph-size)] w-[var(--ph-size)] origin-center [color:var(--ph-idle)] [transition:color_160ms_ease] group-data-[liked=true]:[color:var(--ph-liked)] group-data-[running]:transition-none group-data-[instant]:transition-none motion-reduce:[transition:color_200ms_ease] [&_svg]:block [&_svg]:h-full [&_svg]:w-full [&_svg]:overflow-visible [&_svg]:fill-none [&_svg]:stroke-current [&_svg]:[stroke-width:var(--ph-stroke)] [&_svg]:[stroke-linecap:round] [&_svg]:[stroke-linejoin:round] group-data-[liked=true]:[&_svg]:fill-current group-data-[solid]:[&_svg]:fill-current [@media(hover:hover)_and_(pointer:fine)]:group-enabled:group-hover:group-data-[liked=false]:[color:color-mix(in_srgb,var(--ph-idle)_45%,var(--ph-liked))]"
aria-hidden="true"
>
{paths ? (
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g ref={glyphRef}>
{paths.map(([, attrs]) => (
<path key={String(attrs.key)} d={String(attrs.d)} vectorEffect="non-scaling-stroke" />
))}
</g>
</svg>
) : (
icon
)}
</span>
{showCount ? (
<span
className="inline-flex font-semibold leading-none tracking-[-0.1px] tabular-nums [font-size:calc(var(--ph-size)*0.5)] [color:var(--ph-text)] [&>span]:block [&>span]:h-[1em]"
aria-hidden="true"
>
{cells.map((cell, i) =>
'ch' in cell ? (
<span key={`c${i}`}>{cell.ch}</span>
) : (
<span key={`r${i}`} className="relative overflow-hidden">
<span
ref={rollRef}
className="flex flex-col [transition:transform_var(--ph-roll)_var(--ph-ease-out)] motion-reduce:transition-none [&>span]:block [&>span]:h-[1em] [&>span]:leading-none"
>
<span>{cell.top}</span>
<span>{cell.bottom}</span>
</span>
</span>
)
)}
</span>
) : null}
<span className="sr-only">{showCount ? `${label}, ${format(total)}` : label}</span>
</span>
</button>
);
};
export default PulseHeart;