{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "follow-button",
  "title": "Follow Button",
  "description": "A morphing follow/unfollow button with a spring width morph, animated check draw, and a hover-revealed unfollow state",
  "dependencies": [
    "framer-motion",
    "lucide-react"
  ],
  "files": [
    {
      "path": "app/registry/follow-button/follow-button.tsx",
      "content": "/**\n * Spectrum UI — FollowButton\n *\n * A morphing social follow/unfollow button. Following springs the pill's\n * width to fit the new label, crossfades the solid fill into an outline, and\n * draws a check in; hovering or focusing the followed pill previews a rose\n * \"Unfollow\" affordance, and clicking again dips and morphs back. Works\n * controlled or uncontrolled, honors prefers-reduced-motion, and exposes its\n * state to screen readers via aria-pressed.\n *\n * Dependencies: framer-motion, lucide-react, @/lib/utils\n *\n * @example\n * <FollowButton onFollowingChange={(following) => save(following)} />\n */\n\n\"use client\"\n\nimport React, { useCallback, useEffect, useRef, useState } from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"framer-motion\"\nimport { Plus } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\nexport interface FollowButtonProps {\n  /** Controlled following state. Leave undefined for uncontrolled usage */\n  following?: boolean\n  /** Initial following state when uncontrolled. Default false */\n  defaultFollowing?: boolean\n  /** Fires with the next following state on every toggle */\n  onFollowingChange?: (following: boolean) => void\n  /** Label of the idle call-to-action pill. Default \"Follow\" */\n  followLabel?: string\n  /** Label shown while followed. Default \"Following\" */\n  followingLabel?: string\n  /** Label revealed on hover or focus while followed. Default \"Unfollow\" */\n  unfollowLabel?: string\n  /** Visual size of the button. Default \"md\" */\n  size?: \"sm\" | \"md\" | \"lg\"\n  /** Disables pointer and keyboard interaction */\n  disabled?: boolean\n  className?: string\n}\n\n/**\n * How the current label swap should animate: follow-state changes roll\n * vertically, intent previews (Following ⇄ Unfollow) crossfade in place\n */\ntype LabelSwapCustom = { mode: \"roll\" | \"fade\"; reduce: boolean }\n\n// ─── Constants ───────────────────────────────────────────────────────────────\n\nconst SIZES = {\n  sm: { button: \"h-8 gap-1.5 px-3.5 text-xs\", icon: 14 },\n  md: { button: \"h-10 gap-2 px-4 text-sm\", icon: 16 },\n  lg: { button: \"h-12 gap-2.5 px-5 text-base\", icon: 18 },\n} as const\n\n/** Snappy micro spring — label rolls between follow states */\nconst SNAPPY_SPRING = { type: \"spring\", stiffness: 500, damping: 30 } as const\n/** Softer surface spring — the pill's width morph */\nconst LAYOUT_SPRING = { type: \"spring\", stiffness: 260, damping: 22 } as const\n/** Entrance/reveal ease — the check stroke draw */\nconst EASE_OUT: [number, number, number, number] = [0.22, 1, 0.36, 1]\n\n/** Seconds for the in-place crossfade of an intent preview */\nconst INTENT_FADE_DURATION = 0.12\n/** Seconds for the icon crossfade between plus and check */\nconst ICON_FADE_DURATION = 0.15\n/** Seconds the check stroke takes to draw */\nconst CHECK_DRAW_DURATION = 0.3\n/** Seconds the check draw waits behind the icon crossfade */\nconst CHECK_DRAW_DELAY = 0.05\n/** Quick negative dip when unfollowing — tight and small, no overshoot */\nconst DIP_SCALE = 0.95\nconst DIP_DURATION = 0.1\n\n/** Lucide check, drawn manually so the stroke can animate its pathLength */\nconst CHECK_PATH = \"M20 6 9 17l-5-5\"\n\nconst labelVariants = {\n  enter: ({ mode, reduce }: LabelSwapCustom) =>\n    reduce || mode === \"fade\" ? { y: 0, opacity: 0 } : { y: 8, opacity: 0 },\n  center: { y: 0, opacity: 1 },\n  // Exit carries its own transition so the outgoing label always matches the\n  // swap that removed it, even when props changed since it rendered\n  exit: ({ mode, reduce }: LabelSwapCustom) =>\n    reduce\n      ? { opacity: 0, transition: { duration: 0 } }\n      : mode === \"fade\"\n        ? { opacity: 0, transition: { duration: INTENT_FADE_DURATION } }\n        : { y: -8, opacity: 0, transition: SNAPPY_SPRING },\n}\n\n// ─── Component ───────────────────────────────────────────────────────────────\n\nexport function FollowButton({\n  following: followingProp,\n  defaultFollowing = false,\n  onFollowingChange,\n  followLabel = \"Follow\",\n  followingLabel = \"Following\",\n  unfollowLabel = \"Unfollow\",\n  size = \"md\",\n  disabled = false,\n  className,\n}: FollowButtonProps) {\n  const shouldReduceMotion = useReducedMotion()\n  const [internalFollowing, setInternalFollowing] = useState(defaultFollowing)\n  const [intent, setIntent] = useState(false)\n  const [dipping, setDipping] = useState(false)\n  // Layout morphs are armed only after the first toggle so the pill never\n  // animates its width on first render or unrelated page reflows\n  const [hasToggled, setHasToggled] = useState(false)\n\n  const following = followingProp ?? internalFollowing\n  const prevFollowingRef = useRef(following)\n  const { button: sizeClasses, icon } = SIZES[size]\n  // Hovering or focusing the followed pill previews the destructive action\n  const unfollowIntent = following && intent\n  const label = following\n    ? unfollowIntent\n      ? unfollowLabel\n      : followingLabel\n    : followLabel\n  // Follow-state changes roll the label; intent previews crossfade in place\n  const swapMode: LabelSwapCustom[\"mode\"] =\n    following !== prevFollowingRef.current ? \"roll\" : \"fade\"\n  const swapCustom: LabelSwapCustom = {\n    mode: swapMode,\n    reduce: shouldReduceMotion ?? false,\n  }\n\n  useEffect(() => {\n    // Controlled changes from outside also count as interaction\n    if (following !== prevFollowingRef.current) setHasToggled(true)\n    prevFollowingRef.current = following\n  }, [following])\n\n  const animateLayout =\n    (hasToggled || following !== prevFollowingRef.current) &&\n    !shouldReduceMotion\n\n  const handleClick = useCallback(() => {\n    const next = !following\n    if (followingProp === undefined) setInternalFollowing(next)\n    onFollowingChange?.(next)\n    setHasToggled(true)\n    // Quick dip on unfollow before the pill morphs back to the solid state\n    if (!next && !shouldReduceMotion) setDipping(true)\n    // Require a fresh hover before re-arming the unfollow preview\n    setIntent(false)\n  }, [following, followingProp, onFollowingChange, shouldReduceMotion])\n\n  const handleHoverStart = useCallback(() => setIntent(true), [])\n  const handleHoverEnd = useCallback(() => setIntent(false), [])\n\n  const handleFocus = useCallback(\n    (event: React.FocusEvent<HTMLButtonElement>) => {\n      // Only keyboard-driven focus previews the destructive state; pointer\n      // focus is handled by hover so clicking Follow doesn't flash Unfollow\n      if (event.currentTarget.matches(\":focus-visible\")) setIntent(true)\n    },\n    [],\n  )\n  const handleBlur = useCallback(() => setIntent(false), [])\n\n  const labelTransition = shouldReduceMotion\n    ? { duration: 0 }\n    : swapMode === \"fade\"\n      ? { duration: INTENT_FADE_DURATION, ease: EASE_OUT }\n      : SNAPPY_SPRING\n\n  return (\n    <motion.button\n      type=\"button\"\n      onClick={handleClick}\n      onHoverStart={handleHoverStart}\n      onHoverEnd={handleHoverEnd}\n      onFocus={handleFocus}\n      onBlur={handleBlur}\n      disabled={disabled}\n      aria-pressed={following}\n      aria-label={following ? unfollowLabel : followLabel}\n      layout={animateLayout}\n      style={{ borderRadius: 9999 }}\n      animate={{ scale: dipping ? DIP_SCALE : 1 }}\n      onAnimationComplete={() => {\n        if (dipping) setDipping(false)\n      }}\n      whileTap={shouldReduceMotion ? undefined : { scale: 0.96 }}\n      transition={{\n        layout: LAYOUT_SPRING,\n        scale: { duration: DIP_DURATION, ease: \"easeInOut\" },\n      }}\n      className={cn(\n        \"relative inline-flex touch-manipulation select-none items-center justify-center rounded-full border font-medium transition-colors\",\n        \"focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-neutral-950 dark:focus-visible:ring-neutral-300\",\n        \"disabled:pointer-events-none disabled:opacity-50\",\n        following\n          ? unfollowIntent\n            ? \"border-rose-200 bg-rose-50 text-rose-600 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-400\"\n            : \"border-neutral-200 bg-white text-neutral-900 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-100\"\n          : \"border-transparent bg-neutral-900 text-white hover:bg-neutral-700 dark:bg-white dark:text-neutral-900 dark:hover:bg-neutral-200\",\n        sizeClasses,\n        className,\n      )}\n    >\n      <motion.span\n        layout={animateLayout ? \"position\" : false}\n        aria-hidden=\"true\"\n        className=\"relative inline-flex items-center justify-center\"\n        style={{ width: icon, height: icon }}\n      >\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          {following ? (\n            <motion.svg\n              key=\"check\"\n              viewBox=\"0 0 24 24\"\n              width={icon}\n              height={icon}\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth=\"2\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              exit={{ opacity: 0 }}\n              transition={\n                shouldReduceMotion\n                  ? { duration: 0 }\n                  : { duration: ICON_FADE_DURATION }\n              }\n            >\n              <motion.path\n                d={CHECK_PATH}\n                // Draw only on interaction; defaultFollowing mounts settled\n                initial={\n                  shouldReduceMotion || !hasToggled ? false : { pathLength: 0 }\n                }\n                animate={{ pathLength: 1 }}\n                transition={\n                  shouldReduceMotion\n                    ? { duration: 0 }\n                    : {\n                        duration: CHECK_DRAW_DURATION,\n                        ease: EASE_OUT,\n                        delay: CHECK_DRAW_DELAY,\n                      }\n                }\n              />\n            </motion.svg>\n          ) : (\n            <motion.span\n              key=\"plus\"\n              className=\"inline-flex\"\n              initial={{ opacity: 0, scale: 0.6 }}\n              animate={{ opacity: 1, scale: 1 }}\n              exit={{ opacity: 0, scale: 0.6 }}\n              transition={\n                shouldReduceMotion\n                  ? { duration: 0 }\n                  : { duration: ICON_FADE_DURATION }\n              }\n            >\n              <Plus size={icon} strokeWidth={2} aria-hidden=\"true\" />\n            </motion.span>\n          )}\n        </AnimatePresence>\n      </motion.span>\n\n      <motion.span\n        layout={animateLayout ? \"position\" : false}\n        aria-hidden=\"true\"\n        className=\"relative inline-flex overflow-hidden\"\n      >\n        <AnimatePresence mode=\"popLayout\" initial={false} custom={swapCustom}>\n          <motion.span\n            key={label}\n            className=\"inline-block whitespace-nowrap\"\n            custom={swapCustom}\n            variants={labelVariants}\n            initial=\"enter\"\n            animate=\"center\"\n            exit=\"exit\"\n            transition={labelTransition}\n          >\n            {label}\n          </motion.span>\n        </AnimatePresence>\n      </motion.span>\n    </motion.button>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/spectrumui/follow-button.tsx"
    }
  ],
  "type": "registry:component"
}
