{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "unread-divider",
  "type": "registry:ui",
  "title": "Unread Divider",
  "description": "New-messages line that draws out, counts up and folds away once read.",
  "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/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/unread-divider.tsx",
      "content": "\"use client\";\nimport NumberFlow from \"@number-flow/react\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useRef } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\n/**\n * Calls back once an element has been fully on screen, in a visible tab, for `delay` ms\n * without a break. Scrolling it away or switching tabs restarts the wait.\n */\nexport function useSeen(ref: React.RefObject<Element | null>, { delay = 3000, enabled = true, onSeen }: { delay?: number; enabled?: boolean; onSeen: () => void }) {\n  const cb = useRef(onSeen);\n  useEffect(() => {\n    cb.current = onSeen;\n  });\n  useEffect(() => {\n    const el = ref.current;\n    if (!el || !enabled) return;\n    let visible = false;\n    let timer: number | undefined;\n    const sync = () => {\n      window.clearTimeout(timer);\n      timer = undefined;\n      if (visible && document.visibilityState === \"visible\") timer = window.setTimeout(() => cb.current(), delay);\n    };\n    // The browser clips the target by every scrolling ancestor, so this works inside a chat pane too.\n    const io = new IntersectionObserver(\n      ([entry]) => {\n        visible = !!entry && entry.intersectionRatio >= 0.98;\n        sync();\n      },\n      { threshold: [0, 0.98, 1] },\n    );\n    io.observe(el);\n    document.addEventListener(\"visibilitychange\", sync);\n    return () => {\n      io.disconnect();\n      document.removeEventListener(\"visibilitychange\", sync);\n      window.clearTimeout(timer);\n    };\n  }, [ref, delay, enabled]);\n}\n\nfunction scrollParent(el: HTMLElement) {\n  for (let p = el.parentElement; p; p = p.parentElement) {\n    const { overflowY } = getComputedStyle(p);\n    if (/(auto|scroll|overlay)/.test(overflowY) && p.scrollHeight > p.clientHeight) return p;\n  }\n  return null;\n}\n\nexport type UnreadDividerProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** How many messages are new. Rolls when more arrive. Leave out for a plain \"New messages\". */\n  count?: number;\n  /** Replace the words. A function receives the count, for other languages. */\n  label?: string | ((count: number | undefined) => string);\n  /** Controlled seen state. Once true the divider plays its exit. */\n  seen?: boolean;\n  defaultSeen?: boolean;\n  /** Called once, when it has been fully visible for `seenDelay` ms (or `seen` is set). */\n  onSeenChange?: (seen: boolean) => void;\n  /** Milliseconds fully on screen before it counts as read. */\n  seenDelay?: number;\n  /** What happens once seen: collapse away, dim to a quiet line that keeps its place, or stay. */\n  afterSeen?: \"collapse\" | \"dim\" | \"keep\";\n  /** On mount, scroll the nearest scrolling ancestor so the divider sits a quarter of the way down. */\n  scrollOnMount?: boolean;\n};\n\nconst defaultLabel = (count?: number) => (count === undefined ? \"New messages\" : count === 1 ? \"new message\" : \"new messages\");\n\n/**\n * The \"new messages\" line above the first unread message. Its lines draw outward from the label\n * when it arrives; once it has been on screen for a few seconds they retract into it, and the row\n * folds away without yanking what you're reading.\n */\nexport function UnreadDivider({\n  count,\n  label = defaultLabel,\n  seen: seenProp,\n  defaultSeen = false,\n  onSeenChange,\n  seenDelay = 3000,\n  afterSeen = \"collapse\",\n  scrollOnMount = false,\n  className,\n  ref,\n  ...rest\n}: UnreadDividerProps) {\n  const reduce = !!useReducedMotion();\n  const root = useRef<HTMLDivElement | null>(null);\n  const [seen, setSeen] = useControllableState({ value: seenProp, defaultValue: defaultSeen, onChange: onSeenChange });\n\n  useSeen(root, { delay: seenDelay, enabled: !seen, onSeen: () => setSeen(true) });\n\n  useEffect(() => {\n    const el = root.current;\n    if (!scrollOnMount || !el) return;\n    // Scroll only the chat pane, never the page around it.\n    const pane = scrollParent(el);\n    if (!pane) return;\n    const top = el.getBoundingClientRect().top - pane.getBoundingClientRect().top + pane.scrollTop;\n    pane.scrollTop = Math.max(0, top - pane.clientHeight * 0.25);\n    // Only on mount: later count changes must not move the reader.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  const custom = typeof label === \"function\" && label !== defaultLabel;\n  const text = typeof label === \"string\" ? label : label(count);\n  const spoken = custom || typeof label === \"string\" || count === undefined ? text : `${count} ${text}`;\n\n  const gone = seen && afterSeen === \"collapse\";\n  const dim = seen && afterSeen === \"dim\";\n  const state = gone ? \"gone\" : dim ? \"dim\" : \"new\";\n\n  // Lines grow out of the label on arrival and retract into it on the way out. The starting\n  // style is the same on the server and client; reduced motion just zeroes the travel.\n  const still = { duration: 0 };\n  const line = (side: \"left\" | \"right\") => (\n    <motion.span\n      aria-hidden\n      className={cn(\"h-px flex-1 transition-colors duration-500\", dim ? \"bg-fg/10\" : \"bg-fg/25\", side === \"left\" ? \"origin-right\" : \"origin-left\")}\n      initial={{ scaleX: 0, opacity: 0 }}\n      animate={gone ? { scaleX: reduce ? 1 : 0, opacity: 0 } : { scaleX: 1, opacity: 1 }}\n      transition={\n        gone\n          ? { duration: reduce ? 0.2 : 0.3, ease: ease.in, scaleX: reduce ? still : undefined }\n          : { duration: reduce ? 0.2 : 0.55, ease: ease.out, delay: reduce ? 0 : 0.06, scaleX: reduce ? still : undefined }\n      }\n    />\n  );\n\n  return (\n    <div\n      ref={(node) => {\n        root.current = node;\n        if (typeof ref === \"function\") ref(node);\n        else if (ref) ref.current = node;\n      }}\n      role=\"separator\"\n      aria-label={spoken}\n      aria-hidden={gone || undefined}\n      data-state={state}\n      className={cn(\n        // grid-template-rows folds the row to nothing once the label has gone, after a short hold.\n        \"grid transition-[grid-template-rows] duration-300 ease-in-out-quart\",\n        gone ? \"grid-rows-[0fr] delay-300\" : \"grid-rows-[1fr]\",\n        className,\n      )}\n      {...rest}\n    >\n      <div className=\"min-h-0 overflow-hidden\">\n        <div className=\"flex items-center gap-3 py-2\">\n          {line(\"left\")}\n          <motion.span\n            aria-hidden\n            className={cn(\n              \"inline-flex h-5 shrink-0 items-center gap-1 rounded-full border px-2 text-[11px] font-medium leading-none tabular transition-[color,border-color,background-color] duration-500\",\n              dim ? \"border-line bg-transparent text-fg-3\" : \"border-line-2 bg-raised text-fg shadow-[var(--shadow)]\",\n            )}\n            initial={{ opacity: 0, scale: 0.94, y: 3, filter: \"blur(2px)\" }}\n            animate={gone && !reduce ? { opacity: 0, scale: 0.94, filter: \"blur(2px)\" } : { opacity: gone ? 0 : 1, scale: 1, y: 0, filter: \"blur(0px)\" }}\n            transition={{\n              duration: gone ? 0.2 : reduce ? 0.2 : 0.32,\n              ease: gone ? ease.in : ease.out,\n              delay: gone && !reduce ? 0.14 : 0,\n              ...(reduce ? { scale: still, y: still, filter: still } : {}),\n            }}\n          >\n            {!custom && typeof label !== \"string\" && count !== undefined && <NumberFlow value={count} animated={!reduce} className=\"leading-none\" />}\n            <span>{text}</span>\n          </motion.span>\n          {line(\"right\")}\n        </div>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/unread-divider"
}
