Back to components

Like Burst

A like toggle with the same props as Like, whose outline collapses into a bubble that hollows into a ring as the heart pops back amid paired confetti dots.

actioncollapse
"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-burst.css";

/** Keep in sync with the nth-child choreography in like-burst.css. */
const LIKE_BURST_PARTICLE_COUNT = 7;

/**
 * The ring and every particle group run for the full burst duration, so the
 * burst is done once all of them report in. The per-dot animations on their
 * pseudo-elements share that clock and are not counted.
 */
const LIKE_BURST_SETTLE_ANIMATIONS = new Set([
  "like-burst-ring",
  "like-burst-particle",
]);
const LIKE_BURST_SETTLE_COUNT = 1 + LIKE_BURST_PARTICLE_COUNT;

type ButtonProps = React.ComponentProps<typeof ButtonPrimitive>;
type LikeBurstClickEvent = Parameters<NonNullable<ButtonProps["onClick"]>>[0];

export type LikeBurstProps = 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: LikeBurstClickEvent) => 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-burst-size` custom property,
   * which can also be set in CSS. The ring and particles scale with it.
   */
  iconSize?: number | string;
  /**
   * Total burst duration in milliseconds; every phase keeps its share of it.
   * Defaults to 700ms via the `--like-burst-duration` custom property, which
   * can also be set in CSS.
   */
  duration?: number;
  /** Classes applied to the heart and burst wrapper. */
  iconClassName?: string;
};

type LikeBurstStyle = React.CSSProperties & {
  "--like-burst-duration"?: string;
  "--like-burst-size"?: string;
};

export function LikeBurst({
  liked,
  defaultLiked = false,
  onLikedChange,
  onClick,
  likeLabel = "Like",
  unlikeLabel = "Unlike",
  children,
  iconSize,
  duration,
  className,
  iconClassName,
  disabled,
  ref,
  style,
  ...props
}: LikeBurstProps) {
  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 [burstId, setBurstId] = React.useState(0);
  const [burstActive, setBurstActive] = React.useState(false);
  const settledAnimationsRef = React.useRef(0);
  const likeBurstStyle: LikeBurstStyle = { ...style };

  if (duration !== undefined) {
    likeBurstStyle["--like-burst-duration"] = `${duration}ms`;
  }

  if (iconSize !== undefined) {
    likeBurstStyle["--like-burst-size"] =
      typeof iconSize === "number" ? `${iconSize}px` : iconSize;
  }

  // A burst only belongs to a liked heart, so unliking mid-burst drops it.
  if (burstActive && !isLiked) {
    setBurstActive(false);
  }

  // The burst 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) {
      settledAnimationsRef.current = 0;
      setBurstId((current) => current + 1);
      setBurstActive(true);
    }

    if (previousLikedRef.current !== isLiked) {
      likeIntentRef.current = false;
    }

    previousLikedRef.current = isLiked;
  }, [isLiked]);

  const handleClick = React.useCallback(
    (event: LikeBurstClickEvent) => {
      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 burst leaves the DOM once the ring and every particle group have
  // finished, so long-lived liked items don't keep its layers mounted.
  const handleBurstAnimationEnd = React.useCallback(
    (event: React.AnimationEvent<HTMLSpanElement>) => {
      if (!LIKE_BURST_SETTLE_ANIMATIONS.has(event.animationName)) return;

      settledAnimationsRef.current += 1;

      if (settledAnimationsRef.current >= LIKE_BURST_SETTLE_COUNT) {
        setBurstActive(false);
      }
    },
    [],
  );

  const bursting = isLiked && burstActive;

  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-burst"
      data-liked={isLiked}
      data-bursting={bursting ? "" : undefined}
      data-icon-only={children ? undefined : ""}
      className={cn(
        "like-burst 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={likeBurstStyle}
      onClick={handleClick}
      {...props}
    >
      <span
        data-slot="like-burst-icon"
        data-icon={children ? "inline-start" : undefined}
        className={cn("like-burst-icon", iconClassName)}
        aria-hidden="true"
      >
        {bursting ? (
          <span
            key={burstId}
            data-slot="like-burst-effects"
            className="like-burst-effects"
            onAnimationEnd={handleBurstAnimationEnd}
          >
            <span className="like-burst-ring" />
            <span className="like-burst-particles">
              {Array.from({ length: LIKE_BURST_PARTICLE_COUNT }, (_, index) => (
                <span className="like-burst-particle" key={index} />
              ))}
            </span>
          </span>
        ) : null}

        <HeartIcon className="like-burst-heart like-burst-heart-outline size-full" />
        <HeartIcon
          className="like-burst-heart like-burst-heart-fill size-full"
          fill="currentColor"
        />
      </span>

      {children ? (
        <span data-slot="like-burst-content">{children}</span>
      ) : null}
    </ButtonPrimitive>
  );
}

Installation