{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"follow-button","type":"registry:component","title":"Follow button","description":"Turns its plus into a check as you follow, and warns before you unfollow.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/follow-button.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { AnimatePresence, LayoutGroup, motion } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\nimport { usePreviewPlay } from \"@/lib/preview-play\";\n\ntype Mode = \"follow\" | \"following\" | \"unfollow\";\n\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\n// The glyph is two strokes that trade places: a plus, a check, or a cross.\n// Each shape is written with the same commands so the paths can morph.\nconst STROKES: Record<Mode, [string, string]> = {\n  follow: [\"M12 5 L12 19\", \"M5 12 L19 12\"],\n  following: [\"M5.5 12.5 L10 17\", \"M10 17 L18.5 7.5\"],\n  unfollow: [\"M7 7 L17 17\", \"M17 7 L7 17\"],\n};\n// A quarter turn: the plus looks identical after it, so it reads as the plus\n// turning into the check rather than a swap.\nconst TURN: Record<Mode, number> = { follow: -90, following: 0, unfollow: 0 };\nconst MORPH = { type: \"spring\", duration: 0.35, bounce: 0.15 } as const;\n// Long enough that sweeping past a Following button never flashes red,\n// short enough that someone aiming for it isn't kept waiting.\nconst UNFOLLOW_DELAY = 450;\nconst LABELS: Record<Mode, string> = {\n  follow: \"Follow\",\n  following: \"Following\",\n  unfollow: \"Unfollow\",\n};\n\nexport function FollowButton({\n  following,\n  onFollowingChange,\n  name,\n  className,\n}: {\n  following: boolean;\n  onFollowingChange: (following: boolean) => void;\n  /** Who is being followed, for the accessible name and announcements. */\n  name: string;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const [armed, setArmed] = useState(false);\n  const [warning, setWarning] = useState(false);\n  const [note, setNote] = useState(\"\");\n  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);\n  const widths = useRef<Record<Mode, HTMLSpanElement | null>>({\n    follow: null,\n    following: null,\n    unfollow: null,\n  });\n  const [measured, setMeasured] = useState<Record<Mode, number> | null>(null);\n\n  useEffect(() => () => clearTimeout(timer.current), []);\n\n  // Every label is rendered once, invisibly, so the button can glide to the\n  // next width before the new word has even faded in.\n  useLayoutEffect(() => {\n    const measure = () => {\n      const w = widths.current;\n      if (!w.follow || !w.following || !w.unfollow) return;\n      setMeasured({\n        follow: w.follow.offsetWidth,\n        following: w.following.offsetWidth,\n        unfollow: w.unfollow.offsetWidth,\n      });\n    };\n    measure();\n    // Web fonts can land after the first measure and change every width.\n    let live = true;\n    document.fonts?.ready.then(() => live && measure());\n    return () => {\n      live = false;\n    };\n  }, []);\n\n  const mode: Mode = !following ? \"follow\" : warning ? \"unfollow\" : \"following\";\n\n  const cancelWarning = () => {\n    clearTimeout(timer.current);\n    setWarning(false);\n  };\n\n  const toggle = () => {\n    const next = !following;\n    cancelWarning();\n    // The pointer is still over the button right after following, so it has\n    // to leave and come back before Unfollow can show; otherwise the red\n    // state would appear under a cursor that only just clicked.\n    setArmed(false);\n    onFollowingChange(next);\n    setNote(next ? `Following ${name}` : `Unfollowed ${name}`);\n  };\n\n  return (\n    <span className=\"relative inline-flex\">\n      <button\n        type=\"button\"\n        aria-pressed={following}\n        aria-label={`Follow ${name}`}\n        onClick={toggle}\n        onPointerEnter={(e) => {\n          if (e.pointerType === \"touch\" || !following || !armed) return;\n          clearTimeout(timer.current);\n          timer.current = setTimeout(() => setWarning(true), UNFOLLOW_DELAY);\n        }}\n        onPointerLeave={(e) => {\n          if (e.pointerType === \"touch\") return;\n          setArmed(true);\n          cancelWarning();\n        }}\n        className={cn(\n          \"inline-flex h-9 touch-manipulation items-center gap-1.5 rounded-full pr-4 pl-3 text-sm font-medium outline-hidden select-none\",\n          // Text color is left out on purpose: it flips with the label swap,\n          // which the blur already covers, so the word never fades through\n          // the mid-grey the fill passes on its way from ink to surface.\n          \"transition-[scale,background-color,box-shadow] duration-100 ease-out active:scale-[0.96] motion-reduce:transition-[background-color]\",\n          \"focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground\",\n          mode === \"follow\" &&\n            \"bg-foreground text-background hover:bg-foreground/85\",\n          mode === \"following\" && \"bg-muted text-foreground shadow-raised\",\n          // The tint is faint on purpose: a warning, not an alarm.\n          mode === \"unfollow\" &&\n            \"bg-destructive/10 text-destructive shadow-[0_0_0_1px_color-mix(in_oklab,var(--destructive)_25%,transparent)]\",\n          className,\n        )}\n      >\n        <motion.svg\n          viewBox=\"0 0 24 24\"\n          className=\"size-4 shrink-0\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth={2}\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          aria-hidden\n          initial={false}\n          animate={{ rotate: reduceMotion ? 0 : TURN[mode] }}\n          transition={MORPH}\n        >\n          {STROKES[mode].map((d, i) => (\n            <motion.path\n              key={i}\n              initial={false}\n              animate={{ d }}\n              transition={reduceMotion ? { duration: 0 } : MORPH}\n            />\n          ))}\n        </motion.svg>\n\n        {/* Width is animated (not scale) so the words never stretch; it is a\n            single inline element, so the reflow is cheap. */}\n        <span\n          aria-hidden\n          className=\"relative grid h-5 overflow-hidden transition-[width] duration-[250ms] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none\"\n          style={measured ? { width: measured[mode] } : undefined}\n        >\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.span\n              key={mode}\n              className=\"col-start-1 row-start-1 whitespace-nowrap leading-5\"\n              initial={{\n                opacity: 0,\n                y: reduceMotion ? 0 : 4,\n                filter: reduceMotion ? \"blur(0px)\" : \"blur(4px)\",\n              }}\n              animate={{\n                opacity: 1,\n                y: 0,\n                filter: \"blur(0px)\",\n                transition: { duration: 0.22, ease: EASE_OUT },\n              }}\n              // Leaves in half the time, so the old word is gone before the\n              // new one is legible and they never read as overlapping.\n              exit={{\n                opacity: 0,\n                y: reduceMotion ? 0 : -2,\n                filter: reduceMotion ? \"blur(0px)\" : \"blur(4px)\",\n                transition: { duration: 0.11, ease: EASE_OUT },\n              }}\n            >\n              {LABELS[mode]}\n            </motion.span>\n          </AnimatePresence>\n          {/* The rulers: out of flow and invisible, measured once. */}\n          {(Object.keys(LABELS) as Mode[]).map((m) => (\n            <span\n              key={m}\n              ref={(el) => {\n                widths.current[m] = el;\n              }}\n              className=\"invisible absolute top-0 left-0 whitespace-nowrap\"\n            >\n              {LABELS[m]}\n            </span>\n          ))}\n        </span>\n      </button>\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {note}\n      </span>\n    </span>\n  );\n}\n\nconst FADE = { duration: 0.25, ease: EASE_OUT } as const;\nconst SLIDE = { type: \"spring\", duration: 0.35, bounce: 0.25 } as const;\n\nconst PEOPLE = [\n  { name: \"Ava\", src: \"/avatars/ava.svg\" },\n  { name: \"Ben\", src: \"/avatars/ben.svg\" },\n  { name: \"Cara\", src: \"/avatars/cara.svg\" },\n];\nconst YOU = { name: \"You\", src: \"/avatars/dev.svg\" };\n\nfunction AvatarStack({ withYou, fit }: { withYou: boolean; fit: boolean }) {\n  const reduceMotion = useReducedMotion();\n  const people = withYou ? [...PEOPLE, YOU] : PEOPLE;\n  return (\n    // Right aligned in a slot sized for four, so your face grows the stack\n    // leftward into empty space and nothing next to it shifts. `fit` drops\n    // the slot so a centred preview has no empty gap on one side.\n    <ul\n      className={cn(\"flex justify-end\", !fit && \"w-[82px]\")}\n      aria-label=\"Followers you know\"\n    >\n      <AnimatePresence initial={false}>\n        {people.map((p) => (\n          <motion.li\n            key={p.name}\n            layout={reduceMotion ? false : \"position\"}\n            className=\"-ml-2 first:ml-0\"\n            initial={{\n              opacity: 0,\n              scale: 0.6,\n              filter: reduceMotion ? \"blur(0px)\" : \"blur(4px)\",\n            }}\n            animate={{\n              opacity: 1,\n              scale: 1,\n              filter: \"blur(0px)\",\n            }}\n            exit={{\n              opacity: 0,\n              scale: 0.8,\n              filter: reduceMotion ? \"blur(0px)\" : \"blur(2px)\",\n              transition: { duration: 0.15, ease: EASE_OUT },\n            }}\n            // A spring overshoots, and blur below zero is invalid, so the\n            // blur and fade get a plain ease.\n            transition={{ ...SLIDE, filter: FADE, opacity: FADE }}\n          >\n            {/* eslint-disable-next-line @next/next/no-img-element -- tiny local SVGs, nothing for next/image to optimize */}\n            <img\n              src={p.src}\n              alt={p.name}\n              width={28}\n              height={28}\n              draggable={false}\n              className=\"block size-7 rounded-full bg-[oklch(0.97_0_0)] ring-2 ring-background\"\n            />\n          </motion.li>\n        ))}\n      </AnimatePresence>\n    </ul>\n  );\n}\n\n// The index card's hover show: follow, let your face join the stack and the\n// check settle, then unfollow. Waits in ms.\nconst SHOW_FOLLOW = 350;\nconst SHOW_UNFOLLOW = 2600;\nconst SHOW_REST = 1500;\n\nexport default function FollowButtonDemo() {\n  const [following, setFollowing] = useState(false);\n  const play = usePreviewPlay();\n  const preview = play !== null;\n  const reduceMotion = useReducedMotion();\n\n  useEffect(() => {\n    if (play !== true) return;\n    let timer: ReturnType<typeof setTimeout>;\n    const cycle = (wait: number) => {\n      timer = setTimeout(() => {\n        setFollowing(true);\n        timer = setTimeout(() => {\n          setFollowing(false);\n          cycle(SHOW_REST);\n        }, SHOW_UNFOLLOW);\n      }, wait);\n    };\n    cycle(SHOW_FOLLOW);\n    return () => {\n      clearTimeout(timer);\n      setFollowing(false);\n    };\n  }, [play]);\n\n  return (\n    // On its own page the button's left edge is fixed and it grows\n    // rightward, so the thing you click never slides out from under the\n    // cursor. Nobody clicks the index card's copy, so there the pair sits\n    // centred with no reserved gaps and grows evenly both ways.\n    <div\n      className={cn(\n        \"flex max-w-full items-center gap-3\",\n        preview ? \"justify-center\" : \"w-[260px]\",\n      )}\n    >\n      {/* Centred, a face joining the stack would shove the button sideways\n          in one frame; the group lets the button glide over with the\n          avatars instead. */}\n      <LayoutGroup>\n        <AvatarStack withYou={following} fit={preview} />\n        <motion.span\n          layout={preview && !reduceMotion ? \"position\" : false}\n          transition={SLIDE}\n          className=\"inline-flex\"\n        >\n          <FollowButton\n            name=\"Mira Okafor\"\n            following={following}\n            onFollowingChange={setFollowing}\n          />\n        </motion.span>\n      </LayoutGroup>\n    </div>\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"},{"path":"lib/preview-play.ts","type":"registry:lib","target":"lib/preview-play.ts","content":"\"use client\";\n\nimport { createContext, useContext } from \"react\";\n\n// Lets a demo know whether it's showing in an index card, and whether that\n// card is hovered or focused. Demos use it to act themselves out on hover:\n// a star button stars itself, a checkbox scribbles, and so on.\n//\n// null: not in a preview (the component's own page, or real use).\n// false: in a preview, at rest.\n// true: in a preview, being hovered or focused: play the show.\nexport const PreviewPlayContext = createContext<boolean | null>(null);\n\nexport function usePreviewPlay() {\n  return useContext(PreviewPlayContext);\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["buttons"],"docs":"From ui lab: https://lab.xevrion.dev/lab/follow-button"}