{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"usage-meter","type":"registry:component","title":"Usage meter","description":"Credits, messages or context left: quiet when there’s plenty, clear before it runs out.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/number-roll.json","https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/usage-meter.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * USAGE METER: how much is left, before it runs out\n *\n *   normal  quiet: \"1,240 of 2,000 left\"; the numbers roll as\n *           they're spent and the bar eases to match\n *   low     amber once a fifth is left; the way to upgrade\n *           opens in beside the reset date\n *   out     red; the numbers fold away as the words morph to\n *           \"No credits left\", and \"Resets Oct 12\" becomes\n *           \"You're out until Oct 12.\"\n *\n * A bar for settings and menus, or a ring small enough for a\n * composer toolbar (context windows, daily limits).\n * ───────────────────────────────────────────────────────── */\n\nexport interface UsageMeterProps extends React.HTMLAttributes<HTMLDivElement> {\n  used: number;\n  limit: number;\n  /** What's being counted, e.g. \"credits\" or \"messages\". */\n  unit?: string;\n  label?: string;\n  /** When it resets (ms or Date). */\n  resetAt?: number | Date;\n  variant?: \"bar\" | \"ring\";\n  /** Share left at which it turns amber. */\n  lowAt?: number;\n  onUpgrade?: () => void;\n  upgradeLabel?: string;\n}\n\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst MORPH = \"cubic-bezier(0.16,1,0.3,1)\";\nconst number = new Intl.NumberFormat(\"en-US\");\nconst GROUPED = { useGrouping: true };\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\n/** A piece of a line that opens out of nothing and folds back into it. */\nfunction Reveal({ show, reduced, className = \"\", children }: { show: boolean; reduced: boolean; className?: string; children: React.ReactNode }) {\n  return (\n    <span\n      aria-hidden={!show || undefined}\n      className={`grid ${className}`}\n      style={{\n        gridTemplateColumns: show ? \"1fr\" : \"0fr\",\n        opacity: show ? 1 : 0,\n        filter: show ? \"none\" : \"blur(3px)\",\n        transition: reduced ? \"none\" : `grid-template-columns 460ms ${MORPH}, opacity ${show ? \"320ms\" : \"160ms\"} ${MORPH}, filter 320ms ${MORPH}`,\n      }}\n    >\n      <span className=\"min-w-0 whitespace-nowrap [clip-path:inset(-4px_-2px)]\">{children}</span>\n    </span>\n  );\n}\n\nexport function UsageMeter({\n  used,\n  limit,\n  unit = \"credits\",\n  label = \"Usage\",\n  resetAt,\n  variant = \"bar\",\n  lowAt = 0.2,\n  onUpgrade,\n  upgradeLabel = \"Upgrade\",\n  className = \"\",\n  ...props\n}: UsageMeterProps) {\n  const reduced = useReducedMotion();\n  const left = Math.max(0, limit - used);\n  const share = limit > 0 ? Math.min(1, used / limit) : 1;\n  const out = left <= 0;\n  const low = !out && limit > 0 && left / limit <= lowAt;\n  const tone = out ? \"bg-red-500\" : low ? \"bg-amber-500\" : \"bg-foreground\";\n  const stroke = out ? \"stroke-red-500\" : low ? \"stroke-amber-500\" : \"stroke-foreground\";\n  const resets =\n    resetAt !== undefined ? new Date(resetAt).toLocaleDateString(\"en-US\", { month: \"short\", day: \"numeric\" }) : undefined;\n  const summary = out ? `No ${unit} left` : `${number.format(left)} of ${number.format(limit)} ${unit} left`;\n\n  if (variant === \"ring\") {\n    const c = 2 * Math.PI * 8;\n    return (\n      <div\n        role=\"meter\"\n        aria-label={label}\n        aria-valuemin={0}\n        aria-valuemax={limit}\n        aria-valuenow={used}\n        aria-valuetext={summary}\n        title={`${summary}${resets ? ` · resets ${resets}` : \"\"}`}\n        className={`inline-flex items-center gap-1.5 text-[11.5px] text-muted-foreground ${className}`}\n        {...props}\n      >\n        <svg aria-hidden=\"true\" viewBox=\"0 0 20 20\" className=\"size-4 -rotate-90\">\n          <circle cx=\"10\" cy=\"10\" r=\"8\" fill=\"none\" strokeWidth=\"2.5\" className=\"stroke-border\" />\n          <circle\n            cx=\"10\"\n            cy=\"10\"\n            r=\"8\"\n            fill=\"none\"\n            strokeWidth=\"2.5\"\n            strokeLinecap=\"round\"\n            strokeDasharray={c}\n            strokeDashoffset={c * (1 - share)}\n            className={`${stroke} transition-[stroke-dashoffset,stroke] duration-500 motion-reduce:transition-none`}\n          />\n        </svg>\n        <span aria-hidden=\"true\" className={`font-mono tabular-nums transition-colors duration-300 ${out ? \"text-red-500\" : \"\"}`}>\n          <NumberRoll value={Math.round(share * 100)} suffix=\"%\" duration={700} />\n        </span>\n      </div>\n    );\n  }\n\n  return (\n    <div className={`w-full ${className}`} {...props}>\n      <div className=\"flex items-baseline justify-between gap-4 text-[12.5px]\">\n        <span className=\"font-medium text-foreground\">{label}</span>\n        {/* The numbers fold away when it runs out; the words that stay glide into \"No credits left\". */}\n        <span aria-hidden=\"true\" className={`flex items-baseline font-mono text-[12px] tabular-nums transition-colors duration-300 ${out ? \"text-red-500\" : \"text-muted-foreground\"}`}>\n          <Reveal show={!out} reduced={reduced}>\n            <span className=\"inline-flex items-baseline\">\n              <NumberRoll value={left} format={GROUPED} locales=\"en-US\" duration={700} />\n              &nbsp;of&nbsp;\n              <NumberRoll value={limit} format={GROUPED} locales=\"en-US\" duration={700} />\n              &nbsp;\n            </span>\n          </Reveal>\n          <TextMorph>{out ? `No ${unit} left` : `${unit} left`}</TextMorph>\n        </span>\n      </div>\n      <div\n        role=\"meter\"\n        aria-label={label}\n        aria-valuemin={0}\n        aria-valuemax={limit}\n        aria-valuenow={used}\n        aria-valuetext={summary}\n        className=\"mt-2 h-1.5 w-full overflow-hidden rounded-full bg-muted\"\n      >\n        <div\n          className={`h-full rounded-full ${tone} transition-[width,background-color] duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none`}\n          style={{ width: `${share * 100}%` }}\n        />\n      </div>\n      {(resets || onUpgrade) && (\n        <div className=\"mt-2 flex min-h-[18px] items-center justify-between gap-4 text-[12px] text-muted-foreground\">\n          <span className=\"min-w-0\">\n            <TextMorph>{out ? `You’re out until ${resets ?? \"the next reset\"}.` : resets ? `Resets ${resets}` : \"\"}</TextMorph>\n          </span>\n          {onUpgrade && (\n            // Opens in once it matters: when it's low or out.\n            <Reveal show={low || out} reduced={reduced} className=\"shrink-0\">\n              <span className=\"block px-0.5\">\n                <button\n                  type=\"button\"\n                  inert={!(low || out)}\n                  onClick={onUpgrade}\n                  className={`rounded font-medium text-foreground underline decoration-border underline-offset-4 hover:decoration-foreground ${FOCUS}`}\n                >\n                  {upgradeLabel}\n                </button>\n              </span>\n            </Reveal>\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/usage-meter"}