{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chat-loader",
  "title": "Chat Loader",
  "description": "Waiting marks for a reply: a spinner, dots, a pulse, bars or a Thinking label that breathes.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/utils.json",
    "https://ui.ndekocode.dev/r/spinner.json"
  ],
  "files": [
    {
      "path": "registry/ndeko/ui/chat-loader.tsx",
      "content": "// Adapted from prompt-kit (MIT, Copyright (c) 2025 Julien Thibeaut).\n'use client';\n\nimport { motion, useReducedMotion, type Transition } from 'motion/react';\n\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { Spinner } from '@/registry/ndeko/ui/spinner';\n\ntype LoaderSize = 'sm' | 'md' | 'lg';\n\ntype LoaderVariant = 'spinner' | 'dots' | 'pulse' | 'bars' | 'text';\n\ntype LoaderPartProps = {\n  size?: LoaderSize;\n  className?: string;\n  label?: string;\n};\n\nconst DEFAULT_LABEL = 'Thinking';\n\nconst BREATH: Transition = {\n  duration: 1.2,\n  repeat: Infinity,\n  ease: 'easeInOut',\n};\n\nconst SLOW_BREATH: Transition = {\n  duration: 2,\n  repeat: Infinity,\n  ease: 'easeInOut',\n};\n\nconst STAGGER_SECONDS = 0.16;\n\nconst PARTS = [0, 1, 2] as const;\n\nconst BAR_PARTS = [0, 1, 2, 3] as const;\n\nfunction staggered(index: number, transition: Transition): Transition {\n  const delay = index * STAGGER_SECONDS;\n  return { ...transition, delay };\n}\n\nconst SPINNER_SIZE: Record<LoaderSize, string> = {\n  sm: '[&_svg]:size-3.5',\n  md: '[&_svg]:size-4',\n  lg: '[&_svg]:size-5',\n};\n\nfunction SpinnerLoader({ size = 'md', className, label }: LoaderPartProps) {\n  const spinnerSize = SPINNER_SIZE[size];\n  return (\n    <Spinner\n      label={label ?? DEFAULT_LABEL}\n      className={cn(spinnerSize, className)}\n    />\n  );\n}\n\nconst DOT_SIZE: Record<LoaderSize, string> = {\n  sm: 'size-1',\n  md: 'size-1.5',\n  lg: 'size-2',\n};\n\nconst BOX_HEIGHT: Record<LoaderSize, string> = {\n  sm: 'h-4',\n  md: 'h-5',\n  lg: 'h-6',\n};\n\n/** Opacity only, so it keeps breathing under reduced motion like a spinner keeps turning. */\nfunction DotsLoader({ size = 'md', className, label }: LoaderPartProps) {\n  return (\n    <span\n      role='status'\n      aria-label={label ?? DEFAULT_LABEL}\n      className={cn(\n        'inline-flex items-center gap-1',\n        BOX_HEIGHT[size],\n        className\n      )}\n    >\n      {PARTS.map((index) => (\n        <motion.span\n          key={index}\n          aria-hidden='true'\n          className={cn('bg-muted-foreground rounded-full', DOT_SIZE[size])}\n          initial={{ opacity: 0.3 }}\n          animate={{ opacity: [0.3, 1, 0.3] }}\n          transition={staggered(index, BREATH)}\n        />\n      ))}\n    </span>\n  );\n}\n\nconst PULSE_SIZE: Record<LoaderSize, string> = {\n  sm: 'size-2',\n  md: 'size-2.5',\n  lg: 'size-3',\n};\n\nfunction pulseKeyframesOf(shouldReduceMotion: boolean | null) {\n  if (shouldReduceMotion) {\n    return { opacity: [0.45, 1, 0.45] };\n  }\n  return { opacity: [0.45, 1, 0.45], scale: [0.85, 1, 0.85] };\n}\n\nfunction PulseDotLoader({ size = 'md', className, label }: LoaderPartProps) {\n  const shouldReduceMotion = useReducedMotion();\n  const keyframes = pulseKeyframesOf(shouldReduceMotion);\n\n  return (\n    <span\n      role='status'\n      aria-label={label ?? DEFAULT_LABEL}\n      className={cn('inline-flex items-center', BOX_HEIGHT[size], className)}\n    >\n      <motion.span\n        aria-hidden='true'\n        className={cn('bg-primary rounded-full', PULSE_SIZE[size])}\n        animate={keyframes}\n        transition={BREATH}\n      />\n    </span>\n  );\n}\n\nconst BAR_WIDTH: Record<LoaderSize, string> = {\n  sm: 'w-0.5',\n  md: 'w-[3px]',\n  lg: 'w-1',\n};\n\nfunction barKeyframesOf(shouldReduceMotion: boolean | null) {\n  if (shouldReduceMotion) {\n    return { opacity: [0.35, 1, 0.35] };\n  }\n  return { scaleY: [0.4, 1, 0.4], opacity: [0.5, 1, 0.5] };\n}\n\nfunction BarsLoader({ size = 'md', className, label }: LoaderPartProps) {\n  const shouldReduceMotion = useReducedMotion();\n  const keyframes = barKeyframesOf(shouldReduceMotion);\n\n  return (\n    <span\n      role='status'\n      aria-label={label ?? DEFAULT_LABEL}\n      className={cn(\n        'inline-flex items-center gap-0.5',\n        BOX_HEIGHT[size],\n        className\n      )}\n    >\n      {BAR_PARTS.map((index) => (\n        <motion.span\n          key={index}\n          aria-hidden='true'\n          className={cn(\n            'bg-muted-foreground h-full rounded-full',\n            BAR_WIDTH[size]\n          )}\n          animate={keyframes}\n          transition={staggered(index, BREATH)}\n        />\n      ))}\n    </span>\n  );\n}\n\nconst TEXT_SIZE: Record<LoaderSize, string> = {\n  sm: 'text-xs',\n  md: 'text-[13px]',\n  lg: 'text-sm',\n};\n\ntype TextLoaderProps = Omit<LoaderPartProps, 'label'> & {\n  text?: string;\n};\n\nfunction TextLoader({\n  text = DEFAULT_LABEL,\n  size = 'md',\n  className,\n}: TextLoaderProps) {\n  const shouldReduceMotion = useReducedMotion();\n  const classNames = cn(\n    'text-muted-foreground inline-block font-medium',\n    TEXT_SIZE[size],\n    className\n  );\n\n  if (shouldReduceMotion) {\n    return (\n      <span role='status' className={classNames}>\n        {text}\n      </span>\n    );\n  }\n\n  return (\n    <motion.span\n      role='status'\n      className={classNames}\n      initial={{ opacity: 1 }}\n      animate={{ opacity: [1, 0.45, 1] }}\n      transition={SLOW_BREATH}\n    >\n      {text}\n    </motion.span>\n  );\n}\n\ntype LoaderProps = {\n  variant?: LoaderVariant;\n  size?: LoaderSize;\n  /** The visible word of the `text` variant, and the accessible name of the others. */\n  text?: string;\n  className?: string;\n};\n\nfunction Loader({\n  variant = 'spinner',\n  size = 'md',\n  text,\n  className,\n}: LoaderProps) {\n  if (variant === 'dots') {\n    return <DotsLoader size={size} label={text} className={className} />;\n  }\n  if (variant === 'pulse') {\n    return <PulseDotLoader size={size} label={text} className={className} />;\n  }\n  if (variant === 'bars') {\n    return <BarsLoader size={size} label={text} className={className} />;\n  }\n  if (variant === 'text') {\n    return <TextLoader size={size} text={text} className={className} />;\n  }\n  return <SpinnerLoader size={size} label={text} className={className} />;\n}\n\nexport {\n  BarsLoader,\n  DotsLoader,\n  Loader,\n  PulseDotLoader,\n  SpinnerLoader,\n  TextLoader,\n  type LoaderProps,\n  type LoaderSize,\n  type LoaderVariant,\n};\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "primitives",
    "chat"
  ],
  "type": "registry:ui"
}