{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "new-items-pill",
  "type": "registry:ui",
  "title": "New Items Pill",
  "description": "Holds new feed items behind a rolling count until the reader asks for them.",
  "categories": [
    "notifications"
  ],
  "dependencies": [
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/new-items-pill.tsx",
      "content": "\"use client\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, motion, useAnimate, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ArrowUp } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\n\nconst expo = `cubic-bezier(${ease.out.join(\",\")})`;\nconst digitTiming = {\n  transformTiming: { duration: 420, easing: expo },\n  spinTiming: { duration: 420, easing: expo },\n  opacityTiming: { duration: 160, easing: \"ease-out\" },\n};\n\ntype Key = string | number;\n\n/**\n * Holds items that arrive at the top of a feed until the reader asks for them,\n * so nothing moves under someone who is reading. Items added anywhere else\n * (the next page at the bottom) flow straight in.\n */\nexport function useNewItems<T>(\n  items: T[],\n  { getKey, scrollRef, freshFor = 2400 }: { getKey: (item: T) => Key; scrollRef: React.RefObject<HTMLElement | null>; freshFor?: number },\n) {\n  const reduce = useReducedMotion();\n  const [seen, setSeen] = useState(() => new Set(items.map(getKey)));\n  const [fresh, setFresh] = useState<Set<Key>>(() => new Set());\n  const timer = useRef(0);\n  const frame = useRef(0);\n  useEffect(\n    () => () => {\n      window.clearTimeout(timer.current);\n      cancelAnimationFrame(frame.current);\n    },\n    [],\n  );\n\n  // A feed that started empty takes its first batch straight away.\n  if (seen.size === 0 && items.length > 0) setSeen(new Set(items.map(getKey)));\n\n  // Pending is everything above the newest item already on screen.\n  const firstSeen = items.findIndex((item) => seen.has(getKey(item)));\n  const cut = firstSeen === -1 ? (seen.size ? items.length : 0) : firstSeen;\n  const pending = items.slice(0, cut);\n  const visible = items.slice(cut);\n\n  const reveal = useCallback(() => {\n    const scroller = scrollRef.current;\n    const keys = pending.map(getKey);\n    const hadFocus = !!(document.activeElement as HTMLElement | null)?.closest(\"[data-new-items-pill]\");\n    const commit = () => {\n      setSeen(new Set(items.map(getKey)));\n      setFresh(new Set(keys));\n      window.clearTimeout(timer.current);\n      timer.current = window.setTimeout(() => setFresh(new Set()), freshFor);\n      // The pill is about to leave; don't drop keyboard focus on the floor.\n      if (hadFocus) scroller?.focus({ preventScroll: true });\n    };\n    if (!scroller || scroller.scrollTop <= 1) return commit();\n    scroller.scrollTo({ top: 0, behavior: reduce ? \"auto\" : \"smooth\" });\n    // Insert once the scroll has landed, so the new rows arrive in view instead of shoving the page mid-scroll.\n    const started = performance.now();\n    const wait = () => {\n      if (scroller.scrollTop <= 1 || performance.now() - started > 700) return commit();\n      frame.current = requestAnimationFrame(wait);\n    };\n    cancelAnimationFrame(frame.current);\n    frame.current = requestAnimationFrame(wait);\n  }, [pending, items, getKey, scrollRef, reduce, freshFor]);\n\n  return { visible, pending, reveal, fresh };\n}\n\nexport type NewItemsPillAuthor = { id: string; name: string };\n\nexport type NewItemsPillProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** How many items are waiting. The pill shows above 0. */\n  count: number;\n  /** Scroll to the top and insert the waiting items. `reveal` from useNewItems. */\n  onReveal: () => void;\n  /** The noun after the number, singular and plural. */\n  noun?: { one: string; other: string };\n  /** Who posted them. The latest three show as a stack of initials. */\n  authors?: NewItemsPillAuthor[];\n  /** absolute sits over the nearest positioned parent (the feed); fixed pins to the viewport. */\n  position?: \"absolute\" | \"fixed\";\n};\n\nconst initials = (name: string) =>\n  name\n    .split(/\\s+/)\n    .map((p) => p[0])\n    .join(\"\")\n    .slice(0, 2)\n    .toUpperCase();\n\nexport function NewItemsPill({\n  count,\n  onReveal,\n  noun = { one: \"new update\", other: \"new updates\" },\n  authors = [],\n  position = \"absolute\",\n  className,\n  ...rest\n}: NewItemsPillProps) {\n  const reduce = useReducedMotion();\n  const [scope, animate] = useAnimate<HTMLButtonElement>();\n  const previous = useRef(count);\n  const word = count === 1 ? noun.one : noun.other;\n  const faces = authors.slice(-3);\n\n  // More arriving while it's already showing: a small nudge, not a second entrance.\n  useEffect(() => {\n    const was = previous.current;\n    previous.current = count;\n    if (reduce || was <= 0 || count <= was || !scope.current) return;\n    animate(scope.current, { scale: 1.05 }, { duration: 0.09, ease: ease.out }).then(() => {\n      if (scope.current) animate(scope.current, { scale: 1 }, spring.pop);\n    });\n  }, [count, reduce, animate, scope]);\n\n  return (\n    <div\n      data-slot=\"new-items-pill\"\n      className={cn(\"pointer-events-none left-1/2 top-3 z-(--z-sticky) -translate-x-1/2\", position === \"fixed\" ? \"fixed\" : \"absolute\", className)}\n      {...rest}\n    >\n      <AnimatePresence>\n        {count > 0 && (\n          <motion.div\n            key=\"pill\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: -14, scale: 0.96, filter: \"blur(2px)\" }}\n            animate={{ opacity: 1, y: 0, scale: 1, filter: \"blur(0px)\" }}\n            exit={reduce ? { opacity: 0, transition: { duration: 0.12 } } : { opacity: 0, y: -12, scale: 0.98, transition: { duration: 0.16, ease: ease.in } }}\n            transition={reduce ? { duration: 0.15 } : spring.snappy}\n          >\n            <button\n              ref={scope}\n              type=\"button\"\n              data-new-items-pill=\"\"\n              onClick={onReveal}\n              className={cn(\n                \"group/pill pointer-events-auto relative flex h-8 items-center gap-2 whitespace-nowrap rounded-full bg-fg pl-2.5 pr-3 text-[12.5px] font-medium tracking-[-0.005em] text-frame shadow-pop\",\n                \"outline-none transition-[background-color,scale] duration-150 hover:bg-fg/90 active:scale-[0.96] active:duration-75\",\n                \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n                // A 32px pill still gets a 44px target on touch.\n                \"before:absolute before:-inset-y-1.5 before:inset-x-0 before:content-[''] pointer-fine:before:hidden\",\n              )}\n            >\n              <ArrowUp size={14} className=\"shrink-0 transition-transform duration-200 ease-out group-hover/pill:-translate-y-px\" />\n              {faces.length > 0 && (\n                <span aria-hidden className=\"flex -space-x-1\">\n                  <AnimatePresence initial={false} mode=\"popLayout\">\n                    {faces.map((a) => (\n                      <motion.span\n                        key={a.id}\n                        layout={!reduce}\n                        initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, x: -6 }}\n                        animate={{ opacity: 1, scale: 1, x: 0 }}\n                        exit={{ opacity: 0, scale: 0.5, transition: { duration: 0.12 } }}\n                        transition={reduce ? { duration: 0.12 } : spring.pop}\n                        className=\"grid size-5 place-items-center rounded-full bg-frame text-[9px] font-medium tracking-normal text-fg shadow-[0_0_0_1.5px_var(--fg)]\"\n                      >\n                        {initials(a.name)}\n                      </motion.span>\n                    ))}\n                  </AnimatePresence>\n                </span>\n              )}\n              <span className=\"flex items-center gap-[0.3em]\">\n                <NumberFlow value={count} {...digitTiming} className=\"tabular\" />\n                <span>{word}</span>\n              </span>\n            </button>\n          </motion.div>\n        )}\n      </AnimatePresence>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {count > 0 ? `${count} ${word}` : \"\"}\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/new-items-pill"
}
