{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"new-items-pill","type":"registry:ui","title":"New Items Pill","description":"New content without stealing your scroll.","dependencies":["motion"],"categories":["notification"],"docs":"https://www.interior.dev/docs/new-items-pill","files":[{"path":"registry/interior/new-items-pill.tsx","type":"registry:ui","target":"components/interior/new-items-pill.tsx","content":"\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nconst EASE = [0.23, 1, 0.32, 1] as const;\n\nconst ARRIVE = { type: \"spring\", stiffness: 540, damping: 34, mass: 0.5 } as const;\nconst INSTANT = { duration: 0 } as const;\n\nconst useIsoLayoutEffect =\n  typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\nexport type NewItemsAnchor = \"top\" | \"bottom\";\n\nexport type UseNewItemsOptions = {\n  itemCount: number;\n  anchor?: NewItemsAnchor;\n  threshold?: number;\n};\n\nexport type UseNewItemsResult<T extends HTMLElement> = {\n  scrollProps: {\n    ref: React.RefObject<T | null>;\n    tabIndex: number;\n    style: React.CSSProperties;\n  };\n  unread: number;\n  pinned: boolean;\n\n  jump: () => number;\n};\n\nexport function useNewItems<T extends HTMLElement = HTMLDivElement>({\n  itemCount,\n  anchor = \"top\",\n  threshold = 24,\n}: UseNewItemsOptions): UseNewItemsResult<T> {\n  const ref = useRef<T | null>(null);\n  const pinnedRef = useRef(true);\n  const prevCount = useRef(itemCount);\n  const bottomGap = useRef(0);\n\n  const [unread, setUnread] = useState(0);\n  const [pinned, setPinned] = useState(true);\n  const reduced = useReducedMotion();\n\n  useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n\n    const read = () =>\n      anchor === \"bottom\"\n        ? el.scrollHeight - el.scrollTop - el.clientHeight <= threshold\n        : el.scrollTop <= threshold;\n\n    const onScroll = () => {\n      bottomGap.current = el.scrollHeight - el.scrollTop;\n      const next = read();\n      if (next === pinnedRef.current) return;\n      pinnedRef.current = next;\n      setPinned(next);\n      if (next) setUnread(0);\n    };\n    onScroll();\n    el.addEventListener(\"scroll\", onScroll, { passive: true });\n    return () => el.removeEventListener(\"scroll\", onScroll);\n  }, [anchor, threshold]);\n\n  useIsoLayoutEffect(() => {\n    const el = ref.current;\n    const added = itemCount - prevCount.current;\n    prevCount.current = itemCount;\n    if (!el || added <= 0) return;\n\n    if (pinnedRef.current) {\n      el.scrollTop = anchor === \"bottom\" ? el.scrollHeight : 0;\n      bottomGap.current = el.scrollHeight - el.scrollTop;\n      return;\n    }\n\n    if (anchor === \"top\") {\n      const target = el.scrollHeight - bottomGap.current;\n      if (target > el.scrollTop) el.scrollTop = target;\n    }\n    setUnread((n) => n + added);\n  }, [itemCount, anchor]);\n\n  const unreadRef = useRef(0);\n  unreadRef.current = unread;\n\n  const jump = useCallback(() => {\n    const el = ref.current;\n    const caught = unreadRef.current;\n    if (!el) return caught;\n    pinnedRef.current = true;\n    setPinned(true);\n    setUnread(0);\n\n    el.focus({ preventScroll: true });\n    el.scrollTo({\n      top: anchor === \"bottom\" ? el.scrollHeight : 0,\n      behavior: reduced ? \"auto\" : \"smooth\",\n    });\n    return caught;\n  }, [anchor, reduced]);\n\n  return {\n    scrollProps: { ref, tabIndex: 0, style: { overflowAnchor: \"none\" } },\n    unread,\n    pinned,\n    jump,\n  };\n}\n\nexport type NewItemsPillProps = {\n  count: number;\n  onJump: () => void;\n  anchor?: NewItemsAnchor;\n  label?: (count: number) => string;\n  max?: number;\n  className?: string;\n};\n\nconst defaultLabel = (n: number) => `${n} new ${n === 1 ? \"item\" : \"items\"}`;\n\nexport function NewItemsPill({\n  count,\n  onJump,\n  anchor = \"top\",\n  label = defaultLabel,\n  max = 99,\n  className = \"\",\n}: NewItemsPillProps) {\n  const reduced = useReducedMotion();\n  const [announced, setAnnounced] = useState(0);\n\n  useEffect(() => {\n    if (count === 0) {\n      setAnnounced(0);\n      return;\n    }\n    const t = setTimeout(() => setAnnounced(count), 700);\n    return () => clearTimeout(t);\n  }, [count]);\n\n  const phrase = (n: number) => (n > max ? `${max}+ new items` : label(n));\n  const text = phrase(count);\n  const off = anchor === \"bottom\" ? 10 : -10;\n\n  return (\n    <div\n      className={`pointer-events-none absolute inset-x-0 z-10 flex justify-center ${\n        anchor === \"bottom\" ? \"bottom-2\" : \"top-2\"\n      } ${className}`}\n    >\n      <AnimatePresence initial={false}>\n        {count > 0 && (\n          <motion.button\n            type=\"button\"\n            onClick={onJump}\n            aria-label={text}\n            initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.94, y: off }}\n            animate={{ opacity: 1, scale: 1, y: 0 }}\n            exit={\n              reduced\n                ? { opacity: 0, transition: INSTANT }\n                : {\n                    opacity: 0,\n                    scale: 0.96,\n                    y: off * 0.5,\n                    transition: { duration: 0.16, ease: EASE },\n                  }\n            }\n            transition={\n              reduced\n                ? INSTANT\n                : { ...ARRIVE, opacity: { duration: 0.16, ease: EASE } }\n            }\n            className=\"pointer-events-auto inline-flex h-8 select-none items-center gap-1.5 rounded-[9px] border border-stone-200 bg-white pl-2 pr-2.5 text-[12.5px] font-medium text-stone-700 shadow-[0_1px_2px_rgba(28,25,23,0.08),0_6px_14px_-10px_rgba(28,25,23,0.45)] outline-none transition-[border-color,box-shadow] duration-150 focus-visible:border-[#4568FF] focus-visible:shadow-[0_2px_4px_rgba(28,25,23,0.1),0_12px_22px_-12px_rgba(69,104,255,0.55)] dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:text-stone-100 dark:shadow-[0_2px_8px_rgba(0,0,0,0.5)] dark:focus-visible:border-[#93B0FF] dark:focus-visible:shadow-[0_2px_10px_rgba(0,0,0,0.6),0_12px_22px_-12px_rgba(147,176,255,0.4)]\"\n          >\n            <svg\n              width=\"14\"\n              height=\"14\"\n              viewBox=\"0 0 256 256\"\n              fill=\"none\"\n              aria-hidden=\"true\"\n              className={anchor === \"bottom\" ? \"rotate-180\" : \"\"}\n            >\n              <line\n                x1=\"128\"\n                y1=\"216\"\n                x2=\"128\"\n                y2=\"48\"\n                stroke=\"currentColor\"\n                strokeWidth=\"16\"\n                strokeLinecap=\"round\"\n              />\n              <polyline\n                points=\"56 120 128 48 200 120\"\n                stroke=\"currentColor\"\n                strokeWidth=\"16\"\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n              />\n            </svg>\n            <span className=\"tabular-nums\" aria-hidden=\"true\">\n              {text}\n            </span>\n          </motion.button>\n        )}\n      </AnimatePresence>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announced > 0 ? phrase(announced) : \"\"}\n      </span>\n    </div>\n  );\n}\n"}]}