ericts
uilikesource: ok · hit
Like
A composable heart toggle with controlled and uncontrolled state, a center-out fill transition, and a miniature-heart scatter.
npx shadcn add "https://ui.ericts.com/r/like.json"free
Where it goes
stats
Interaction
animated-counter
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightui-primitives@base-ui/react
iconslucide-react
Files2 · source cached 10/14/2026, 7:55:34 PM
registry/base/ui/like.tsx
"use client";
import * as React from "react";
import { Button as ButtonPrimitive } from "@base-ui/react/button";
import { HeartIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import "./like.css";
/** Keep in sync with the nth-child choreography in like.css. */
const LIKE_PARTICLE_COUNT = 8;
type ButtonProps = React.ComponentProps<typeof ButtonPrimitive>;
type LikeClickEvent = Parameters<NonNullable<ButtonProps["onClick"]>>[0];
export type LikeProps = Omit<
ButtonProps,
"aria-pressed" | "children" | "duration" | "onClick" | "size"
> & {
/** Controlled liked state. */
liked?: boolean;
/** Initial liked state for uncontrolled usage. */
defaultLiked?: boolean;
/** Called when the control requests a liked-state change. */
onLikedChange?: (liked: boolean) => void;
/** Called before the liked state changes. Prevent default to cancel the change. */
onClick?: (event: LikeClickEvent) => void;
/** Accessible action label while unliked. Applied only when icon-only. */
likeLabel?: string;
/** Accessible action label while liked. Applied only when icon-only. */
unlikeLabel?: string;
/** Optional content placed after the heart. */
children?: React.ReactNode;
/**
* Heart size. Numbers are interpreted as pixels; CSS lengths are also
* accepted. Defaults to 32px via the `--like-size` custom property, which
* can also be set in CSS.
*/
iconSize?: number | string;
/**
* Base particle travel duration in milliseconds. Defaults to 350ms via the
* `--like-duration` custom property, which can also be set in CSS.
*/
duration?: number;
/**
* CSS `<position>` the fill circle grows from, relative to the heart, e.g.
* `"50% 100%"` or `"left top"`. Defaults to the heart's visual center
* (`50% 45%`) via the `--like-fill-origin` custom property, which can also
* be set in CSS.
*/
fillOrigin?: string;
/** Classes applied to the heart and scatter wrapper. */
iconClassName?: string;
};
type LikeStyle = React.CSSProperties & {
"--like-duration"?: string;
"--like-fill-origin"?: string;
"--like-size"?: string;
};
export function Like({
liked,
defaultLiked = false,
onLikedChange,
onClick,
likeLabel = "Like",
unlikeLabel = "Unlike",
children,
iconSize,
duration,
fillOrigin,
className,
iconClassName,
disabled,
ref,
style,
...props
}: LikeProps) {
const [internalLiked, setInternalLiked] = React.useState(defaultLiked);
const controlled = liked !== undefined;
const isLiked = controlled ? liked : internalLiked;
const previousLikedRef = React.useRef(isLiked);
const likeIntentRef = React.useRef(false);
const [scatterId, setScatterId] = React.useState(0);
const [scatterActive, setScatterActive] = React.useState(false);
const settledParticlesRef = React.useRef(0);
const likeStyle: LikeStyle = { ...style };
if (duration !== undefined) {
likeStyle["--like-duration"] = `${duration}ms`;
}
if (fillOrigin !== undefined) {
likeStyle["--like-fill-origin"] = fillOrigin;
}
if (iconSize !== undefined) {
likeStyle["--like-size"] =
typeof iconSize === "number" ? `${iconSize}px` : iconSize;
}
// A scatter only belongs to a liked heart, so unliking mid-scatter drops it.
if (scatterActive && !isLiked) {
setScatterActive(false);
}
// The scatter celebrates user intent: it plays when `isLiked` turns true after
// a click asked for it — immediately for optimistic parents, or once a
// non-optimistic parent commits the result — but never for controlled state
// arriving from a fetch or a realtime update.
React.useEffect(() => {
if (!previousLikedRef.current && isLiked && likeIntentRef.current) {
settledParticlesRef.current = 0;
setScatterId((current) => current + 1);
setScatterActive(true);
}
if (previousLikedRef.current !== isLiked) {
likeIntentRef.current = false;
}
previousLikedRef.current = isLiked;
}, [isLiked]);
const handleClick = React.useCallback(
(event: LikeClickEvent) => {
onClick?.(event);
if (event.defaultPrevented || disabled) {
return;
}
const nextLiked = !isLiked;
if (!controlled) {
setInternalLiked(nextLiked);
}
likeIntentRef.current = nextLiked;
onLikedChange?.(nextLiked);
}, [controlled, disabled, isLiked, onClick, onLikedChange],
);
// The scatter leaves the DOM once every particle has finished, so long-lived
// liked items don't keep 8 idle SVGs mounted.
const handleScatterAnimationEnd = React.useCallback(
(event: React.AnimationEvent<HTMLSpanElement>) => {
if (event.animationName !== "like-particle") return;
settledParticlesRef.current += 1;
if (settledParticlesRef.current >= LIKE_PARTICLE_COUNT) {
setScatterActive(false);
}
},
[],
);
return (
<ButtonPrimitive
ref={ref}
type="button"
disabled={disabled}
// With visible content, the accessible name must stay the visible text
// (WCAG 2.5.3); aria-pressed alone conveys the state. The action label
// only becomes the name when the control is icon-only.
aria-label={children ? undefined : isLiked ? unlikeLabel : likeLabel}
aria-pressed={isLiked}
data-slot="like"
data-liked={isLiked}
data-icon-only={children ? undefined : ""}
className={cn(
"like relative inline-flex shrink-0 items-center justify-center gap-1.5 outline-none select-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50",
children ? "w-fit" : "rounded-full text-muted-foreground",
className,
)}
style={likeStyle}
onClick={handleClick}
{...props}
>
<span
data-slot="like-icon"
data-icon={children ? "inline-start" : undefined}
className={cn("like-icon", iconClassName)}
aria-hidden="true"
>
<HeartIcon
className="like-heart like-heart-fill size-full"
fill="currentColor"
/>
<HeartIcon className="like-heart like-heart-outline size-full" />
{isLiked && scatterActive ? (
<span
key={scatterId}
data-slot="like-scatter"
className="like-scatter"
onAnimationEnd={handleScatterAnimationEnd}
>
{Array.from({ length: LIKE_PARTICLE_COUNT }, (_, index) => (
<span className="like-particle" key={index}>
<HeartIcon
className="like-particle-heart size-full"
fill="currentColor"
/>
</span>
))}
</span>
) : null}
</span>
{children ? <span data-slot="like-content">{children}</span> : null}
</ButtonPrimitive>
);
}
registry/base/ui/like.css
/* Defaults are layered for customization. Normal unlayered styles and
utilities in a later layer can override them. */
@layer components {
.like {
/* Like's heart colour, which Like Button inherits. A dedicated token
rather than --destructive: a like is not an error. */
--like-color: var(--ericts-like);
--like-size: 32px;
--like-duration: 350ms;
/* Where the fill circle grows from. 45% sits on the heart's visual
center: its mass is in the two lobes, not the bottom point. */
--like-fill-origin: 50% 45%;
--like-ease-out: cubic-bezier(0.23, 1, 0.32, 1);
}
.like-icon {
position: relative;
display: inline-flex;
width: var(--like-size);
height: var(--like-size);
flex: none;
font-size: var(--like-size);
transform: scale(1);
transition: transform 160ms var(--like-ease-out);
}
.like[data-icon-only] {
width: max(2rem, calc(var(--like-size) + 0.5rem));
height: max(2rem, calc(var(--like-size) + 0.5rem));
}
.like:active .like-icon {
transform: scale(0.97);
}
.like-heart {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
stroke-width: 1.75;
}
.like-heart-outline {
color: currentColor;
transition: color 150ms var(--like-ease-out);
}
.like-heart-fill {
color: var(--like-color);
clip-path: circle(0% at var(--like-fill-origin));
transition: clip-path 160ms var(--like-ease-out);
}
.like[data-liked="true"] .like-heart-outline {
color: var(--like-color);
transition-delay: 60ms;
}
.like[data-liked="true"] .like-heart-fill {
clip-path: circle(150% at var(--like-fill-origin));
}
.like-scatter {
position: absolute;
inset: 0;
pointer-events: none;
color: var(--like-color);
}
.like-particle {
--like-particle-delay: 0ms;
--like-particle-duration-scale: 1;
position: absolute;
top: 50%;
left: 50%;
width: var(--like-particle-size, 0.25em);
height: var(--like-particle-size, 0.25em);
opacity: 0;
animation: like-particle
calc(var(--like-duration) * var(--like-particle-duration-scale))
linear calc(24ms + var(--like-particle-delay)) both;
}
/* Only 8 particles are choreographed below (LIKE_PARTICLE_COUNT in
like.tsx); anything past them has no travel vector, so hide it. */
.like-particle:nth-child(n + 9) {
display: none;
}
.like-particle-heart {
display: block;
stroke: none;
}
/* Three compact radial waves. Every heart travels the same 1.05em radius. */
.like-particle:nth-child(1) {
--like-particle-size: 0.3em;
--like-particle-x: -0.602em;
--like-particle-y: -0.86em;
--like-particle-rotate: -28deg;
}
.like-particle:nth-child(2) {
--like-particle-size: 0.125em;
--like-particle-x: 0.128em;
--like-particle-y: -1.042em;
--like-particle-rotate: 14deg;
--like-particle-delay: 44ms;
--like-particle-duration-scale: 1.125;
}
.like-particle:nth-child(3) {
--like-particle-size: 0.2em;
--like-particle-x: 0.78em;
--like-particle-y: -0.703em;
--like-particle-rotate: 31deg;
--like-particle-delay: 0ms;
--like-particle-duration-scale: 0.9;
}
.like-particle:nth-child(4) {
--like-particle-size: 0.34em;
--like-particle-x: 1.049em;
--like-particle-y: 0.037em;
--like-particle-rotate: -17deg;
--like-particle-delay: 88ms;
--like-particle-duration-scale: 0.975;
}
.like-particle:nth-child(5) {
--like-particle-size: 0.225em;
--like-particle-x: 0.703em;
--like-particle-y: 0.78em;
--like-particle-rotate: 24deg;
--like-particle-delay: 44ms;
--like-particle-duration-scale: 1.05;
}
.like-particle:nth-child(6) {
--like-particle-size: 0.36em;
--like-particle-x: -0.128em;
--like-particle-y: 1.042em;
--like-particle-rotate: -12deg;
--like-particle-delay: 0ms;
--like-particle-duration-scale: 1.1;
}
.like-particle:nth-child(7) {
--like-particle-size: 0.175em;
--like-particle-x: -0.839em;
--like-particle-y: 0.632em;
--like-particle-rotate: -34deg;
--like-particle-delay: 88ms;
--like-particle-duration-scale: 1.0875;
}
.like-particle:nth-child(8) {
--like-particle-size: 0.125em;
--like-particle-x: -1.047em;
--like-particle-y: -0.073em;
--like-particle-rotate: 18deg;
--like-particle-delay: 44ms;
--like-particle-duration-scale: 0.9375;
}
@keyframes like-particle {
0% {
opacity: 0;
transform: translate(-50%, -50%) rotate(0deg) scale(0.95);
}
8% {
opacity: 1;
}
40% {
opacity: 0.9;
}
70% {
opacity: 0.65;
}
90% {
opacity: 0.25;
}
100% {
opacity: 0;
transform: translate(
calc(-50% + var(--like-particle-x, 0px)),
calc(-50% + var(--like-particle-y, 0px))
)
rotate(var(--like-particle-rotate, 0deg)) scale(0.95);
}
}
@media (hover: hover) and (pointer: fine) {
.like[data-icon-only]:hover .like-heart-outline {
color: var(--foreground);
}
.like[data-icon-only][data-liked="true"]:hover .like-heart-outline {
color: var(--like-color);
}
}
}
/* Motion guards override ordinary customization, including inline timing.
Keep these outside the defaults layer; only motion declarations are important. */
@media (prefers-reduced-motion: reduce) {
.like-icon,
.like:active .like-icon {
transform: none !important;
}
.like-heart-fill {
clip-path: none;
opacity: 0;
transition: opacity 120ms var(--like-ease-out) !important;
}
.like[data-liked="true"] .like-heart-fill {
opacity: 1;
}
/* Zero duration instead of `none` so animationend still fires and the
component can unmount the scatter. `both` fill ends at opacity 0. */
.like-particle {
animation-duration: 0ms !important;
animation-delay: 0ms !important;
}
}