{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tab-title-badge",
  "title": "Tab Title Badge",
  "description": "A tab-title badge: it puts \"(3)\" in front of the page title while the reader is in another tab, takes it back off the moment they return, and never steals the title from the router. Reach for it wherever something arrives while nobody is looking: unread messages in a chat, support inbox or team channel, a notification or alert count on a dashboard, an export, import, render or report that finishes minutes after it was kicked off, a CI run, deploy, migration or test suite a developer is waiting on, a background sync or upload queue, approvals or review requests piling up, a live auction or bid about to close, a pomodoro or cooking timer ending, the other player's turn, and a call or meeting waiting to be joined. Common asks it answers: \"react document title unread count\", \"set document.title in react\", \"useDocumentTitle hook\", \"tab title notification badge\", \"show unread count in browser tab\", \"put (1) in the tab title react\", \"unread badge in page title\", \"blinking tab title react\", \"flash tab title on new message\", \"clear the tab badge when the user comes back\", \"detect when the user returns to the tab react\", \"visibilitychange react hook\", \"document.title resets on route change\", \"title flickers after navigation next.js\", \"document.title not updating in next app router\", \"restore the original document title on unmount\", \"document.title restored to the wrong page title\", \"two components fighting over document.title\", \"react-helmet just for a counter\", \"title badge shadcn\", \"notification count in tab title\". shadcn/ui ships nothing that touches the tab: measured against the live catalogue on 2026-10-27 (65 items indexed, the 62 fetchable ones concatenated to 223,287 bytes of source), document.title, favicon, visibilitychange, Notification, rel=\"icon\" and unread are all zero hits. It is the quiet counterpart to pulld install-prompt and notification-permission — it tells the reader something is waiting without asking the browser for permission to interrupt them, and unlike a toast it is still there when they come back in an hour. Written from the platform up, because the obvious version — read document.title on mount, prefix the count, put the remembered string back on unmount — is wrong in four separate ways that all look fine on one static page, and all four were measured in a real browser rather than remembered. One: the remembered title goes stale. In a single-page app the router writes a new title on every navigation, so the value captured at mount belongs to whatever page was open then, and restoring it puts a dead page's title over the live one; this hook adopts every title it did not write as the new base, so the badge always lands on the page the reader is actually on. Two: writing the same string still counts as a change — assigning document.title a value it already holds fires a mutation record all the same, so \"re-apply the badge whenever the title changes\" answers its own notification forever; in the probe it looped until a counter cut it off, and here one equality check ends it. Three: the change does not arrive where a first guess puts it. A plain assignment is a childList record on <title>, not characterData, and a title library that swaps the whole <title> element produces a childList record on <head> and leaves an observer bound to a node that is no longer in the document; one observer on <head> with subtree catches both, and neither narrower choice catches the other. Four: there is exactly one document.title, so a second instance is not a second badge but a second writer — the first to mount owns the title and the rest report isOwner false and hold still, the same shape pulld fullscreen-button and read-aloud use for the other page-wide APIs. The API is the count and what to do about it: count (0 and anything below 1 shows nothing), label for a word instead of a number (\"New\", \"2 failed\"), max to write anything larger as \"99+\", template to choose the join (\"({badge}) {title}\" by default, \"{badge} • {title}\" and \"{title} — {badge} new\" both work), showWhileSeen to keep it up in front of a reader who is already here, blinkMs to alternate with the page's own title, and onSeen to be told when they come back so your own counter can go to zero — the badge is a view of your state, never a copy of it. seenRule picks what \"looking\" means: the default follows Page Visibility, and \"focus\" additionally requires the window to be in front, which matters because a tab stays visible while its whole window sits behind the editor, so under the default rule a message that arrives while the reader is in another application clears itself unseen. useTabTitleBadge is the hook; <TabTitleBadge count={unread} /> is the same thing declaratively and renders nothing at all, because what it writes lives in the tab rather than on the page. Server-safe, no dependencies, not even an icon.",
  "files": [
    {
      "path": "registry/ui/tab-title-badge.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\n/**\n * Put \"(3)\" in front of the tab title while the reader is somewhere else, and take it back off\n * without stealing the title from the page.\n *\n * Everything below was written against a probe page driven in a real browser (Chromium / macOS,\n * 2026-10-27), because the obvious four-line version — remember `document.title` on mount, write\n * `\"(\" + count + \") \" + remembered`, put the remembered one back on unmount — is wrong in four\n * separate ways that all look fine on a single static page:\n *\n *   - The remembered title goes stale. In a single-page app the router writes a new title on every\n *     navigation, so the value captured at mount belongs to whatever page was open *then*;\n *     restoring it on unmount puts the old page's title back over the new page.\n *   - Writing the same string still counts as a change. Measured: assigning `document.title` a\n *     value it already holds fires a mutation record all the same. So \"re-apply the badge whenever\n *     the title changes\" re-enters its own observer forever — in the probe it looped until a\n *     counter cut it off.\n *   - The change does not arrive where a first guess puts it. `document.title = x` replaces the\n *     text node inside `<title>`, which is a **childList** record on that element, not\n *     `characterData`; and a framework that swaps the whole `<title>` element (what title-managing\n *     libraries do) produces a childList record on `<head>` and leaves an observer bound to the old\n *     element watching a node that is no longer in the document. One observer on `<head>` with\n *     `subtree` catches both; neither narrower choice catches the other case.\n *   - Two of these on one page fight over the same string. There is one `document.title`, so a\n *     second instance is not a second badge, it is a second writer — hence the module-scope owner\n *     below, the same shape pulld's fullscreen-button and read-aloud use for the other page-wide\n *     APIs.\n *\n * What is deliberately *not* here: stamping the number onto the favicon. The mechanics work (an SVG\n * favicon draws onto a canvas at an explicit size, and `toDataURL` returns a PNG for a same-origin\n * icon — both measured), but a swapped `<link rel=\"icon\">` produced no icon fetch and no visible\n * change anywhere it could be observed, so shipping it would mean shipping an effect nobody had\n * seen happen.\n */\n\n/** The template tokens, so the regex-free substitution below has one spelling. */\nconst BADGE_TOKEN = \"{badge}\"\nconst TITLE_TOKEN = \"{title}\"\n\n/** `\"(3) Inbox\"` — the convention every mail client and chat app already taught people to read. */\nexport const DEFAULT_TEMPLATE = `(${BADGE_TOKEN}) ${TITLE_TOKEN}`\n\nexport interface FormatBadgeOptions {\n  /** How many unseen things there are. Anything below 1 is nothing to announce. */\n  count?: number\n  /** Show this exact text instead of a number — `\"New\"`, `\"●\"`, `\"2 failed\"`. Wins over `count`. */\n  label?: string\n  /** Counts above this are written as `\"99+\"`. */\n  max?: number\n}\n\n/**\n * The badge text, or `null` when there is nothing to show.\n *\n * Separate and pure so the awkward values can be pinned without a document: a count of zero, a\n * negative count from a subtraction that went past the end, a fraction from a progress figure that\n * was never meant to be a count.\n */\nexport function formatBadge({ count, label, max = 99 }: FormatBadgeOptions): string | null {\n  if (label != null && label !== \"\") return label\n  if (count == null || !Number.isFinite(count)) return null\n  // Truncation, not rounding: 1.7 unread items is one item and a bug somewhere upstream, and\n  // showing \"2\" would invent one that does not exist.\n  const whole = Math.trunc(count)\n  if (whole < 1) return null\n  if (max > 0 && whole > max) return `${max}+`\n  return String(whole)\n}\n\n/**\n * Put the badge and the page's own title into the template.\n *\n * Literal substitution, with no cleverness about a template that leaves `{title}` out: a caller who\n * writes `\"{badge} unread\"` gets exactly that, and gets to decide that the page title is not worth\n * the space. The one thing it will not do is leave the token sitting in the tab.\n */\nexport function applyTemplate(template: string, badge: string, title: string): string {\n  return template.split(BADGE_TOKEN).join(badge).split(TITLE_TOKEN).join(title)\n}\n\n/**\n * Which of `visibilitychange` and `focus` decides that the reader is here.\n *\n * `\"visible\"` is the Page Visibility rule: the tab is the frontmost tab of a window that is not\n * minimised. That is the usual meaning of \"they are looking\", and it is the default because it is\n * what the badge is for — the tab is in the background, so put the news where a background tab can\n * show it.\n *\n * `\"focus\"` additionally requires the window itself to be in front. The difference is real and\n * easy to miss: a tab stays `visible` while its whole window sits behind the editor, so under the\n * default rule a message that arrives while the reader is in another application clears itself\n * without ever being seen. Choose this one when a cleared badge is meant to mean \"they read it\".\n */\nexport type SeenRule = \"visible\" | \"focus\"\n\nexport interface UseTabTitleBadgeOptions extends FormatBadgeOptions {\n  /**\n   * How the badge joins the page's own title. Both `{badge}` and `{title}` are substituted.\n   * Examples: `\"({badge}) {title}\"`, `\"{badge} • {title}\"`, `\"{title} — {badge} new\"`.\n   */\n  template?: string\n  /** What counts as the reader being here. See `SeenRule`. */\n  seenRule?: SeenRule\n  /**\n   * Keep the badge up even while the reader is looking at the page.\n   *\n   * Off by default, which is the whole point of the component: a tab the reader is already reading\n   * does not need to be shouted at, and a number that stays in the title while they watch the\n   * thing it counts is the oldest way to look broken.\n   */\n  showWhileSeen?: boolean\n  /**\n   * Alternate between the badged title and the page's own title every N milliseconds. `0` is off,\n   * and off is the default — a blinking tab is a strong thing to do to somebody.\n   *\n   * The alternation is driven by a timer, and a background tab is exactly where browsers slow\n   * timers down to once a second or less. Nothing here depends on the rate: each tick asks for a\n   * title and the single writer below settles it, so a throttled tab blinks slowly rather than\n   * landing on the wrong string.\n   */\n  blinkMs?: number\n  /**\n   * Called when the reader arrives while a badge is showing — zero your own counter here.\n   *\n   * The badge is a view of your state, not a copy of it: this hook never decides that something has\n   * been read. If the count stays where it was, the badge comes back the next time they leave.\n   */\n  onSeen?: () => void\n}\n\nexport interface TabTitleBadgeState {\n  /** The page's own title, as last written by anything that is not this hook. */\n  baseTitle: string\n  /** The badge text, or `null` when nothing is showing. */\n  badge: string | null\n  /** Whether the reader is here, by the rule `seenRule` selects. */\n  isSeen: boolean\n  /**\n   * Whether this instance is the one driving the tab title.\n   *\n   * `false` means another one claimed it first. There is one title, so the second instance holds\n   * still rather than fighting; read this if you would rather surface the clash than let it be\n   * quiet.\n   */\n  isOwner: boolean\n}\n\n/**\n * One owner per document, claimed by whichever instance mounts first.\n *\n * Module scope, like the page-wide APIs in pulld's fullscreen-button and read-aloud: the thing\n * being owned is not this component's state, it is the document's one title.\n */\nlet titleOwner: symbol | null = null\n\n/** True when the document is being looked at under `rule`. SSR-safe: no document, nobody looking. */\nfunction readIsSeen(rule: SeenRule): boolean {\n  if (typeof document === \"undefined\") return false\n  if (document.visibilityState !== \"visible\") return false\n  // `hasFocus()` is the only part of this that distinguishes a window in front from a window\n  // merely un-minimised.\n  if (rule === \"focus\") return typeof document.hasFocus === \"function\" ? document.hasFocus() : true\n  return true\n}\n\n/**\n * The whole behaviour. Renders nothing, because what it changes is the tab, not the page.\n */\nexport function useTabTitleBadge({\n  count,\n  label,\n  max,\n  template = DEFAULT_TEMPLATE,\n  seenRule = \"visible\",\n  showWhileSeen = false,\n  blinkMs = 0,\n  onSeen,\n}: UseTabTitleBadgeOptions = {}): TabTitleBadgeState {\n  const badge = formatBadge({ count, label, max })\n\n  // Starts \"being looked at\" so that the server's render and the first client render agree, and so\n  // that a page which hydrates in the foreground never flashes a badge at a reader who is right\n  // there. Settles on mount.\n  const [isSeen, setIsSeen] = React.useState(true)\n  const [baseTitle, setBaseTitle] = React.useState(\"\")\n  const [isOwner, setIsOwner] = React.useState(false)\n  // Which half of the blink is showing. Meaningless while `blinkMs` is 0.\n  const [blinkOn, setBlinkOn] = React.useState(true)\n\n  // The exact string this hook last put in the tab. The discriminator for \"did that change come\n  // from us?\", and it has to be the value rather than a flag: two writes in one task arrive as one\n  // observer callback carrying two records, so counting callbacks mis-attributes them, while\n  // comparing the title to what was written cannot.\n  const writtenRef = React.useRef<string | null>(null)\n  const baseRef = React.useRef(\"\")\n  const ownerRef = React.useRef<symbol | null>(null)\n\n  const onSeenRef = React.useRef(onSeen)\n  React.useEffect(() => {\n    onSeenRef.current = onSeen\n  }, [onSeen])\n\n  /**\n   * The one place `document.title` is assigned.\n   *\n   * The equality check is not an optimisation. Assigning a title the value it already holds still\n   * fires the mutation observer below, so a writer without this guard answers its own notification\n   * with another write and never stops.\n   */\n  const write = React.useCallback((next: string) => {\n    if (typeof document === \"undefined\") return\n    writtenRef.current = next\n    if (document.title !== next) document.title = next\n  }, [])\n\n  // Claim the title, and hand it back. Written as its own effect so the claim outlives the prop\n  // changes that re-run the effect below, and so React's development double-mount releases and\n  // re-claims in order rather than leaving the document owned by a hook that is gone.\n  React.useEffect(() => {\n    if (typeof document === \"undefined\") return\n    // Already ours. Checked before the claim rather than relying on this effect running exactly\n    // once: re-entering it must not look like a second instance arriving and stand this one down\n    // from a title it is already driving.\n    if (ownerRef.current !== null && titleOwner === ownerRef.current) return\n    if (titleOwner !== null) {\n      setIsOwner(false)\n      return\n    }\n    const token = Symbol(\"tab-title-badge\")\n    titleOwner = token\n    ownerRef.current = token\n    setIsOwner(true)\n    const initial = document.title\n    baseRef.current = initial\n    setBaseTitle(initial)\n    return () => {\n      // The restore is what makes this safe to unmount mid-badge: the page gets its own title back,\n      // whatever the router has made it since this mounted.\n      if (typeof document !== \"undefined\" && document.title !== baseRef.current) {\n        document.title = baseRef.current\n      }\n      writtenRef.current = null\n      if (titleOwner === token) titleOwner = null\n      ownerRef.current = null\n      setIsOwner(false)\n    }\n  }, [])\n\n  // Who is here. Both events, because the two rules need different ones and listening to both costs\n  // nothing: `visibilitychange` for the tab, `focus`/`blur` for the window.\n  React.useEffect(() => {\n    if (typeof document === \"undefined\") return\n    const settle = () => setIsSeen(readIsSeen(seenRule))\n    settle()\n    document.addEventListener(\"visibilitychange\", settle)\n    window.addEventListener(\"focus\", settle)\n    window.addEventListener(\"blur\", settle)\n    return () => {\n      document.removeEventListener(\"visibilitychange\", settle)\n      window.removeEventListener(\"focus\", settle)\n      window.removeEventListener(\"blur\", settle)\n    }\n  }, [seenRule])\n\n  // \"They came back while something was waiting.\" Fired from the transition rather than from the\n  // event, so a window that is alt-tabbed over twice does not report the same arrival twice.\n  const wasSeenRef = React.useRef(true)\n  React.useEffect(() => {\n    const was = wasSeenRef.current\n    wasSeenRef.current = isSeen\n    if (!isOwner || was || !isSeen || badge === null) return\n    onSeenRef.current?.()\n  }, [badge, isOwner, isSeen])\n\n  const wanted = isOwner && badge !== null && (showWhileSeen || !isSeen)\n\n  // The blink timer. Only runs while something is actually showing, so an idle page holds no\n  // interval, and it always leaves the badged half on the way out.\n  //\n  // Held in a ref and cleared before any new one is started, rather than created fresh each time\n  // this effect runs: two intervals on the same swing do not blink twice as visibly, they toggle\n  // twice per period and cancel out, which reads as a badge that never blinks at all.\n  const blinkRef = React.useRef<{ id: ReturnType<typeof setInterval>; ms: number } | null>(null)\n  const stopBlink = React.useCallback(() => {\n    if (blinkRef.current === null) return\n    clearInterval(blinkRef.current.id)\n    blinkRef.current = null\n  }, [])\n\n  // Stopping the swing is its own effect as well as part of the one below, because the one below\n  // returns no cleanup on the run that finds a timer it is happy with — and an interval whose only\n  // owner is a cleanup that was never registered outlives the page it was blinking for.\n  React.useEffect(() => stopBlink, [stopBlink])\n\n  React.useEffect(() => {\n    const stop = stopBlink\n    if (!wanted || blinkMs <= 0) {\n      stop()\n      setBlinkOn(true)\n      return\n    }\n    // Already swinging at this rate. Restarting it here would reset the phase on every unrelated\n    // render, so a page that re-renders often would hold the badge up and never show the other half.\n    if (blinkRef.current?.ms === blinkMs) return\n    stop()\n    blinkRef.current = { id: setInterval(() => setBlinkOn((on) => !on), blinkMs), ms: blinkMs }\n    return () => {\n      stop()\n      setBlinkOn(true)\n    }\n  }, [blinkMs, stopBlink, wanted])\n\n  // What the tab should say, kept where anything that writes can reach it.\n  //\n  // A ref rather than a closure, and this is the whole reason the component does not deadlock: the\n  // observer below outlives the renders that change the badge, so a version that closed over\n  // `badge` and `blinkOn` would have one writer asking for the badged title and another — created a\n  // render earlier — asking for the plain one. Each would see the other's write as somebody else's,\n  // answer it, and the two would trade the title back and forth inside microtasks, which starves\n  // the event loop so completely that no timer ever runs again. Every writer reading the same ref\n  // cannot disagree.\n  const viewRef = React.useRef({ wanted: false, badge: null as string | null, template, blinkOn: true })\n\n  const desiredTitle = React.useCallback(() => {\n    const view = viewRef.current\n    if (!view.wanted || view.badge === null || !view.blinkOn) return baseRef.current\n    return applyTemplate(view.template, view.badge, baseRef.current)\n  }, [])\n\n  // Adopt whatever anyone else puts in the title as the page's own. This is the part that makes the\n  // component more than a one-liner: the title is shared, the router writes it whenever it likes,\n  // and the only honest base title is the most recent one that did not come from here.\n  //\n  // One observer, on `<head>`, with `subtree`. A plain assignment arrives as a childList record on\n  // `<title>`; a library that swaps the element arrives as a childList record on `<head>` and would\n  // be missed entirely by an observer bound to the element that was replaced.\n  React.useEffect(() => {\n    if (typeof document === \"undefined\" || !isOwner) return\n    const observer = new MutationObserver(() => {\n      const current = document.title\n      if (current === writtenRef.current) return\n      // Somebody else owns this string now — a route change, a title library, a stray assignment.\n      // Their value is the page's own title from here on, and the badge goes back on top of it.\n      baseRef.current = current\n      setBaseTitle(current)\n      write(desiredTitle())\n    })\n    observer.observe(document.head, { childList: true, subtree: true, characterData: true })\n    return () => observer.disconnect()\n  }, [desiredTitle, isOwner, write])\n\n  // And keep it up to date as the badge, the blink and the reader's attention change.\n  React.useEffect(() => {\n    if (typeof document === \"undefined\" || !isOwner) return\n    viewRef.current = { wanted, badge, template, blinkOn }\n    write(desiredTitle())\n  }, [badge, blinkOn, desiredTitle, isOwner, template, wanted, write])\n\n  return { baseTitle, badge, isSeen, isOwner }\n}\n\nexport interface TabTitleBadgeProps extends UseTabTitleBadgeOptions {\n  /**\n   * Called on every change, for a page that wants to mirror the tab somewhere else — a heading, a\n   * document-level live region, a test.\n   */\n  onStateChange?: (state: TabTitleBadgeState) => void\n}\n\n/**\n * The declarative form: drop it in a layout and feed it a count.\n *\n * It renders nothing at all. There is no element to show, because what it writes is the one piece\n * of this page that lives outside it — put the same number in the page itself with a badge, a\n * heading, or pulld's stat-card.\n *\n * ```tsx\n * <TabTitleBadge count={unread} onSeen={() => markAllRead()} />\n * ```\n */\nexport function TabTitleBadge({ onStateChange, ...options }: TabTitleBadgeProps) {\n  const state = useTabTitleBadge(options)\n\n  const onStateChangeRef = React.useRef(onStateChange)\n  React.useEffect(() => {\n    onStateChangeRef.current = onStateChange\n  }, [onStateChange])\n\n  const { baseTitle, badge, isSeen, isOwner } = state\n  React.useEffect(() => {\n    onStateChangeRef.current?.({ baseTitle, badge, isSeen, isOwner })\n  }, [baseTitle, badge, isSeen, isOwner])\n\n  return null\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 140 components: https://pulld.pages.dev/?utm_source=cli"
}
