{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "char-limit-ring",
  "type": "registry:ui",
  "title": "Character Limit Ring",
  "description": "A tiny ring that fills as you type, then counts down and over.",
  "categories": [
    "forms"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/char-limit-ring.tsx",
      "content": "\"use client\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { spring } from \"@/lib/motion\";\n\n/**\n * Counts what a person sees as characters: \"👍🏽\" and \"é\" are one each, not\n * two or four. Falls back to code points where Intl.Segmenter is missing.\n */\nexport function countCharacters(text: string) {\n  if (!text) return 0;\n  if (typeof Intl !== \"undefined\" && \"Segmenter\" in Intl) {\n    return [...new Intl.Segmenter(undefined, { granularity: \"grapheme\" }).segment(text)].length;\n  }\n  return Array.from(text).length;\n}\n\ntype Level = \"empty\" | \"ok\" | \"near\" | \"warn\" | \"over\";\n\nexport type CharLimitRingProps = Omit<React.ComponentProps<\"span\">, \"children\"> & {\n  /** Characters typed. Use countCharacters(text) so emoji count as one. */\n  count: number;\n  limit: number;\n  /** Fraction of the limit where the ring turns to the warning color. */\n  warnAt?: number;\n  /** Remaining characters at which the number appears inside the ring. */\n  countFrom?: number;\n  size?: \"sm\" | \"md\";\n  /** Hide the ring until the first character. */\n  hideWhenEmpty?: boolean;\n};\n\nconst plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;\n\nexport function CharLimitRing({ count, limit, warnAt = 0.9, countFrom = 20, size = \"md\", hideWhenEmpty = true, className, ...rest }: CharLimitRingProps) {\n  const reduce = useReducedMotion();\n  const remaining = limit - count;\n  const level: Level = count === 0 ? \"empty\" : remaining < 0 ? \"over\" : count >= limit * warnAt ? \"warn\" : remaining <= countFrom ? \"near\" : \"ok\";\n  const showCount = count > 0 && remaining <= countFrom;\n  const fill = Math.min(1, Math.max(0, count / limit));\n\n  // Announce crossings, not keystrokes: entering the last stretch, reaching the limit, going over, coming back.\n  const band = remaining < 0 ? \"over\" : remaining === 0 ? \"zero\" : remaining <= 10 ? \"ten\" : remaining <= countFrom ? \"near\" : \"fine\";\n  const [prev, setPrev] = useState(band);\n  const [said, setSaid] = useState(\"\");\n  const [bumps, setBumps] = useState(0);\n  if (band !== prev) {\n    setPrev(band);\n    setSaid(\n      band === \"over\"\n        ? `${plural(-remaining, \"character\")} over the limit`\n        : band === \"zero\"\n          ? \"Character limit reached\"\n          : band === \"ten\"\n            ? `${remaining} characters left`\n            : band === \"near\"\n              ? `${remaining} characters left`\n              : prev === \"over\"\n                ? \"Back under the limit\"\n                : \"\",\n    );\n    // Going over earns one small pop, so the moment is felt without a shake.\n    if (band === \"over\" && prev !== \"over\") setBumps((b) => b + 1);\n  }\n\n  const base = size === \"sm\" ? 18 : 22;\n  const stroke = size === \"sm\" ? 2 : 2.25;\n  const r = (base - stroke) / 2;\n  // The ring grows to make room for the number, then settles back.\n  const grow = showCount ? (size === \"sm\" ? 1.35 : 1.3) : 1;\n  const digits = String(remaining).length;\n\n  return (\n    <span\n      data-slot=\"char-limit-ring\"\n      data-level={level}\n      className={cn(\n        \"relative inline-grid shrink-0 place-items-center transition-opacity duration-200 ease-out\",\n        hideWhenEmpty && count === 0 && \"opacity-0\",\n        className,\n      )}\n      style={{ width: base * 1.35, height: base * 1.35 }}\n      {...rest}\n    >\n      {/* CSS, not Motion, for the growth: its value renders the same on the server and a reduced-motion client. */}\n      <span\n        aria-hidden\n        className=\"relative grid place-items-center transition-[scale] duration-300 ease-out-expo motion-reduce:transition-none\"\n        style={{ width: base, height: base, scale: grow }}\n      >\n        <motion.span\n          key={bumps}\n          className=\"absolute inset-0\"\n          initial={bumps && !reduce ? { scale: 1.18 } : false}\n          animate={{ scale: 1 }}\n          transition={spring.bouncy}\n        >\n          <svg width={base} height={base} viewBox={`0 0 ${base} ${base}`} className=\"-rotate-90 overflow-visible\">\n            <circle cx={base / 2} cy={base / 2} r={r} fill=\"none\" strokeWidth={stroke} className=\"stroke-fg/15\" />\n            <circle\n              cx={base / 2}\n              cy={base / 2}\n              r={r}\n              fill=\"none\"\n              strokeWidth={stroke}\n              strokeLinecap=\"round\"\n              pathLength={1}\n              strokeDasharray=\"1 1\"\n              className={cn(\n                \"transition-[stroke-dashoffset,stroke] duration-150 ease-out\",\n                level === \"over\" ? \"stroke-danger\" : level === \"warn\" ? \"stroke-warning\" : \"stroke-fg-2\",\n              )}\n              style={{ strokeDashoffset: 1 - fill }}\n            />\n          </svg>\n        </motion.span>\n        <span\n          className={cn(\n            \"relative font-medium leading-none tabular tracking-[-0.02em] transition-[opacity,color] duration-150\",\n            showCount ? \"opacity-100\" : \"opacity-0\",\n            level === \"over\" ? \"text-danger\" : level === \"warn\" ? \"text-warning\" : \"text-fg-2\",\n          )}\n          // Shrinks a touch for three or four characters so \"-120\" still fits inside.\n          style={{ fontSize: (size === \"sm\" ? 9 : 10) - Math.max(0, digits - 2) }}\n        >\n          {/* Plain digits: this changes on every keystroke, where rolling numbers would read as lag. */}\n          {showCount ? remaining : countFrom}\n        </span>\n      </span>\n\n      <span className=\"sr-only\">\n        {remaining >= 0 ? `${plural(remaining, \"character\")} left of ${limit}` : `${plural(-remaining, \"character\")} over the ${limit} limit`}\n      </span>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {said}\n      </span>\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/char-limit-ring"
}
