shadimage
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 · light
ui-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;
  }
}

More from ericts

Browse all components in this library →