{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "terminal",
  "title": "Terminal",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A terminal window with command, output, success, error and comment lines, optional per-command copy buttons and a sequenced typing animation that respects reduced motion.",
  "dependencies": [
    "class-variance-authority",
    "lucide-react",
    "motion@^12"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/terminal.tsx",
      "content": "// Ballmac UI: Terminal. https://ui.ballmac.com/components/terminal\n\"use client\"\n\nimport * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { Check, Copy } from \"lucide-react\"\nimport { motion, useInView, useReducedMotion } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\n\ntype TerminalProps = React.ComponentProps<\"figure\"> & {\n  /** Text in the title bar, e.g. \"zsh\" or \"~/my-app\". */\n  title?: string\n  /** \"dark\" scopes the theme's dark tokens to the window in light mode too; \"inherit\" follows the page. */\n  theme?: \"dark\" | \"inherit\"\n  /** Classes for the scrolling body. */\n  bodyClassName?: string\n}\n\nfunction Terminal({ title, theme = \"dark\", bodyClassName, className, children, ...props }: TerminalProps) {\n  const msg = useMessages()\n  return (\n    <figure\n      data-slot=\"terminal\"\n      dir=\"ltr\"\n      className={cn(\n        \"w-full min-w-0 overflow-hidden rounded-xl border bg-card text-card-foreground shadow-[0_1px_2px_0_rgb(0_0_0/0.08)]\",\n        theme === \"dark\" && \"dark [color-scheme:dark]\",\n        className\n      )}\n      {...props}\n    >\n      <figcaption\n        data-slot=\"terminal-header\"\n        className=\"relative flex h-9 items-center border-b bg-muted/40 px-3.5\"\n      >\n        <span aria-hidden=\"true\" className=\"flex gap-1.5\">\n          <span className=\"size-2.5 rounded-full bg-muted-foreground/35\" />\n          <span className=\"size-2.5 rounded-full bg-muted-foreground/35\" />\n          <span className=\"size-2.5 rounded-full bg-muted-foreground/35\" />\n        </span>\n        <span className=\"absolute inset-x-16 truncate text-center font-mono text-xs text-muted-foreground\">\n          {title ?? <span className=\"sr-only\">{msg(\"terminal.terminal\", \"Terminal\")}</span>}\n        </span>\n      </figcaption>\n      <div\n        data-slot=\"terminal-body\"\n        tabIndex={0}\n        className={cn(\"overflow-x-auto px-4 py-3.5 font-mono text-[13px] leading-6 outline-none focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/50\", bodyClassName)}\n      >\n        {children}\n      </div>\n    </figure>\n  )\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Sequencing: TerminalAnimated reveals lines one after another.\n * -----------------------------------------------------------------------------------------------*/\n\ntype SequenceContextValue = { active: number; done: (index: number) => void; lineDelay: number; speed: number }\nconst SequenceContext = React.createContext<SequenceContextValue | null>(null)\nconst LineIndexContext = React.createContext<number>(-1)\n\ntype TerminalAnimatedProps = React.ComponentProps<\"div\"> & {\n  /** Typing speed for command lines, in characters per second. */\n  speed?: number\n  /** Pause before each non-command line appears, in milliseconds. */\n  lineDelay?: number\n  /** Wait before the first line starts, in milliseconds. */\n  startDelay?: number\n  /** Start only once the terminal scrolls into view. */\n  startOnView?: boolean\n  /** Called when every line has been revealed. */\n  onComplete?: () => void\n}\n\n/** Wrap TerminalLine children to reveal them in order: commands type out, output lines follow. Reduced motion shows everything at once. */\nfunction TerminalAnimated({\n  speed = 36,\n  lineDelay = 140,\n  startDelay = 300,\n  startOnView = true,\n  onComplete,\n  className,\n  children,\n  ...props\n}: TerminalAnimatedProps) {\n  const ref = React.useRef<HTMLDivElement>(null)\n  const inView = useInView(ref, { once: true, margin: \"0px 0px -10% 0px\" })\n  const reduceMotion = useReducedMotion()\n  const items = React.Children.toArray(children)\n  const total = items.length\n  const [active, setActive] = React.useState(-1)\n  const onCompleteRef = React.useRef(onComplete)\n  React.useEffect(() => {\n    onCompleteRef.current = onComplete\n  })\n\n  React.useEffect(() => {\n    if (reduceMotion) {\n      setActive(total)\n      return\n    }\n    if (active !== -1 || (startOnView && !inView)) return\n    const t = setTimeout(() => setActive(0), startDelay)\n    return () => clearTimeout(t)\n  }, [reduceMotion, total, active, startOnView, inView, startDelay])\n\n  React.useEffect(() => {\n    if (active >= total && total > 0) onCompleteRef.current?.()\n  }, [active, total])\n\n  const done = React.useCallback((index: number) => {\n    setActive((current) => (current === index ? index + 1 : current))\n  }, [])\n\n  const ctx = React.useMemo(() => ({ active, done, lineDelay, speed }), [active, done, lineDelay, speed])\n\n  return (\n    <SequenceContext.Provider value={ctx}>\n      <div\n        ref={ref}\n        data-slot=\"terminal-animated\"\n        aria-busy={active < total}\n        className={cn(\"min-h-6\", className)}\n        {...props}\n      >\n        {items.map((child, index) => (\n          <LineIndexContext.Provider key={React.isValidElement(child) && child.key != null ? child.key : index} value={index}>\n            {child}\n          </LineIndexContext.Provider>\n        ))}\n      </div>\n    </SequenceContext.Provider>\n  )\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Lines\n * -----------------------------------------------------------------------------------------------*/\n\nconst terminalLineVariants = cva(\"group/line relative flex min-w-0 items-start gap-2 whitespace-pre\", {\n  variants: {\n    variant: {\n      command: \"text-foreground\",\n      output: \"text-foreground/75\",\n      success: \"text-chart-2\",\n      error: \"text-destructive\",\n      comment: \"text-muted-foreground italic\",\n    },\n  },\n  defaultVariants: { variant: \"output\" },\n})\n\nfunction Caret() {\n  const reduceMotion = useReducedMotion()\n  const className = \"inline-block h-[1.15em] w-[0.6ch] translate-y-[0.2em] bg-foreground/80\"\n  if (reduceMotion) return <span aria-hidden=\"true\" className={className} />\n  return (\n    <motion.span\n      aria-hidden=\"true\"\n      className={className}\n      animate={{ opacity: [1, 1, 0, 0] }}\n      transition={{ duration: 1, times: [0, 0.5, 0.5, 1], repeat: Infinity, ease: \"linear\" }}\n    />\n  )\n}\n\nfunction useTyping(text: string, enabled: boolean, speed: number, onDone: () => void) {\n  const [count, setCount] = React.useState(0)\n  const onDoneRef = React.useRef(onDone)\n  React.useEffect(() => {\n    onDoneRef.current = onDone\n  })\n  React.useEffect(() => {\n    if (!enabled) return\n    let i = 0\n    setCount(0)\n    const interval = setInterval(() => {\n      i += 1\n      setCount(i)\n      if (i >= text.length) {\n        clearInterval(interval)\n        onDoneRef.current()\n      }\n    }, 1000 / speed)\n    return () => clearInterval(interval)\n  }, [enabled, text, speed])\n  return count\n}\n\nfunction LineCopyButton({ value }: { value: string }) {\n  const msg = useMessages()\n  const [copied, setCopied] = React.useState(false)\n  const timer = React.useRef<ReturnType<typeof setTimeout>>(undefined)\n  React.useEffect(() => () => clearTimeout(timer.current), [])\n  return (\n    <>\n      <button\n        type=\"button\"\n        aria-label={copied ? msg(\"terminal.copied\", \"Copied\") : msg(\"terminal.copyCommand\", \"Copy command\")}\n        title={msg(\"terminal.copyCommand\", \"Copy command\")}\n        onClick={async () => {\n          try {\n            await navigator.clipboard.writeText(value)\n          } catch {\n            return\n          }\n          setCopied(true)\n          clearTimeout(timer.current)\n          timer.current = setTimeout(() => setCopied(false), 1600)\n        }}\n        className=\"ms-auto flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none transition-opacity duration-150 hover:bg-accent hover:text-foreground focus-visible:opacity-100 focus-visible:ring-[3px] focus-visible:ring-ring/50 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover/line:opacity-100\"\n      >\n        {copied ? <Check aria-hidden=\"true\" className=\"size-3.5\" /> : <Copy aria-hidden=\"true\" className=\"size-3.5\" />}\n      </button>\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {copied ? \"Copied to clipboard\" : \"\"}\n      </span>\n    </>\n  )\n}\n\ntype TerminalLineProps = React.ComponentProps<\"div\"> &\n  VariantProps<typeof terminalLineVariants> & {\n    /** Prompt shown before command lines. */\n    prompt?: string\n    /** Show a copy button for command lines (copies the text without the prompt). */\n    copyable?: boolean\n    /** Type the command out character by character (string children only). Implied inside TerminalAnimated. */\n    typing?: boolean\n    /** Characters per second when typing outside TerminalAnimated. */\n    speed?: number\n  }\n\nfunction TerminalLine({\n  variant = \"output\",\n  prompt = \"$\",\n  copyable = false,\n  typing,\n  speed,\n  className,\n  children,\n  ...props\n}: TerminalLineProps) {\n  const sequence = React.useContext(SequenceContext)\n  const index = React.useContext(LineIndexContext)\n  const reduceMotion = useReducedMotion()\n  const inSequence = sequence !== null && index >= 0\n  const isCommand = variant === \"command\"\n  const text = typeof children === \"string\" ? children : null\n\n  const isActive = inSequence && sequence.active === index\n  const typeThis = isCommand && text !== null && !reduceMotion && (inSequence ? typing !== false : !!typing)\n  // Standalone typing starts on mount; sequenced typing when this line becomes active.\n  const [mounted, setMounted] = React.useState(false)\n  React.useEffect(() => setMounted(true), [])\n  const typingEnabled = typeThis && (inSequence ? isActive : mounted)\n\n  const finish = React.useCallback(() => {\n    if (inSequence) sequence.done(index)\n  }, [inSequence, sequence, index])\n\n  const typed = useTyping(text ?? \"\", typingEnabled, speed ?? sequence?.speed ?? 36, finish)\n\n  // Non-typing lines inside a sequence advance after a short pause.\n  React.useEffect(() => {\n    if (!isActive || typeThis) return\n    const t = setTimeout(finish, sequence?.lineDelay ?? 0)\n    return () => clearTimeout(t)\n  }, [isActive, typeThis, finish, sequence?.lineDelay])\n\n  if (inSequence) {\n    const visible = index < sequence.active || (isActive && typeThis)\n    if (!visible) return null\n  }\n\n  const stillTyping = typeThis && (inSequence ? index >= sequence!.active : typed < (text?.length ?? 0))\n  const content = typeThis && stillTyping ? (text ?? \"\").slice(0, typed) : children\n\n  return (\n    <div\n      data-slot=\"terminal-line\"\n      data-variant={variant}\n      className={cn(terminalLineVariants({ variant }), className)}\n      {...props}\n    >\n      {isCommand ? (\n        <span aria-hidden=\"true\" className=\"shrink-0 text-muted-foreground select-none\">\n          {prompt}\n        </span>\n      ) : null}\n      {variant === \"success\" || variant === \"error\" ? (\n        <>\n          <span aria-hidden=\"true\" className=\"shrink-0 select-none\">\n            {variant === \"success\" ? \"✓\" : \"✗\"}\n          </span>\n          <span className=\"sr-only\">{variant === \"success\" ? \"Success: \" : \"Error: \"}</span>\n        </>\n      ) : null}\n      <span className=\"min-w-0\">\n        {content}\n        {typeThis && stillTyping ? <Caret /> : null}\n      </span>\n      {isCommand && copyable && text !== null && !stillTyping ? <LineCopyButton value={text} /> : null}\n    </div>\n  )\n}\n\nexport { Terminal, TerminalLine, TerminalAnimated, terminalLineVariants, type TerminalProps, type TerminalLineProps, type TerminalAnimatedProps }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/terminal.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "terminal",
      "cli",
      "shell",
      "command",
      "typing",
      "developer",
      "docs"
    ],
    "version": "1.0.0",
    "updated": "2026-09-28",
    "ai": {
      "summary": "Show CLI sessions: <Terminal title> with <TerminalLine variant='command' copyable> and output lines. Wrap lines in <TerminalAnimated> to type commands out and reveal output in order when scrolled into view.",
      "whenToUse": [
        "Install and setup steps on landing pages and docs",
        "Showing what a CLI prints, including success and error lines",
        "An animated hero that types a command and its result"
      ],
      "whenNotToUse": [
        "Copyable install commands for several package managers (use install-tabs)",
        "Source code files (use code-block)",
        "A real interactive shell (this renders static or scripted lines only)"
      ],
      "composesWith": [
        "install-tabs",
        "code-block"
      ],
      "a11y": [
        {
          "keys": "Tab",
          "action": "Focuses the copy button on command lines (hidden until hover on mouse devices, visible on focus)"
        },
        {
          "keys": "—",
          "action": "The prompt symbol is hidden from screen readers; success and error lines carry a text prefix, not just color; aria-busy is set while animating"
        }
      ],
      "customization": [
        "theme: dark (default; scopes the theme's .dark tokens to the window) | inherit (follows the page)",
        "TerminalLine variant: command | output | success | error | comment; prompt changes the '$'",
        "TerminalLine typing + speed (chars/sec) types a single command; inside TerminalAnimated commands type automatically",
        "TerminalAnimated speed, lineDelay (ms), startDelay (ms), startOnView, onComplete",
        "Reduced motion shows every line immediately"
      ]
    },
    "examples": [
      "terminal-demo",
      "terminal-typing"
    ],
    "url": "https://ui.ballmac.com/components/terminal"
  },
  "categories": [
    "developer"
  ],
  "type": "registry:ui"
}