Like
A composable heart toggle with controlled and uncontrolled state, a center-out fill transition, and a miniature-heart scatter.
actionradial-fill
"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;
/** Classes applied to the heart and burst wrapper. */
iconClassName?: string;
};
type LikeStyle = React.CSSProperties & {
"--like-duration"?: string;
"--like-size"?: string;
};
export function Like({
liked,
defaultLiked = false,
onLikedChange,
onClick,
likeLabel = "Like",
unlikeLabel = "Unlike",
children,
iconSize,
duration,
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 [burstId, setBurstId] = React.useState(0);
const [burstActive, setBurstActive] = React.useState(false);
const settledParticlesRef = React.useRef(0);
const likeStyle: LikeStyle = { ...style };
if (duration !== undefined) {
likeStyle["--like-duration"] = `${duration}ms`;
}
if (iconSize !== undefined) {
likeStyle["--like-size"] =
typeof iconSize === "number" ? `${iconSize}px` : iconSize;
}
React.useEffect(() => {
if (!previousLikedRef.current && isLiked) {
settledParticlesRef.current = 0;
setBurstId((current) => current + 1);
setBurstActive(true);
}
previousLikedRef.current = isLiked;
}, [isLiked]);
const handleClick = React.useCallback(
(event: LikeClickEvent) => {
onClick?.(event);
if (event.defaultPrevented || disabled) {
return;
}
const nextLiked = !isLiked;
if (!controlled) {
setInternalLiked(nextLiked);
}
onLikedChange?.(nextLiked);
}, [controlled, disabled, isLiked, onClick, onLikedChange],
);
// The burst leaves the DOM once every particle has finished, so long-lived
// liked items don't keep 8 idle SVGs mounted.
const handleBurstAnimationEnd = React.useCallback(
(event: React.AnimationEvent<HTMLSpanElement>) => {
if (event.animationName !== "like-particle") return;
settledParticlesRef.current += 1;
if (settledParticlesRef.current >= LIKE_PARTICLE_COUNT) {
setBurstActive(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 && burstActive ? (
<span
key={burstId}
data-slot="like-burst"
className="like-burst"
onAnimationEnd={handleBurstAnimationEnd}
>
{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>
);
}