{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "follow-button",
  "type": "registry:ui",
  "title": "Follow Button",
  "description": "Follow becomes Following with a drawn tick, offers Unfollow only when safe.",
  "categories": [
    "collaboration"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/follow-button.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\ntype Save = (following: boolean) => unknown;\n\n/**\n * Optimistic follow state with one request in flight. Rapid toggles settle on the last intent and\n * send at most one follow-up; a failure puts the button back to what the server last agreed to.\n */\nexport function useOptimisticFollow({\n  following: followingProp,\n  defaultFollowing = false,\n  onFollowingChange,\n  onSave,\n  onError,\n}: {\n  following?: boolean;\n  defaultFollowing?: boolean;\n  onFollowingChange?: (following: boolean) => void;\n  onSave?: Save;\n  onError?: (error: unknown) => void;\n}) {\n  const [following, setFollowing] = useControllableState({ value: followingProp, defaultValue: defaultFollowing, onChange: onFollowingChange });\n  const [failed, setFailed] = useState<null | \"follow\" | \"unfollow\">(null);\n  const confirmed = useRef(following);\n  const desired = useRef(following);\n  const inflight = useRef(false);\n  const alive = useRef(true);\n  const timer = useRef<number>(undefined);\n\n  useEffect(() => {\n    alive.current = true;\n    return () => {\n      alive.current = false;\n      window.clearTimeout(timer.current);\n    };\n  }, []);\n\n  const flush = async () => {\n    if (!onSave) {\n      confirmed.current = desired.current;\n      return;\n    }\n    inflight.current = true;\n    const target = desired.current;\n    try {\n      await onSave(target);\n      confirmed.current = target;\n    } catch (error) {\n      inflight.current = false;\n      if (!alive.current) return;\n      desired.current = confirmed.current;\n      setFollowing(confirmed.current);\n      setFailed(target ? \"follow\" : \"unfollow\");\n      window.clearTimeout(timer.current);\n      timer.current = window.setTimeout(() => setFailed(null), 2600);\n      onError?.(error);\n      return;\n    }\n    inflight.current = false;\n    // Toggled again while we waited: send the latest intent, once.\n    if (alive.current && desired.current !== confirmed.current) flush();\n  };\n\n  const toggle = (next: boolean) => {\n    desired.current = next;\n    setFollowing(next);\n    setFailed(null);\n    window.clearTimeout(timer.current);\n    if (!inflight.current) flush();\n  };\n\n  return { following, toggle, failed };\n}\n\ntype Mode = \"follow\" | \"following\" | \"unfollow\" | \"failed\";\n\nexport type FollowButtonProps = Omit<React.ComponentProps<\"button\">, \"value\" | \"defaultValue\" | \"onChange\"> & {\n  following?: boolean;\n  defaultFollowing?: boolean;\n  /** Fires on every change, including a rollback after a failed save. */\n  onFollowingChange?: (following: boolean) => void;\n  /** Persist the change. Return a promise; if it rejects, the button rolls back and says so. */\n  onSave?: Save;\n  onError?: (error: unknown) => void;\n  /** Who this follows. Completes the accessible name (\"Follow Maya Okafor\") and the failure message. */\n  name?: string;\n  label?: string;\n  followingLabel?: string;\n  unfollowLabel?: string;\n  failedLabel?: string;\n  size?: \"sm\" | \"md\";\n};\n\n/**\n * Follow becomes Following with a check that draws. Pointing at Following offers Unfollow in the\n * danger color, but only once the pointer has left and come back, so the click that followed\n * never lands on an unfollow. On touch, Unfollow asks for a second tap.\n */\nexport function FollowButton({\n  following: followingProp,\n  defaultFollowing = false,\n  onFollowingChange,\n  onSave,\n  onError,\n  name,\n  label = \"Follow\",\n  followingLabel = \"Following\",\n  unfollowLabel = \"Unfollow\",\n  failedLabel = \"Try again\",\n  size = \"md\",\n  disabled,\n  className,\n  onClick,\n  onPointerDown,\n  onPointerEnter,\n  onPointerLeave,\n  onFocus,\n  onBlur,\n  ...rest\n}: FollowButtonProps) {\n  const { following, toggle, failed } = useOptimisticFollow({ following: followingProp, defaultFollowing, onFollowingChange, onSave, onError });\n  const reduce = useReducedMotion();\n  const [hovered, setHovered] = useState(false);\n  const [focused, setFocused] = useState(false);\n  // True right after following, until the pointer leaves or focus moves: no instant \"Unfollow\".\n  const [settling, setSettling] = useState(false);\n  // Touch has no hover, so the first tap on Following arms Unfollow and the second one does it.\n  const [armed, setArmed] = useState(false);\n  const pointer = useRef<string>(\"mouse\");\n  const armTimer = useRef<number>(undefined);\n  useEffect(() => () => window.clearTimeout(armTimer.current), []);\n\n  const offering = following && !settling && (hovered || focused || armed);\n  const mode: Mode = failed ? \"failed\" : !following ? \"follow\" : offering ? \"unfollow\" : \"following\";\n  const icon = size === \"sm\" ? 14 : 16;\n  const who = name ? ` ${name}` : \"\";\n\n  const enter = reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: \"blur(2px)\" };\n  const leave = reduce ? { opacity: 0 } : { opacity: 0, y: -6, filter: \"blur(2px)\" };\n  const texts = { follow: label, following: followingLabel, unfollow: unfollowLabel, failed: failedLabel };\n  const gap = size === \"sm\" ? \"gap-1.5\" : \"gap-2\";\n\n  return (\n    <>\n      <button\n        type=\"button\"\n        data-state={mode}\n        data-size={size}\n        aria-pressed={following}\n        aria-label={`${label}${who}`}\n        disabled={disabled}\n        onPointerDown={(e) => {\n          onPointerDown?.(e);\n          pointer.current = e.pointerType;\n        }}\n        onPointerEnter={(e) => {\n          onPointerEnter?.(e);\n          if (e.pointerType === \"mouse\") setHovered(true);\n        }}\n        onPointerLeave={(e) => {\n          onPointerLeave?.(e);\n          setHovered(false);\n          setSettling(false);\n        }}\n        onFocus={(e) => {\n          onFocus?.(e);\n          // Only keyboard focus previews Unfollow; a mouse press focuses too, and that must not count.\n          if (e.currentTarget.matches(\":focus-visible\")) setFocused(true);\n        }}\n        onBlur={(e) => {\n          onBlur?.(e);\n          setFocused(false);\n          setSettling(false);\n          setArmed(false);\n        }}\n        onClick={(e) => {\n          onClick?.(e);\n          if (e.defaultPrevented) return;\n          window.clearTimeout(armTimer.current);\n          if (!following) {\n            toggle(true);\n            setSettling(true);\n            setArmed(false);\n            return;\n          }\n          if (pointer.current === \"touch\" && !armed) {\n            setArmed(true);\n            armTimer.current = window.setTimeout(() => setArmed(false), 3000);\n            return;\n          }\n          toggle(false);\n          setArmed(false);\n          pointer.current = \"mouse\";\n        }}\n        className={cn(\n          \"group/follow relative inline-flex shrink-0 select-none items-center justify-center border font-medium tracking-[-0.005em]\",\n          \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n          \"transition-[background-color,border-color,color,box-shadow,scale] duration-150 ease-out active:scale-[0.97] active:duration-75\",\n          \"disabled:pointer-events-none disabled:opacity-50\",\n          size === \"sm\" ? \"h-7 rounded-md px-2.5 text-[12px]\" : \"h-8 rounded-lg px-3 text-[12.5px]\",\n          // Follow is the one primary thing on a profile; once done, it steps back to a quiet outline.\n          \"data-[state=follow]:border-transparent data-[state=follow]:bg-fg data-[state=follow]:text-frame data-[state=follow]:hover:bg-fg/90\",\n          \"data-[state=following]:border-line-2 data-[state=following]:bg-raised data-[state=following]:text-fg data-[state=following]:shadow-[var(--shadow)]\",\n          \"data-[state=unfollow]:border-danger/35 data-[state=unfollow]:bg-danger-soft data-[state=unfollow]:text-danger\",\n          \"data-[state=failed]:border-danger/35 data-[state=failed]:bg-raised data-[state=failed]:text-danger\",\n          className,\n        )}\n        {...rest}\n      >\n        {/* Every state is laid out in one grid cell, so the button is as wide as the widest and\n            never shifts. Each state is centered in that width, icon and label together. */}\n        <span className=\"grid place-items-center overflow-hidden py-1\">\n          {([\"follow\", \"following\", \"unfollow\", \"failed\"] as const).map((m) => (\n            <span key={m} aria-hidden className={cn(\"invisible col-start-1 row-start-1 flex items-center\", gap)}>\n              <span className=\"shrink-0\" style={{ width: icon, height: icon }} />\n              {texts[m]}\n            </span>\n          ))}\n          <AnimatePresence initial={false}>\n            <motion.span\n              key={mode}\n              aria-hidden\n              className={cn(\"col-start-1 row-start-1 flex items-center\", gap)}\n              variants={{ enter, show: { opacity: 1, y: 0, filter: \"blur(0px)\" }, leave }}\n              initial=\"enter\"\n              animate=\"show\"\n              exit=\"leave\"\n              transition={{ duration: reduce ? 0.12 : 0.2, ease: ease.out }}\n            >\n              {/* Inherits the layer's variants, so it only pops on a change, never on first paint. */}\n              <motion.span\n                className=\"grid shrink-0 place-items-center\"\n                style={{ width: icon, height: icon }}\n                variants={{\n                  enter: reduce ? {} : { scale: 0.5, rotate: mode === \"follow\" ? -90 : 0 },\n                  show: { scale: 1, rotate: 0, transition: reduce ? { duration: 0 } : spring.pop },\n                  leave: {},\n                }}\n              >\n                <Glyph mode={mode} size={icon} reduce={!!reduce} />\n              </motion.span>\n              {texts[mode]}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n      </button>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {failed === \"follow\" ? `Couldn’t follow${who}. Try again.` : failed === \"unfollow\" ? `Couldn’t unfollow${who}. Try again.` : armed ? `Tap again to unfollow${who}` : \"\"}\n      </span>\n    </>\n  );\n}\n\nfunction Glyph({ mode, size, reduce }: { mode: Mode; size: number; reduce: boolean }) {\n  const common = { width: size, height: size, viewBox: \"0 0 16 16\", fill: \"none\", stroke: \"currentColor\", strokeWidth: 1.5, strokeLinecap: \"round\" as const, strokeLinejoin: \"round\" as const, \"aria-hidden\": true };\n  // Variants, not initial/animate: the tick draws when the state changes to Following, and is\n  // simply there when a page loads already following.\n  const draw = { variants: { enter: { pathLength: reduce ? 1 : 0 }, show: { pathLength: 1, transition: { duration: reduce ? 0 : 0.3, ease: ease.out, delay: 0.05 } }, leave: {} } };\n  if (mode === \"following\")\n    return (\n      <svg {...common}>\n        <motion.path d=\"M3.5 8.5 6.5 11.5 12.5 4.5\" {...draw} />\n      </svg>\n    );\n  if (mode === \"unfollow\")\n    return (\n      <svg {...common}>\n        <path d=\"M4 8h8\" />\n      </svg>\n    );\n  if (mode === \"failed\")\n    return (\n      <svg {...common}>\n        <path d=\"M13 8a5 5 0 1 1-1.5-3.55M13 2.75V5h-2.25\" />\n      </svg>\n    );\n  return (\n    <svg {...common}>\n      <path d=\"M8 3.5v9M3.5 8h9\" />\n    </svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/follow-button"
}
