{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion"],"description":"Family of AI waiting indicators — dots, sweep bar and pixel grid — sharing one cycle length, with an optional elapsed counter.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { motion, useAnimationFrame, useReducedMotion } from \"motion/react\";\nimport { useRef, useState } from \"react\";\n\n/**\n * One cycle length for every variant.\n *\n * Two loaders on the same screen running at different tempos read as two\n * unrelated things loading. Sharing the cycle makes them feel like one system\n * working, which is the whole point of a loader family.\n */\nexport const AI_LOADER_CYCLE_SECONDS = 1.2;\n\nconst DOT_COUNT = 3;\nconst GRID_SIZE = 3;\nconst GRID_CELLS = GRID_SIZE * GRID_SIZE;\n/** Diagonal wave order, so the grid fills like a sweep rather than at random. */\nconst GRID_DELAYS = [0, 1, 2, 1, 2, 3, 2, 3, 4];\nconst EASE_IN_OUT = [0.645, 0.045, 0.355, 1] as const;\nconst MS_PER_SECOND = 1000;\nconst ELAPSED_DECIMALS = 1;\n\nexport type AILoaderVariant = \"dots\" | \"bar\" | \"grid\";\n\nexport type AILoaderProps = {\n  className?: string;\n  /** Optional text before the indicator, e.g. \"Thinking\". */\n  label?: string;\n  /** Appends a live elapsed counter. Long waits need a sign of progress. */\n  showElapsed?: boolean;\n  variant?: AILoaderVariant;\n};\n\nconst Dots = ({ reduced }: { reduced: boolean }) => (\n  <span className=\"flex items-center gap-1\">\n    {Array.from({ length: DOT_COUNT }, (_, index) => (\n      <motion.span\n        animate={reduced ? { opacity: 0.5 } : { opacity: [0.25, 1, 0.25] }}\n        className=\"size-1.5 rounded-full bg-current\"\n        // biome-ignore lint/suspicious/noArrayIndexKey: dots are positional\n        key={index}\n        transition={\n          reduced\n            ? { duration: 0 }\n            : {\n                delay: (index * AI_LOADER_CYCLE_SECONDS) / (DOT_COUNT * 2),\n                duration: AI_LOADER_CYCLE_SECONDS,\n                ease: EASE_IN_OUT,\n                repeat: Number.POSITIVE_INFINITY,\n              }\n        }\n      />\n    ))}\n  </span>\n);\n\nconst Bar = ({ reduced }: { reduced: boolean }) => (\n  <span className=\"relative block h-1 w-24 overflow-hidden rounded-full bg-current/15\">\n    {/* A sweep, not a percentage. Faking determinate progress for an unknown\n        wait is the one thing a loader must never do. */}\n    <motion.span\n      animate={reduced ? { x: \"0%\" } : { x: [\"-100%\", \"200%\"] }}\n      className=\"absolute inset-y-0 w-1/3 rounded-full bg-current\"\n      transition={\n        reduced\n          ? { duration: 0 }\n          : {\n              duration: AI_LOADER_CYCLE_SECONDS * 1.4,\n              ease: EASE_IN_OUT,\n              repeat: Number.POSITIVE_INFINITY,\n            }\n      }\n    />\n  </span>\n);\n\nconst Grid = ({ reduced }: { reduced: boolean }) => (\n  <span className=\"grid grid-cols-3 gap-0.5\">\n    {Array.from({ length: GRID_CELLS }, (_, index) => (\n      <motion.span\n        animate={reduced ? { opacity: 0.45 } : { opacity: [0.2, 1, 0.2] }}\n        className=\"size-1.5 rounded-[2px] bg-current\"\n        // biome-ignore lint/suspicious/noArrayIndexKey: cells are positional\n        key={index}\n        transition={\n          reduced\n            ? { duration: 0 }\n            : {\n                delay:\n                  ((GRID_DELAYS[index] ?? 0) * AI_LOADER_CYCLE_SECONDS) / 8,\n                duration: AI_LOADER_CYCLE_SECONDS,\n                ease: EASE_IN_OUT,\n                repeat: Number.POSITIVE_INFINITY,\n              }\n        }\n      />\n    ))}\n  </span>\n);\n\nconst Elapsed = () => {\n  const startRef = useRef<number | null>(null);\n  const [seconds, setSeconds] = useState(0);\n\n  useAnimationFrame((time) => {\n    const start = startRef.current ?? time;\n    startRef.current = start;\n    const next = (time - start) / MS_PER_SECOND;\n    // Only re-render on a tenth changing — a 60fps counter is unreadable and\n    // re-renders the whole label for nothing.\n    setSeconds((current) =>\n      next.toFixed(ELAPSED_DECIMALS) === current.toFixed(ELAPSED_DECIMALS)\n        ? current\n        : next\n    );\n  });\n\n  return (\n    <span className=\"tabular-nums opacity-60\">\n      {seconds.toFixed(ELAPSED_DECIMALS)}s\n    </span>\n  );\n};\n\n/**\n * The waiting indicator family.\n *\n * All three variants share one cycle length, so a page can mix them without the\n * result looking out of sync. None of them fake determinate progress.\n */\nconst AILoader = ({\n  className,\n  label,\n  showElapsed = false,\n  variant = \"dots\",\n}: AILoaderProps) => {\n  const reduced = Boolean(useReducedMotion());\n\n  return (\n    <span\n      aria-live=\"polite\"\n      className={cn(\n        \"inline-flex items-center gap-2 text-muted-foreground text-sm\",\n        className\n      )}\n      role=\"status\"\n    >\n      {label ? <span>{label}</span> : null}\n      {variant === \"dots\" && <Dots reduced={reduced} />}\n      {variant === \"bar\" && <Bar reduced={reduced} />}\n      {variant === \"grid\" && <Grid reduced={reduced} />}\n      {showElapsed ? <Elapsed /> : null}\n      <span className=\"sr-only\">{label ?? \"Loading\"}</span>\n    </span>\n  );\n};\n\nexport default AILoader;\n","path":"index.tsx","target":"components/smoothui/ai-loader/index.tsx","type":"registry:ui"}],"name":"ai-loader","registryDependencies":[],"title":"Ai Loader","type":"registry:ui"}