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