{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"usage-limits","type":"registry:component","title":"Usage limits","description":"What an agent has left: the context window split by what fills it, and each plan limit with its share and a live reset countdown.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/number-roll.json"],"files":[{"path":"registry/usage-limits.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { ChevronDown, ChevronRight } from \"lucide-react\";\nimport { NumberRoll } from \"./number-roll\";\n\n/* ─────────────────────────────────────────────────────────\n * USAGE LIMITS: what an agent has left, at a glance\n *\n *   context   one bar for the context window, split by what's\n *             in it (system, tools, files, conversation); the\n *             chevron folds the breakdown open underneath, and\n *             pointing at a part steps the others back\n *   limits    each plan limit as a bar with its share and when\n *             it resets; under a day away it counts down, the\n *             minutes rolling down like a clock\n *   status    a bar turns amber past 80% and red at its limit,\n *             where \"Limit reached\" folds open beside the share\n *   arrive    the bars grow from the left the first time they're\n *             seen and every figure rolls up from zero\n *\n * All of it is real text; the bars are decoration.\n * ───────────────────────────────────────────────────────── */\n\nexport interface ContextPart {\n  id: string;\n  label: string;\n  tokens: number;\n}\n\nexport interface PlanLimit {\n  id: string;\n  label: string;\n  used: number;\n  limit: number;\n  /** When it resets (ms or Date). */\n  resetsAt?: number | Date;\n}\n\nexport interface UsageLimitsProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** The context window, split into what fills it. */\n  context?: { limit: number; parts: ContextPart[] };\n  /** The plan's name, e.g. \"Max\". */\n  plan?: string;\n  limits: PlanLimit[];\n  /** Makes the plan heading a link, e.g. to billing. */\n  onPlanClick?: () => void;\n  defaultOpen?: boolean;\n  locales?: string | 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 COLORS = [\"var(--chart-1, var(--primary))\", \"var(--chart-4, #8b6fe8)\", \"var(--chart-3, #1baf7a)\", \"var(--chart-2, #e8743b)\", \"var(--chart-5, #e0a21b)\"];\nconst compact = (n: number, locales?: string | string[]) => new Intl.NumberFormat(locales, { notation: \"compact\", maximumFractionDigits: 1 }).format(n);\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 tone = (share: number) => (share >= 1 ? \"bg-red-500\" : share >= 0.8 ? \"bg-amber-500\" : \"\");\n\n/** \"Resets in 2 h 46 min\" under a day away, the minutes rolling down; \"Resets Tue 3:00 pm\" further out. */\nfunction Resets({ at, now, locales }: { at: number; now: number | null; locales?: string | string[] }) {\n  if (now === null) return <span className=\"invisible\">Resets</span>;\n  const left = Math.max(0, at - now);\n  if (left > 86_400_000) {\n    const d = new Date(at);\n    return <span>Resets {d.toLocaleString(locales ?? \"en-GB\", { weekday: \"short\", hour: \"numeric\", minute: \"2-digit\" })}</span>;\n  }\n  const hours = Math.floor(left / 3_600_000);\n  const minutes = Math.ceil((left % 3_600_000) / 60_000);\n  return (\n    <span className=\"flex items-baseline gap-[0.25em] whitespace-nowrap\">\n      Resets in\n      {hours > 0 && (\n        <>\n          <NumberRoll value={hours} direction=\"down\" duration={700} />h\n        </>\n      )}\n      <NumberRoll value={minutes} direction=\"down\" duration={700} />\n      min\n    </span>\n  );\n}\n\nexport function UsageLimits({ context, plan, limits, onPlanClick, defaultOpen = false, locales, className = \"\", ...props }: UsageLimitsProps) {\n  const reduced = useReducedMotion();\n  const id = React.useId();\n  const [open, setOpen] = React.useState(defaultOpen);\n  const [visible, setVisible] = React.useState(false);\n  const [focus, setFocus] = React.useState<string | null>(null);\n  const [now, setNow] = React.useState<number | null>(null);\n  const rootRef = React.useRef<HTMLDivElement>(null);\n\n  React.useEffect(() => {\n    const el = rootRef.current;\n    if (!el) return;\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        if (entry.isIntersecting) {\n          setVisible(true);\n          observer.disconnect();\n        }\n      },\n      { rootMargin: \"0px 0px -10% 0px\" },\n    );\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n\n  // A clock that ticks on each whole minute, only once mounted (so a server render never reads as \"reset\").\n  React.useEffect(() => {\n    let timer = 0;\n    const tick = () => {\n      setNow(Date.now());\n      timer = window.setTimeout(tick, 60_000 - (Date.now() % 60_000) + 50);\n    };\n    tick();\n    return () => window.clearTimeout(timer);\n  }, []);\n\n  const used = context ? context.parts.reduce((s, p) => s + p.tokens, 0) : 0;\n  const contextShare = context?.limit ? used / context.limit : 0;\n\n  return (\n    <div ref={rootRef} className={`@container w-full rounded-[22px] bg-background shadow-[0_0_0_1px_var(--border)] ${className}`} {...props}>\n      <div className=\"p-4 @md:p-5\">\n        {context && (\n          <section aria-labelledby={`${id}-context`}>\n            <button\n              type=\"button\"\n              aria-expanded={open}\n              aria-controls={`${id}-parts`}\n              onClick={() => setOpen((o) => !o)}\n              className={`-mx-1.5 flex w-[calc(100%+12px)] items-baseline justify-between gap-3 rounded-lg px-1.5 py-0.5 text-left ${FOCUS}`}\n            >\n              <span id={`${id}-context`} className=\"text-[12.5px] text-muted-foreground @md:text-[13px]\">\n                Context window\n              </span>\n              <span className=\"flex items-baseline gap-1.5 whitespace-nowrap text-[12px] tabular-nums text-muted-foreground @md:text-[12.5px]\">\n                <span>\n                  <NumberRoll value={visible ? used : 0} format={{ notation: \"compact\", maximumFractionDigits: 1 }} locales={locales} duration={900} /> /{\" \"}\n                  {compact(context.limit, locales)}\n                </span>\n                <span className={`font-medium ${contextShare >= 1 ? \"text-red-600\" : contextShare >= 0.8 ? \"text-amber-600\" : \"text-foreground\"}`}>\n                  <NumberRoll value={visible ? contextShare : 0} format={{ style: \"percent\" }} duration={900} />\n                </span>\n                <ChevronDown\n                  aria-hidden=\"true\"\n                  className=\"size-3.5 self-center\"\n                  style={{ transform: open ? \"rotate(180deg)\" : \"none\", transition: reduced ? \"none\" : `transform 320ms ${EASE}` }}\n                />\n              </span>\n            </button>\n\n            {/* What fills the window: one segment per part, a 2px gap between them. */}\n            <div aria-hidden=\"true\" className=\"mt-2 flex h-1.5 gap-[2px] overflow-hidden rounded-full bg-muted\" onPointerLeave={() => setFocus(null)}>\n              {context.parts.map((p, i) => (\n                <span\n                  key={p.id}\n                  onPointerEnter={() => setFocus(p.id)}\n                  className=\"h-full first:rounded-l-full\"\n                  style={{\n                    width: `${visible ? (p.tokens / context.limit) * 100 : 0}%`,\n                    background: COLORS[i % COLORS.length],\n                    opacity: focus && focus !== p.id ? 0.3 : 1,\n                    transition: reduced ? \"none\" : `width 900ms ${EASE} ${i * 70}ms, opacity 240ms ${EASE}`,\n                  }}\n                />\n              ))}\n            </div>\n\n            <div\n              id={`${id}-parts`}\n              inert={!open}\n              className=\"grid\"\n              style={{\n                gridTemplateRows: open ? \"1fr\" : \"0fr\",\n                opacity: open ? 1 : 0,\n                transition: reduced ? \"none\" : `grid-template-rows 420ms ${EASE}, opacity ${open ? \"320ms\" : \"140ms\"} ${EASE}`,\n              }}\n            >\n              <ul className=\"min-h-0 overflow-hidden\" onPointerLeave={() => setFocus(null)}>\n                {context.parts.map((p, i) => (\n                  <li\n                    key={p.id}\n                    onPointerEnter={() => setFocus(p.id)}\n                    className=\"flex items-center gap-2 pt-2 text-[12px] transition-opacity duration-200 @md:text-[12.5px]\"\n                    style={{ opacity: focus && focus !== p.id ? 0.45 : 1 }}\n                  >\n                    <span aria-hidden=\"true\" className=\"size-[7px] shrink-0 rounded-full\" style={{ background: COLORS[i % COLORS.length] }} />\n                    <span className=\"min-w-0 flex-1 truncate text-muted-foreground\">{p.label}</span>\n                    <span className=\"tabular-nums text-foreground\">{compact(p.tokens, locales)}</span>\n                    <span className=\"w-9 text-right tabular-nums text-muted-foreground\">{Math.round((p.tokens / context.limit) * 100)}%</span>\n                  </li>\n                ))}\n              </ul>\n            </div>\n          </section>\n        )}\n\n        <section aria-labelledby={`${id}-plan`} className={context ? \"mt-3.5 border-t border-border pt-3 @md:mt-4 @md:pt-3.5\" : \"\"}>\n          <h3 id={`${id}-plan`} className=\"text-[12.5px] font-normal text-muted-foreground @md:text-[13px]\">\n            {onPlanClick ? (\n              <button type=\"button\" onClick={onPlanClick} className={`-mx-1.5 flex w-[calc(100%+12px)] items-center justify-between rounded-lg px-1.5 py-0.5 text-left hover:text-foreground ${FOCUS}`}>\n                <span>Plan limits{plan ? ` · ${plan}` : \"\"}</span>\n                <ChevronRight aria-hidden=\"true\" className=\"size-3.5\" />\n              </button>\n            ) : (\n              <span>Plan limits{plan ? ` · ${plan}` : \"\"}</span>\n            )}\n          </h3>\n          <ul className=\"mt-1\">\n            {limits.map((l, i) => {\n              const share = l.limit ? l.used / l.limit : 0;\n              const reached = share >= 1;\n              return (\n                <li key={l.id} className=\"pt-2.5 first:pt-1.5\">\n                  <div className=\"flex items-baseline justify-between gap-3 text-[12.5px] @md:text-[13px]\">\n                    <span className=\"min-w-0 truncate text-foreground\">{l.label}</span>\n                    <span className=\"flex shrink-0 items-baseline gap-2.5 text-[11.5px] text-muted-foreground @md:text-[12px]\">\n                      {l.resetsAt !== undefined && <Resets at={+new Date(l.resetsAt)} now={now} locales={locales} />}\n                      {/* \"Limit reached\" folds open beside the share rather than replacing it. */}\n                      <span className=\"flex items-baseline\">\n                        <span\n                          className=\"grid\"\n                          style={{\n                            gridTemplateColumns: reached ? \"1fr\" : \"0fr\",\n                            opacity: reached ? 1 : 0,\n                            transition: reduced ? \"none\" : `grid-template-columns 420ms ${EASE}, opacity 260ms ${EASE}`,\n                          }}\n                        >\n                          <span className=\"min-w-0 overflow-hidden whitespace-nowrap pr-1.5 font-medium text-red-600 [clip-path:inset(-4px_0)]\">Limit reached</span>\n                        </span>\n                        <span className={`min-w-[2.6em] text-right font-medium tabular-nums ${reached ? \"text-red-600\" : share >= 0.8 ? \"text-amber-600\" : \"text-foreground\"}`}>\n                          <NumberRoll value={visible ? Math.min(share, 1) : 0} format={{ style: \"percent\" }} duration={900} />\n                        </span>\n                      </span>\n                    </span>\n                  </div>\n                  <div aria-hidden=\"true\" className=\"mt-1.5 h-1.5 overflow-hidden rounded-full bg-muted\">\n                    <div\n                      className={`h-full rounded-full transition-colors duration-300 ${tone(share)}`}\n                      style={{\n                        width: `${visible ? Math.max(share > 0 ? 1.5 : 0, Math.min(share, 1) * 100) : 0}%`,\n                        background: tone(share) ? undefined : \"var(--chart-1, var(--primary))\",\n                        transition: reduced ? \"none\" : `width 900ms ${EASE} ${i * 90}ms, background-color 300ms ${EASE}`,\n                      }}\n                    />\n                  </div>\n                </li>\n              );\n            })}\n          </ul>\n        </section>\n      </div>\n    </div>\n  );\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/usage-limits"}