{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"log-stream","type":"registry:component","title":"Log stream","description":"A live tail you can actually read: scroll up and it lets go while a \"↓ 128 new\" pill counts what arrives, with pause, level counts, search and lines that open into their fields.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/text-morph.json?as=dependency","https://hairlineui.com/r/number-roll.json?as=dependency"],"files":[{"path":"registry/log-stream.tsx","type":"registry:component","content":"// Hairline UI · log-stream · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { ArrowDown, Search } from \"lucide-react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * LOG STREAM: a live tail you can actually read\n *\n *   tail      lines arrive in batches, one render per frame\n *             however fast they come, and the view stays\n *             pinned to the newest line\n *   read      scroll up and the view lets go: lines keep\n *             arriving underneath, and a \"↓ 128 new\" pill rises\n *             with its count rolling; one tap takes you back\n *             down and pins it again\n *   pause     Live → Paused holds new lines back without\n *             losing them (\"Paused · 42 waiting\"); Live plays\n *             them in\n *   filter    level chips count as lines arrive and hide what\n *             you don't need; search marks every match (\"/\"\n *             focuses it)\n *   open      a line with data opens into its fields under it,\n *             by height, with Copy\n *   earlier   reach the top and older lines load in above\n *             without moving what you're reading\n *\n * Lines are plain records; subscribe() feeds them from SSE, a\n * WebSocket, Supabase Realtime or a poll.\n * ───────────────────────────────────────────────────────── */\n\nexport type LogLevel = \"debug\" | \"info\" | \"warn\" | \"error\";\nexport type DateLike = Date | string | number;\n\nexport interface LogLine {\n  id: string;\n  at: DateLike;\n  level: LogLevel;\n  message: string;\n  /** Where it came from: \"api\", \"worker\", \"cron\". */\n  source?: string;\n  /** Fields shown when the line is opened. */\n  data?: Record<string, unknown>;\n}\n\nexport interface LogStreamProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"defaultValue\"> {\n  /** Lines to start with, oldest first. */\n  defaultValue?: LogLine[];\n  /** Starts the tail: call push with new lines; return a function that stops it. */\n  subscribe?: (push: (lines: LogLine | LogLine[]) => void) => () => void;\n  /** Loads lines older than the first one shown, oldest first; an empty list means there are no more. */\n  loadEarlier?: (before: LogLine) => Promise<LogLine[]>;\n  /** Lines kept in memory while you're at the bottom. */\n  max?: number;\n  /** The scrolling area's height. */\n  height?: number | string;\n  title?: string;\n}\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst LEVELS: LogLevel[] = [\"error\", \"warn\", \"info\", \"debug\"];\nconst NAMES: Record<LogLevel, string> = { error: \"Errors\", warn: \"Warnings\", info: \"Info\", debug: \"Debug\" };\nconst TONE: Record<LogLevel, string> = {\n  error: \"text-red-600 dark:text-red-400\",\n  warn: \"text-amber-700 dark:text-amber-400\",\n  info: \"text-muted-foreground\",\n  debug: \"text-muted-foreground/60\",\n};\nconst TICK: Record<LogLevel, string> = { error: \"bg-red-500\", warn: \"bg-amber-500\", info: \"bg-transparent\", debug: \"bg-transparent\" };\nconst NONE: LogLine[] = [];\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nconst toTime = (d: DateLike) => (d instanceof Date ? d.getTime() : typeof d === \"number\" ? d : new Date(d).getTime());\nconst pad = (n: number, w = 2) => String(n).padStart(w, \"0\");\nconst clock = (d: DateLike) => {\n  const t = new Date(toTime(d));\n  return `${pad(t.getHours())}:${pad(t.getMinutes())}:${pad(t.getSeconds())}.${pad(t.getMilliseconds(), 3)}`;\n};\n\n/** The message with every match marked. */\nfunction Marked({ text, query }: { text: string; query: string }) {\n  if (!query) return <>{text}</>;\n  const parts: React.ReactNode[] = [];\n  const lower = text.toLowerCase();\n  const q = query.toLowerCase();\n  let i = 0;\n  let at = lower.indexOf(q);\n  while (at !== -1) {\n    if (at > i) parts.push(text.slice(i, at));\n    parts.push(\n      <mark key={at} className=\"rounded-[3px] bg-primary/15 text-inherit\">\n        {text.slice(at, at + q.length)}\n      </mark>,\n    );\n    i = at + q.length;\n    at = lower.indexOf(q, i);\n  }\n  parts.push(text.slice(i));\n  return <>{parts}</>;\n}\n\nfunction Value({ v }: { v: unknown }) {\n  if (v === null || v === undefined) return <span className=\"text-muted-foreground\">{String(v)}</span>;\n  if (typeof v === \"number\" || typeof v === \"boolean\") return <span className=\"text-primary\">{String(v)}</span>;\n  if (typeof v === \"string\") return <span className=\"text-foreground\">&quot;{v}&quot;</span>;\n  return <span className=\"text-foreground\">{JSON.stringify(v)}</span>;\n}\n\ninterface RowProps {\n  line: LogLine;\n  open: boolean;\n  query: string;\n  enter: boolean;\n  reduced: boolean;\n  toggle: React.RefObject<(id: string) => void>;\n}\n\nconst Row = React.memo(function Row({ line, open, query, enter, reduced, toggle }: RowProps) {\n  const ref = React.useRef<HTMLDivElement>(null);\n  const [copied, setCopied] = React.useState(false);\n  const has = Boolean(line.data && Object.keys(line.data).length);\n\n  React.useLayoutEffect(() => {\n    if (enter && !reduced) ref.current?.animate([{ opacity: 0, filter: \"blur(2px)\" }, { opacity: 1, filter: \"blur(0px)\" }], { duration: 260, easing: EASE });\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n  React.useEffect(() => {\n    if (!copied) return;\n    const timer = window.setTimeout(() => setCopied(false), 1400);\n    return () => window.clearTimeout(timer);\n  }, [copied]);\n\n  const head = (\n    <>\n      <span aria-hidden=\"true\" className={`absolute top-1.5 bottom-1.5 left-0 w-0.5 rounded-full ${TICK[line.level]}`} />\n      <time dateTime={new Date(toTime(line.at)).toISOString()} className=\"hidden w-[6.6rem] shrink-0 text-muted-foreground tabular-nums @md:block\">\n        {clock(line.at)}\n      </time>\n      <span className={`w-[2.6rem] shrink-0 uppercase ${TONE[line.level]}`}>{line.level === \"error\" ? \"err\" : line.level}</span>\n      {line.source && <span className=\"hidden w-[4.5rem] shrink-0 truncate text-muted-foreground @sm:block\">{line.source}</span>}\n      <span className=\"min-w-0 flex-1 truncate text-foreground\">\n        <Marked text={line.message} query={query} />\n      </span>\n    </>\n  );\n\n  return (\n    <div ref={ref} role=\"listitem\" className=\"[contain-intrinsic-size:auto_28px] [content-visibility:auto]\">\n      {has ? (\n        <button\n          type=\"button\"\n          aria-expanded={open}\n          onClick={() => toggle.current(line.id)}\n          className={`relative flex h-7 w-full cursor-pointer items-center gap-3 rounded-[6px] pr-2 pl-3 text-left transition-colors hover:bg-accent/70 ${open ? \"bg-accent/70\" : \"\"} ${FOCUS}`}\n        >\n          {head}\n        </button>\n      ) : (\n        <div className=\"relative flex h-7 items-center gap-3 pr-2 pl-3\">{head}</div>\n      )}\n      {has && (\n        <div className=\"grid\" inert={!open} style={{ gridTemplateRows: open ? \"1fr\" : \"0fr\", transition: reduced ? \"none\" : `grid-template-rows 320ms ${EASE}` }}>\n          <div className=\"min-h-0 min-w-0 overflow-clip\">\n            {open && (\n              <div className=\"relative my-1 ml-3 rounded-[10px] px-3 py-2 shadow-[inset_0_0_0_1px_var(--border)] @md:ml-[7.6rem]\">\n                <button\n                  type=\"button\"\n                  onClick={async () => {\n                    try {\n                      await navigator.clipboard.writeText(JSON.stringify({ at: new Date(toTime(line.at)).toISOString(), level: line.level, source: line.source, message: line.message, ...line.data }, null, 2));\n                      setCopied(true);\n                    } catch {\n                      /* The fields are on screen to select. */\n                    }\n                  }}\n                  className={`absolute top-1.5 right-1.5 h-6 cursor-pointer rounded-full px-2 font-sans text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground ${FOCUS}`}\n                >\n                  <TextMorph>{copied ? \"Copied\" : \"Copy\"}</TextMorph>\n                </button>\n                <dl className=\"grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-0.5 pr-14\">\n                  {Object.entries(line.data!).map(([k, v]) => (\n                    <React.Fragment key={k}>\n                      <dt className=\"text-muted-foreground\">{k}</dt>\n                      <dd className=\"min-w-0 break-all\">\n                        <Value v={v} />\n                      </dd>\n                    </React.Fragment>\n                  ))}\n                </dl>\n              </div>\n            )}\n          </div>\n        </div>\n      )}\n    </div>\n  );\n});\n\nexport function LogStream({ defaultValue = NONE, subscribe, loadEarlier, max = 1000, height = 420, title = \"Logs\", className = \"\", ...props }: LogStreamProps) {\n  const reduced = useReducedMotion();\n  const id = React.useId();\n  const [lines, setLines] = React.useState<LogLine[]>(defaultValue);\n  const [paused, setPaused] = React.useState(false);\n  const [held, setHeld] = React.useState(0);\n  const [below, setBelow] = React.useState(0);\n  const [query, setQuery] = React.useState(\"\");\n  const [shown, setShown] = React.useState<Set<LogLevel>>(() => new Set(LEVELS));\n  const [open, setOpen] = React.useState<Set<string>>(() => new Set());\n  const [loading, setLoading] = React.useState(false);\n  const [noMore, setNoMore] = React.useState(!loadEarlier);\n  const [fresh, setFresh] = React.useState<Set<string>>(() => new Set());\n  const [announce, setAnnounce] = React.useState(\"\");\n\n  const viewRef = React.useRef<HTMLDivElement>(null);\n  const searchRef = React.useRef<HTMLInputElement>(null);\n  const sentinelRef = React.useRef<HTMLDivElement>(null);\n  const incoming = React.useRef<LogLine[]>([]);\n  const holding = React.useRef<LogLine[]>([]);\n  const frame = React.useRef(0);\n  const stuck = React.useRef(true);\n  const intent = React.useRef(0);\n  const keep = React.useRef<number | null>(null);\n  const pausedRef = React.useRef(paused);\n  pausedRef.current = paused;\n  const linesRef = React.useRef(lines);\n  linesRef.current = lines;\n\n  const toggle = React.useRef<(id: string) => void>(() => {});\n  toggle.current = (lineId) =>\n    setOpen((o) => {\n      const next = new Set(o);\n      if (next.has(lineId)) next.delete(lineId);\n      else next.add(lineId);\n      return next;\n    });\n\n  // New lines wait for the next frame, so a burst is one render.\n  const flush = React.useCallback(() => {\n    frame.current = 0;\n    const batch = incoming.current;\n    incoming.current = [];\n    if (!batch.length) return;\n    setLines((prev) => {\n      const next = prev.concat(batch);\n      // Trim only while pinned to the bottom, so lines never vanish from under someone reading.\n      return stuck.current && next.length > max ? next.slice(next.length - max) : next;\n    });\n    setFresh(batch.length <= 12 ? new Set(batch.map((l) => l.id)) : new Set());\n    if (!stuck.current) setBelow((b) => b + batch.length);\n  }, [max]);\n\n  const push = React.useCallback(\n    (input: LogLine | LogLine[]) => {\n      const list = Array.isArray(input) ? input : [input];\n      if (pausedRef.current) {\n        holding.current.push(...list);\n        setHeld(holding.current.length);\n        return;\n      }\n      incoming.current.push(...list);\n      if (!frame.current) frame.current = requestAnimationFrame(flush);\n    },\n    [flush],\n  );\n\n  React.useEffect(() => {\n    if (!subscribe) return;\n    const stop = subscribe(push);\n    return () => {\n      stop();\n      cancelAnimationFrame(frame.current);\n      frame.current = 0;\n    };\n  }, [subscribe, push]);\n\n  // Pinned: every commit ends on the newest line. Loading earlier: the line you were reading stays put.\n  React.useLayoutEffect(() => {\n    const view = viewRef.current;\n    if (!view) return;\n    if (keep.current !== null) {\n      view.scrollTop += view.scrollHeight - keep.current;\n      keep.current = null;\n      return;\n    }\n    if (stuck.current) view.scrollTop = view.scrollHeight;\n  }, [lines, shown, query]);\n\n  const onScroll = () => {\n    const view = viewRef.current;\n    if (!view) return;\n    const atBottom = view.scrollHeight - view.scrollTop - view.clientHeight < 24;\n    if (atBottom) {\n      stuck.current = true;\n      setBelow(0);\n    } else if (performance.now() - intent.current < 900) {\n      // Only a scroll the reader made lets go; content changing size doesn't.\n      stuck.current = false;\n    }\n  };\n  const meant = () => (intent.current = performance.now());\n\n  const toBottom = () => {\n    const view = viewRef.current;\n    if (!view) return;\n    stuck.current = true;\n    setBelow(0);\n    view.scrollTo({ top: view.scrollHeight, behavior: reduced ? \"auto\" : \"smooth\" });\n  };\n\n  const setLive = (live: boolean) => {\n    setPaused(!live);\n    if (live) {\n      const waiting = holding.current;\n      holding.current = [];\n      setHeld(0);\n      if (waiting.length) {\n        incoming.current.push(...waiting);\n        if (!frame.current) frame.current = requestAnimationFrame(flush);\n      }\n      setAnnounce(waiting.length ? `Live. ${waiting.length} lines played in.` : \"Live.\");\n    } else setAnnounce(\"Paused. New lines are held.\");\n  };\n\n  // Older lines, when the top comes into view.\n  React.useEffect(() => {\n    const el = sentinelRef.current;\n    const view = viewRef.current;\n    if (!el || !view || !loadEarlier || noMore) return;\n    const observer = new IntersectionObserver(\n      async ([entry]) => {\n        if (!entry.isIntersecting || loading) return;\n        const first = linesRef.current[0];\n        if (!first) return;\n        setLoading(true);\n        try {\n          const older = await loadEarlier(first);\n          if (!older.length) setNoMore(true);\n          else {\n            keep.current = viewRef.current?.scrollHeight ?? null;\n            setFresh(new Set());\n            setLines((prev) => older.concat(prev));\n          }\n        } finally {\n          setLoading(false);\n        }\n      },\n      { root: view, rootMargin: \"120px 0px 0px 0px\" },\n    );\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [loadEarlier, noMore, loading]);\n\n  React.useEffect(() => () => cancelAnimationFrame(frame.current), []);\n\n  const counts = React.useMemo(() => {\n    const c: Record<LogLevel, number> = { error: 0, warn: 0, info: 0, debug: 0 };\n    for (const l of lines) c[l.level]++;\n    return c;\n  }, [lines]);\n  const q = query.trim();\n  const visible = React.useMemo(() => lines.filter((l) => shown.has(l.level) && (!q || l.message.toLowerCase().includes(q.toLowerCase()) || l.source?.toLowerCase().includes(q.toLowerCase()))), [lines, shown, q]);\n\n  return (\n    <div\n      className={`@container w-full rounded-[22px] border border-border bg-background text-foreground ${className}`}\n      onKeyDown={(e) => {\n        if (e.key === \"/\" && document.activeElement !== searchRef.current) {\n          e.preventDefault();\n          searchRef.current?.focus();\n        } else if (e.key === \"Escape\" && document.activeElement === searchRef.current && query) {\n          setQuery(\"\");\n        }\n      }}\n      {...props}\n    >\n      <div className=\"flex flex-wrap items-center gap-2 border-b border-border px-3 py-2.5 @md:px-4\">\n        <button\n          type=\"button\"\n          aria-pressed={!paused}\n          onClick={() => setLive(paused)}\n          className={`inline-flex h-7 shrink-0 cursor-pointer items-center gap-2 rounded-full px-2.5 text-[12px] font-medium shadow-[inset_0_0_0_1px_var(--border)] transition-colors hover:bg-accent ${FOCUS}`}\n        >\n          <span className=\"relative grid size-2 place-items-center\" aria-hidden=\"true\">\n            <span className={`size-1.5 rounded-full transition-colors duration-300 ${paused ? \"bg-muted-foreground\" : \"bg-emerald-500\"}`} />\n            {!paused && !reduced && <span className=\"absolute inset-0 rounded-full bg-emerald-500 opacity-40 animate-[ui-ping_1.6s_ease-out_infinite]\" />}\n          </span>\n          <TextMorph>{paused ? \"Paused\" : \"Live\"}</TextMorph>\n          <span\n            aria-hidden={!(paused && held) || undefined}\n            className=\"-ml-2 inline-grid text-muted-foreground\"\n            style={{ gridTemplateColumns: paused && held ? \"1fr\" : \"0fr\", opacity: paused && held ? 1 : 0, transition: reduced ? \"none\" : `grid-template-columns 380ms ${EASE}, opacity 240ms ${EASE}` }}\n          >\n            <span className=\"min-w-0 [clip-path:inset(-4px_0)]\">\n              <span className=\"inline-flex items-baseline pl-2 font-normal whitespace-nowrap tabular-nums\">\n                ·&nbsp;\n                <NumberRoll value={held || 1} />\n                &nbsp;waiting\n              </span>\n            </span>\n          </span>\n        </button>\n\n        <label className=\"flex h-7 min-w-[9rem] flex-1 items-center gap-1.5 rounded-full px-2.5 shadow-[inset_0_0_0_1px_var(--border)] transition-shadow focus-within:shadow-[inset_0_0_0_1px_var(--ring)]\">\n          <Search className=\"size-3.5 shrink-0 text-muted-foreground\" strokeWidth={1.75} aria-hidden=\"true\" />\n          <span className=\"sr-only\">Search {title}</span>\n          <input\n            ref={searchRef}\n            type=\"search\"\n            value={query}\n            onChange={(e) => setQuery(e.target.value)}\n            placeholder=\"Search\"\n            className=\"h-full min-w-0 flex-1 bg-transparent text-[16px] text-foreground outline-none placeholder:text-muted-foreground/70 [@media(pointer:fine)]:text-[12.5px] [&::-webkit-search-cancel-button]:hidden\"\n          />\n          <kbd aria-hidden=\"true\" className=\"hidden rounded-[5px] px-1 font-mono text-[10.5px] text-muted-foreground shadow-[inset_0_0_0_1px_var(--border)] [@media(pointer:fine)]:block\">\n            /\n          </kbd>\n        </label>\n\n        <div role=\"group\" aria-label=\"Levels\" className=\"-mx-1 flex max-w-full items-center gap-1 overflow-x-auto px-1 [scrollbar-width:none] @lg:mx-0 @lg:px-0\">\n          {LEVELS.map((lv) => {\n            const on = shown.has(lv);\n            return (\n              <button\n                key={lv}\n                type=\"button\"\n                aria-pressed={on}\n                onClick={() =>\n                  setShown((s) => {\n                    const next = new Set(s);\n                    if (next.has(lv)) next.delete(lv);\n                    else next.add(lv);\n                    return next;\n                  })\n                }\n                className={`inline-flex h-7 shrink-0 cursor-pointer items-center gap-1.5 rounded-full px-2.5 text-[12px] font-medium whitespace-nowrap transition-[background-color,color,box-shadow,opacity] duration-200 ${FOCUS} ${\n                  on ? \"bg-accent text-foreground shadow-[inset_0_0_0_1px_var(--border)]\" : \"text-muted-foreground opacity-60 hover:opacity-100\"\n                }`}\n              >\n                {(lv === \"error\" || lv === \"warn\") && <span aria-hidden=\"true\" className={`size-1.5 rounded-full ${TICK[lv]}`} />}\n                {NAMES[lv]}\n                <span className=\"font-normal text-muted-foreground tabular-nums\">\n                  <NumberRoll value={counts[lv]} />\n                </span>\n              </button>\n            );\n          })}\n        </div>\n      </div>\n\n      <div className=\"relative\">\n        <div\n          ref={viewRef}\n          onScroll={onScroll}\n          onWheel={meant}\n          onTouchMove={meant}\n          onKeyDown={meant}\n          onPointerDown={(e) => e.target === e.currentTarget && meant()}\n          tabIndex={0}\n          aria-label={`${title}, newest at the bottom`}\n          className={`overflow-y-auto overscroll-contain px-1.5 py-1.5 font-mono text-[12px] @md:px-2 ${FOCUS} focus-visible:ring-inset`}\n          style={{ height }}\n        >\n          <div ref={sentinelRef} aria-hidden=\"true\" className=\"h-px\" />\n          {loadEarlier && (\n            <p className=\"flex h-7 items-center justify-center gap-2 font-sans text-[11.5px] text-muted-foreground\">\n              {noMore ? \"Start of the log\" : loading ? \"Loading earlier lines\" : \"Scroll up for earlier lines\"}\n            </p>\n          )}\n          <div role=\"list\" aria-label={title} id={`${id}-list`}>\n            {visible.map((l) => (\n              <Row key={l.id} line={l} open={open.has(l.id)} query={q} enter={fresh.has(l.id)} reduced={reduced} toggle={toggle} />\n            ))}\n          </div>\n          {!visible.length && (\n            <p className=\"flex h-24 items-center justify-center font-sans text-[12.5px] text-muted-foreground\">\n              {lines.length ? \"No lines match.\" : paused ? \"Paused.\" : \"Waiting for the first line…\"}\n            </p>\n          )}\n        </div>\n\n        {/* Lines that arrived while you were reading further up. */}\n        <div className=\"pointer-events-none absolute inset-x-0 bottom-3 flex justify-center\">\n          <button\n            type=\"button\"\n            onClick={toBottom}\n            tabIndex={below ? 0 : -1}\n            aria-hidden={!below || undefined}\n            className={`pointer-events-auto inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-full bg-background px-3 text-[12px] font-medium shadow-[inset_0_0_0_1px_var(--border)] transition-[transform,opacity] hover:bg-accent ${FOCUS} ${below ? \"\" : \"pointer-events-none\"}`}\n            style={{ opacity: below ? 1 : 0, transform: below ? \"none\" : \"translateY(10px) scale(0.96)\", transitionDuration: reduced ? \"0ms\" : \"420ms\", transitionTimingFunction: EASE }}\n          >\n            <ArrowDown className=\"size-3.5\" strokeWidth={2} aria-hidden=\"true\" />\n            <span className=\"inline-flex items-baseline tabular-nums\">\n              <NumberRoll value={below || 1} />\n              &nbsp;new\n            </span>\n          </button>\n        </div>\n      </div>\n\n      <span className=\"sr-only\" role=\"status\">\n        {announce}\n      </span>\n    </div>\n  );\n}\n"}],"css":{"@keyframes ui-ping":{"from":{"transform":"scale(1)","opacity":"0.55"},"to":{"transform":"scale(2.6)","opacity":"0"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/log-stream. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}