{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "jump-to-latest",
  "type": "registry:ui",
  "title": "Jump to Latest",
  "description": "Follows new messages at the bottom, then counts them when you scroll away.",
  "categories": [
    "messaging"
  ],
  "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/jump-to-latest.tsx",
      "content": "\"use client\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ArrowDown } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\n\n// useLayoutEffect warns on the server; the effect only matters in the browser.\nconst useIsoLayoutEffect = typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\n/* -------------------------------------------------------------------------------------------------\n * useStickToBottom\n * -----------------------------------------------------------------------------------------------*/\n\nexport type StickToBottom = {\n  /** Put on the scrolling element. */\n  viewportRef: React.RefCallback<HTMLElement>;\n  /** Put on the element inside it that grows. */\n  contentRef: React.RefCallback<HTMLElement>;\n  /** True while the view follows new content. False once the reader scrolls up. */\n  stuck: boolean;\n  /** Items added since the reader left the bottom. */\n  unseen: number;\n  /** More than half a screen away from the bottom. */\n  far: boolean;\n  /** Scroll to the newest content and follow it again. */\n  scrollToBottom: (options?: { instant?: boolean }) => void;\n};\n\n/**\n * Follows the bottom of a growing list while the reader is there, lets go the moment they scroll\n * up, and counts what arrives while they are away. Streaming content that grows in place is\n * followed too, because it watches the content's size rather than the item count.\n */\nexport function useStickToBottom({ itemCount = 0 }: { itemCount?: number } = {}): StickToBottom {\n  const reduce = !!useReducedMotion();\n  // The nodes live in refs (they are mutated: scrollTop); state only re-runs effects when they attach.\n  const vp = useRef<HTMLElement | null>(null);\n  const ct = useRef<HTMLElement | null>(null);\n  const [viewportNode, setViewportNode] = useState<HTMLElement | null>(null);\n  const [contentNode, setContentNode] = useState<HTMLElement | null>(null);\n  const viewportRef = useCallback((n: HTMLElement | null) => {\n    vp.current = n;\n    setViewportNode(n);\n  }, []);\n  const contentRef = useCallback((n: HTMLElement | null) => {\n    ct.current = n;\n    setContentNode(n);\n  }, []);\n  const [stuck, setStuck] = useState(true);\n  const [unseen, setUnseen] = useState(0);\n  const [far, setFar] = useState(false);\n  const stuckRef = useRef(true);\n  const lastTop = useRef(0);\n\n  // Count what arrives while the reader is scrolled away. Adjusted during render, from the\n  // previous count, so the badge updates in the same frame as the list.\n  const [prevCount, setPrevCount] = useState(itemCount);\n  if (itemCount !== prevCount) {\n    setPrevCount(itemCount);\n    if (!stuck && itemCount > prevCount) setUnseen((n) => n + itemCount - prevCount);\n  }\n\n  const pin = useCallback((value: boolean) => {\n    stuckRef.current = value;\n    setStuck(value);\n    if (value) setUnseen(0);\n  }, []);\n\n  // Start at the newest message.\n  useIsoLayoutEffect(() => {\n    const viewport = vp.current;\n    if (!viewport) return;\n    viewport.scrollTop = viewport.scrollHeight;\n    lastTop.current = viewport.scrollTop;\n  }, [viewportNode]);\n\n  // Growth while stuck (new messages, streaming text, images loading) keeps the bottom in view.\n  // ResizeObserver runs after layout and before paint, so the jump is never seen.\n  useEffect(() => {\n    const viewport = vp.current;\n    const content = ct.current;\n    if (!viewport || !content) return;\n    const ro = new ResizeObserver(() => {\n      if (stuckRef.current) {\n        viewport.scrollTop = viewport.scrollHeight;\n        lastTop.current = viewport.scrollTop;\n      }\n      const dist = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;\n      setFar(dist > viewport.clientHeight / 2);\n    });\n    ro.observe(content);\n    ro.observe(viewport);\n    return () => ro.disconnect();\n  }, [viewportNode, contentNode]);\n\n  useEffect(() => {\n    const viewport = vp.current;\n    if (!viewport) return;\n    const onScroll = () => {\n      const top = viewport.scrollTop;\n      const dist = viewport.scrollHeight - top - viewport.clientHeight;\n      // Only a move up lets go. Content growing never does, and neither does the browser\n      // clamping scrollTop when content shrinks, because that leaves us at the bottom.\n      if (top < lastTop.current - 1 && dist > 4) {\n        if (stuckRef.current) pin(false);\n      } else if (dist <= 4 && !stuckRef.current) pin(true);\n      lastTop.current = top;\n      setFar(dist > viewport.clientHeight / 2);\n    };\n    viewport.addEventListener(\"scroll\", onScroll, { passive: true });\n    return () => viewport.removeEventListener(\"scroll\", onScroll);\n  }, [viewportNode, pin]);\n\n  const scrollToBottom = useCallback(\n    ({ instant = false }: { instant?: boolean } = {}) => {\n      const viewport = vp.current;\n      if (!viewport) return;\n      pin(true);\n      const target = viewport.scrollHeight - viewport.clientHeight;\n      const dist = target - viewport.scrollTop;\n      if (instant || reduce) {\n        viewport.scrollTop = target;\n        return;\n      }\n      // A long way up, skip most of it: land a screen and a half away, then glide the rest.\n      if (dist > viewport.clientHeight * 2) viewport.scrollTop = target - viewport.clientHeight * 1.5;\n      lastTop.current = viewport.scrollTop;\n      viewport.scrollTo({ top: target, behavior: \"smooth\" });\n    },\n    [pin, reduce],\n  );\n\n  return { viewportRef, contentRef, stuck, unseen, far, scrollToBottom };\n}\n\n/* -------------------------------------------------------------------------------------------------\n * ChatScroll\n * -----------------------------------------------------------------------------------------------*/\n\ntype Ctx = StickToBottom & { viewport: React.RefObject<HTMLElement | null> };\nconst ChatScrollContext = createContext<Ctx | null>(null);\n\n/** The stick-to-bottom state from inside a ChatScroll: send buttons call scrollToBottom. */\nexport function useChatScroll() {\n  const ctx = useContext(ChatScrollContext);\n  if (!ctx) throw new Error(\"useChatScroll must be used inside <ChatScroll>\");\n  return ctx;\n}\n\nexport type ChatScrollProps = React.ComponentProps<\"div\"> & {\n  /** How many messages there are. Growth while scrolled up becomes the unseen count. */\n  itemCount?: number;\n};\n\n/** Frames a chat: holds the scrolling viewport, the jump button, and anything else that needs the scroll state. */\nexport function ChatScroll({ itemCount, className, children, ...rest }: ChatScrollProps) {\n  const stick = useStickToBottom({ itemCount });\n  const viewport = useRef<HTMLElement | null>(null);\n  const attach = stick.viewportRef;\n  // Stable, so the scroller is attached once rather than on every render.\n  const viewportRef = useCallback(\n    (node: HTMLElement | null) => {\n      viewport.current = node;\n      attach(node);\n    },\n    [attach],\n  );\n  const { contentRef, stuck, unseen, far, scrollToBottom } = stick;\n  const value = useMemo<Ctx>(\n    () => ({ viewportRef, contentRef, stuck, unseen, far, scrollToBottom, viewport }),\n    [viewportRef, contentRef, stuck, unseen, far, scrollToBottom],\n  );\n  return (\n    <ChatScrollContext.Provider value={value}>\n      <div className={cn(\"relative flex min-h-0 flex-col\", className)} {...rest}>\n        {children}\n      </div>\n    </ChatScrollContext.Provider>\n  );\n}\n\nexport type ChatScrollViewportProps = React.ComponentProps<\"div\"> & {\n  /** Props for the growing element inside the scroller, e.g. role=\"log\". */\n  contentProps?: React.ComponentProps<\"div\">;\n};\n\n/** The scrolling region. Keyboard focusable, so arrow keys, Page Down and End scroll it. */\nexport function ChatScrollViewport({ className, children, contentProps, \"aria-label\": label = \"Messages\", ...rest }: ChatScrollViewportProps) {\n  const { viewportRef, contentRef } = useChatScroll();\n  return (\n    <div\n      ref={viewportRef}\n      tabIndex={0}\n      aria-label={label}\n      className={cn(\n        \"min-h-0 flex-1 overflow-y-auto overscroll-contain outline-none\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3\",\n        className,\n      )}\n      {...rest}\n    >\n      <div ref={contentRef} {...contentProps}>\n        {children}\n      </div>\n    </div>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * JumpToLatest\n * -----------------------------------------------------------------------------------------------*/\n\nexport type JumpToLatestProps = Omit<React.ComponentProps<\"button\">, \"children\"> & {\n  /** Words after the count. Receives the count, for plurals and other languages. */\n  label?: (count: number) => string;\n  /** Name when nothing new has arrived. */\n  emptyLabel?: string;\n  /** Centered over the viewport, or tucked in the bottom-right corner. */\n  align?: \"center\" | \"end\";\n};\n\nconst defaultLabel = (n: number) => (n === 1 ? \"new message\" : \"new messages\");\n\n/**\n * Floats up when the reader has scrolled away. Quiet and round with nothing new; once messages\n * arrive it widens into a filled pill whose count rolls, and the arrow ticks down with each one.\n */\nexport function JumpToLatest({ label = defaultLabel, emptyLabel = \"Jump to latest\", align = \"center\", className, onClick, ...rest }: JumpToLatestProps) {\n  const { stuck, unseen, far, scrollToBottom, viewport } = useChatScroll();\n  const reduce = !!useReducedMotion();\n  const show = !stuck && (far || unseen > 0);\n  const loud = unseen > 0;\n  const words = label(unseen);\n  const name = loud ? `${emptyLabel}, ${unseen} ${words}` : emptyLabel;\n\n  return (\n    <div className={cn(\"pointer-events-none absolute inset-x-0 bottom-3 z-(--z-sticky) flex px-3\", align === \"center\" ? \"justify-center\" : \"justify-end\")}>\n      <AnimatePresence>\n        {show && (\n          <motion.button\n            key=\"jump\"\n            type=\"button\"\n            layout={reduce ? false : true}\n            aria-label={name}\n            data-state={loud ? \"new\" : \"idle\"}\n            onClick={(e) => {\n              onClick?.(e);\n              if (e.defaultPrevented) return;\n              const hadFocus = document.activeElement === e.currentTarget;\n              scrollToBottom();\n              // The button is about to leave; keep keyboard users in the conversation.\n              if (hadFocus) viewport.current?.focus({ preventScroll: true });\n            }}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 10, scale: 0.9 }}\n            animate={{ opacity: 1, y: 0, scale: 1 }}\n            exit={reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, y: 6, scale: 0.94, transition: { duration: 0.14, ease: ease.in } }}\n            transition={reduce ? { duration: 0.15 } : { ...spring.snappy, layout: spring.snappy }}\n            style={{ borderRadius: 999 }}\n            className={cn(\n              \"group/jump pointer-events-auto relative flex h-8 min-w-8 items-center justify-center gap-1.5 overflow-hidden border text-[12.5px] font-medium shadow-pop\",\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] duration-200 active:scale-[0.96]\",\n              // A touch target of 44px around the 32px drawing.\n              \"before:absolute before:-inset-1.5 before:content-[''] pointer-fine:before:hidden\",\n              loud ? \"border-transparent bg-fg pl-2.5 pr-3 text-frame hover:bg-fg/90\" : \"border-line-2 bg-raised text-fg-2 hover:bg-hover hover:text-fg\",\n              className,\n            )}\n            {...(rest as React.ComponentProps<typeof motion.button>)}\n          >\n            {/* The arrow ticks down each time another message lands. */}\n            <motion.span layout={reduce ? false : \"position\"} className=\"grid size-4 shrink-0 place-items-center\">\n              <motion.span\n                key={unseen}\n                className=\"grid place-items-center transition-transform duration-200 group-hover/jump:translate-y-px\"\n                initial={reduce || unseen === 0 ? false : { y: -5, opacity: 0.3 }}\n                animate={{ y: 0, opacity: 1 }}\n                transition={spring.pop}\n              >\n                <ArrowDown size={16} />\n              </motion.span>\n            </motion.span>\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              {loud && (\n                <motion.span\n                  key=\"count\"\n                  layout={reduce ? false : \"position\"}\n                  className=\"flex items-center gap-1 whitespace-nowrap tabular\"\n                  initial={{ opacity: 0, filter: reduce ? \"blur(0px)\" : \"blur(2px)\" }}\n                  animate={{ opacity: 1, filter: \"blur(0px)\" }}\n                  exit={{ opacity: 0, transition: { duration: 0.08 } }}\n                  transition={{ duration: 0.2, ease: ease.out }}\n                  aria-hidden\n                >\n                  <NumberFlow value={unseen} animated={!reduce} className=\"leading-none\" />\n                  <span>{words}</span>\n                </motion.span>\n              )}\n            </AnimatePresence>\n          </motion.button>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/jump-to-latest"
}
