# Terminal Animation

> Composable animated terminal for React and shadcn/ui that plays typed commands and output, with tabbed scenarios for CLI docs.

Source: https://www.cult-ui.com/docs/components/terminal-animation

## Example

```tsx title="terminal-animation-demo.tsx"
"use client"

import { useState } from "react"

import { cn } from "@/lib/utils"

import {
  TerminalAnimationBackgroundGradient,
  TerminalAnimationBlinkingCursor,
  TerminalAnimationCommandBar,
  TerminalAnimationContainer,
  TerminalAnimationContent,
  TerminalAnimationOutput,
  TerminalAnimationRoot,
  TerminalAnimationTabList,
  TerminalAnimationTabTrigger,
  TerminalAnimationTrailingPrompt,
  TerminalAnimationWindow,
  type TabContent,
  type TerminalLine,
} from "../ui/terminal-animation"

export interface TerminalAnimationDemoProps {
  /** Tab content for each command; defaults to defaultTerminalTabs */
  tabs?: TabContent[]
  /** Background image URL; when unset, BackgroundGradient is used */
  backgroundImage?: string
  /** Force dark mode for the terminal regardless of page theme */
  alwaysDark?: boolean
}

const backgroundImage =
  "/component-images/terminal-animation/terminal-animation-bg-2.png"

const tabs: TabContent[] = [
  {
    label: "install",
    command: "npm install",
    lines: [
      { text: "", delay: 80 },
      {
        text: "added 1,247 packages in 12s",
        color: "text-[#6FF7CC]",
        delay: 400,
      },
      { text: "", delay: 80 },
      {
        text: "  Cult UI is looking for funding",
        color: "text-slate-400",
        delay: 150,
      },
      {
        text: "    run `npm fund cult-ui` for details",
        color: "text-slate-500",
        delay: 100,
      },
      {
        text: "  +-----------------------+",
        color: "text-[#ED42B5]",
        delay: 120,
      },
      {
        text: "  |       CULT UI         |",
        color: "text-[#ED42B5]",
        delay: 120,
      },
      {
        text: "  |   Shadcn expanded    |",
        color: "text-[#ED42B5]",
        delay: 120,
      },
      {
        text: "  +-----------------------+",
        color: "text-[#ED42B5]",
        delay: 160,
      },
      { text: "", delay: 80 },
      {
        text: "  found 0 vulnerabilities",
        color: "text-[#ADFA1F]",
        delay: 250,
      },
    ],
  },
  {
    label: "build",
    command: "npm run build",
    lines: [
      { text: "", delay: 80 },
      {
        text: "  ▲ Next.js 16.1.6",
        color: "text-slate-300",
        delay: 300,
      },
      { text: "", delay: 80 },
      {
        text: "  Creating an optimized production build...",
        color: "text-slate-400",
        delay: 250,
      },
      {
        text: "  ✓ Compiled successfully",
        color: "text-[#6FF7CC]",
        delay: 200,
      },
      {
        text: "  ✓ Linting and checking validity of types",
        color: "text-[#6FF7CC]",
        delay: 150,
      },
      {
        text: "  ✓ Generating static pages (12/12)",
        color: "text-[#6FF7CC]",
        delay: 150,
      },
      {
        text: "  Route (app)  /  142 kB  |  First Load JS 198 kB",
        color: "text-slate-500",
        delay: 150,
      },
      {
        text: "  Route (app)  /blog 61 kB | First Load JS 57 kB",
        color: "text-slate-500",
        delay: 150,
      },
      {
        text: "  Route (app)  /about 75 kB | First Load JS 92 kB",
        color: "text-slate-500",
        delay: 150,
      },
      { text: "", delay: 80 },
      {
        text: "  ✓ Build completed in 4.2s",
        color: "text-[#6FF7CC]",
        delay: 300,
      },
    ],
  },
  {
    label: "deploy",
    command: "vercel deploy --prod",
    lines: [
      { text: "", delay: 80 },
      { text: "  Vercel CLI 39.2.0", color: "text-slate-400", delay: 200 },
      { text: "", delay: 80 },
      {
        text: "  > Deploying to production...",
        color: "text-[#ED42B5]",
        delay: 300,
      },
      { text: "", delay: 80 },
      { text: "  ✓ Building", color: "text-[#6FF7CC]", delay: 250 },
      { text: "  ✓ Uploading", color: "text-[#6FF7CC]", delay: 200 },
      { text: "  ✓ Finalizing", color: "text-[#6FF7CC]", delay: 200 },
      { text: "", delay: 80 },
      {
        text: "  Production: https://aisdkagents.vercel.app",
        color: "text-[#ED42B5]",
        delay: 400,
      },
      { text: "", delay: 80 },
      {
        text: "  ✓ Deployment complete",
        color: "text-[#6FF7CC]",
        delay: 250,
      },
    ],
  },
  {
    label: "test",
    command: "npm test",
    lines: [
      { text: "", delay: 80 },
      {
        text: "  PASS  src/components/Button.test.tsx",
        color: "text-slate-400",
        delay: 200,
      },
      {
        text: "    ✓ renders correctly",
        color: "text-[#ADFA1F]",
        delay: 100,
      },
      {
        text: "    ✓ handles click events",
        color: "text-[#ADFA1F]",
        delay: 100,
      },
      {
        text: "  PASS  src/utils/format.test.ts",
        color: "text-slate-400",
        delay: 150,
      },
      {
        text: "    ✓ formats currency",
        color: "text-[#ADFA1F]",
        delay: 100,
      },
      {
        text: "    ✓ formats dates",
        color: "text-[#ADFA1F]",
        delay: 100,
      },
      { text: "", delay: 80 },
      {
        text: "  Test Suites: 2 passed, 2 total",
        color: "text-[#ADFA1F]",
        delay: 200,
      },
      {
        text: "  Tests:       4 passed, 4 total",
        color: "text-[#ADFA1F]",
        delay: 150,
      },
      { text: "  Time:        1.234 s", color: "text-slate-500", delay: 100 },
    ],
  },
]

export function TerminalAnimationDemo() {
  const [animationKey, setAnimationKey] = useState(0)

  return (
    <TerminalAnimationRoot
      key={animationKey}
      alwaysDark={true}
      backgroundImage={backgroundImage}
      className="bg-background relative flex w-full justify-center overflow-clip"
      defaultActiveTab={1}
      hideCursorOnComplete={false}
      tabs={tabs}
    >
      <button
        className="absolute top-4 left-4 z-20 rounded-md border border-white/25 bg-black/45 px-3 py-1.5 font-mono text-[11px] tracking-wide text-white/90 uppercase transition hover:bg-black/65"
        onClick={() => setAnimationKey((prev) => prev + 1)}
        type="button"
      >
        Refresh
      </button>
      <a
        className="absolute top-4 right-4 z-20 rounded-md border border-white/25 bg-black/45 px-3 py-1.5 font-mono text-[11px] tracking-wide text-white/90 uppercase transition hover:bg-black/65"
        download="terminal-animation-image-2.png"
        href={backgroundImage}
      >
        Download BG
      </a>
      {!backgroundImage && <TerminalAnimationBackgroundGradient />}
      <TerminalAnimationContainer className="max-w-[43rem]">
        <TerminalAnimationWindow className="outline-1 outline-offset-[2px] outline-white/30">
          <TerminalAnimationContent className="min-h-[26rem]">
            <div className="flex items-center gap-2 leading-relaxed">
              <span className="text-muted-foreground font-mono text-[10px] select-none md:text-sm">
                $
              </span>
              <TerminalAnimationCommandBar
                className="text-foreground font-mono text-[10px] md:text-sm"
                cursor={<TerminalAnimationBlinkingCursor />}
              />
            </div>

            <TerminalAnimationOutput
              className="mt-1"
              renderLine={(
                line: TerminalLine,
                _i: number,
                visible: boolean
              ) => {
                if (!visible) {
                  return null
                }
                return (
                  <div className="leading-relaxed">
                    <span
                      className={cn(
                        "font-mono text-[10px] md:text-sm",
                        line.color ?? "text-muted-foreground"
                      )}
                    >
                      {line.text || "\u00A0"}
                    </span>
                  </div>
                )
              }}
            />
            <TerminalAnimationTrailingPrompt className="mt-1 flex items-center gap-2 leading-relaxed">
              <span className="text-muted-foreground font-mono text-sm select-none">
                $
              </span>
              <TerminalAnimationBlinkingCursor />
            </TerminalAnimationTrailingPrompt>
          </TerminalAnimationContent>

          <div className="flex justify-center pb-6">
            <TerminalAnimationTabList className="border-border bg-muted/50 inline-flex items-center gap-0 rounded-lg border px-1 py-1">
              {tabs.map((tab, i) => (
                <TerminalAnimationTabTrigger
                  className={cn(
                    "cursor-pointer rounded-md px-3.5 py-1 font-mono text-sm transition-all duration-150",
                    "data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=active]:font-medium",
                    "data-[state=inactive]:text-muted-foreground data-[state=inactive]:hover:text-foreground"
                  )}
                  index={i}
                  key={tab.label}
                >
                  {tab.label}
                </TerminalAnimationTabTrigger>
              ))}
            </TerminalAnimationTabList>
          </div>
        </TerminalAnimationWindow>
      </TerminalAnimationContainer>
    </TerminalAnimationRoot>
  )
}
```

Terminal Animation is a set of composable React primitives for shadcn/ui with controlled or uncontrolled state. Use it for CLI documentation, developer tool landing pages, or install guides.

## Installation

### CLI

```bash
npx shadcn@latest add @cult-ui/terminal-animation
```

### Manual

**Install the required dependencies.**

```bash
npm install @radix-ui/react-slot @radix-ui/react-use-controllable-state
```

**Copy and paste the component source into your project.**

```tsx title="terminal-animation.tsx"
"use client"

import {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useRef,
  useState,
  type ReactNode,
} from "react"
import { Slot } from "@radix-ui/react-slot"
import { useControllableState } from "@radix-ui/react-use-controllable-state"

import { cn } from "@/lib/utils"

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

export interface TerminalLine {
  text: string
  color?: string
  delay?: number
}

export interface TabContent {
  label: string
  command: string
  lines: TerminalLine[]
}

export type TerminalAnimationRootProps = React.ComponentProps<"div"> & {
  /** Tab content for each command */
  tabs: TabContent[]
  /** Initial active tab index (uncontrolled) */
  defaultActiveTab?: number
  /** Active tab index (controlled) */
  activeTab?: number
  /** Callback when active tab changes */
  onActiveTabChange?: (index: number) => void
  /** Optional background image URL; renders a full-bleed layer when provided */
  backgroundImage?: string
  /** Force dark mode for the terminal regardless of page theme */
  alwaysDark?: boolean
  /** Hide cursor after output completes */
  hideCursorOnComplete?: boolean
}

// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------

interface TerminalAnimationContextValue {
  activeTab: number
  setActiveTab: (index: number) => void
  commandTyped: string
  isTypingCommand: boolean
  showCursor: boolean
  visibleLines: number
  currentTab: TabContent
  tabs: TabContent[]
}

const TerminalAnimationContext = createContext<
  TerminalAnimationContextValue | undefined
>(undefined)

function useTerminalAnimationContext() {
  const ctx = useContext(TerminalAnimationContext)
  if (!ctx) {
    throw new Error(
      "TerminalAnimation components must be used within TerminalAnimationRoot"
    )
  }
  return ctx
}

// ---------------------------------------------------------------------------
// Default tabs data (for demo)
// ---------------------------------------------------------------------------

export const defaultTerminalTabs: TabContent[] = [
  {
    label: "new",
    command: "vite new my-app",
    lines: [
      { text: "", delay: 80 },
      {
        text: "  Scaffolding project in ./my-app...",
        color: "text-[#b39aff]",
        delay: 400,
      },
      { text: "", delay: 80 },
      { text: "  Select a framework:", color: "text-neutral-400", delay: 200 },
      { text: "    Vanilla", color: "text-neutral-500", delay: 80 },
      { text: "    Vue", color: "text-neutral-500", delay: 80 },
      { text: "  > React", color: "text-[#32f3e9]", delay: 80 },
      { text: "    Preact", color: "text-neutral-500", delay: 80 },
      { text: "    Svelte", color: "text-neutral-500", delay: 80 },
      { text: "", delay: 200 },
      { text: "  Select a variant:", color: "text-neutral-400", delay: 200 },
      { text: "  > TypeScript", color: "text-[#32f3e9]", delay: 80 },
      { text: "", delay: 200 },
      { text: "  Done. Now run:", color: "text-neutral-400", delay: 300 },
      { text: "", delay: 50 },
      { text: "    cd my-app", color: "text-neutral-300", delay: 100 },
      { text: "    vite dev", color: "text-neutral-300", delay: 100 },
    ],
  },
  {
    label: "dev",
    command: "vite dev",
    lines: [
      { text: "", delay: 80 },
      {
        text: "  VITE v6.3.0  ready in 124 ms",
        color: "text-[#b39aff]",
        delay: 400,
      },
      { text: "", delay: 80 },
      {
        text: "  >  Local:   http://localhost:5173/",
        color: "text-[#32f3e9]",
        delay: 200,
      },
      {
        text: "  >  Network: http://192.168.1.42:5173/",
        color: "text-neutral-500",
        delay: 100,
      },
      {
        text: "  >  press h + enter to show help",
        color: "text-neutral-600",
        delay: 100,
      },
      { text: "", delay: 300 },
      {
        text: "  hmr update /src/App.tsx 2ms",
        color: "text-neutral-500",
        delay: 600,
      },
      {
        text: "  hmr update /src/App.tsx 1ms",
        color: "text-neutral-500",
        delay: 400,
      },
    ],
  },
  {
    label: "lint",
    command: "vite lint ./src",
    lines: [
      { text: "", delay: 80 },
      {
        text: "  Checked 42 files in 8ms",
        color: "text-neutral-400",
        delay: 300,
      },
      { text: "", delay: 80 },
      { text: "  src/App.tsx:14:5", color: "text-neutral-500", delay: 150 },
      {
        text: "    warning  Unexpected console statement       no-console",
        color: "text-yellow-400",
        delay: 100,
      },
      { text: "", delay: 80 },
      { text: "  src/utils.ts:31:1", color: "text-neutral-500", delay: 150 },
      {
        text: "    warning  Missing return type on function    return-type",
        color: "text-yellow-400",
        delay: 100,
      },
      { text: "", delay: 80 },
      {
        text: "  src/api/client.ts:7:10",
        color: "text-neutral-500",
        delay: 150,
      },
      {
        text: "    error    Unused variable 'baseUrl'          no-unused",
        color: "text-red-400",
        delay: 100,
      },
      { text: "", delay: 80 },
      {
        text: "  3 problems (1 error, 2 warnings)  [8ms]",
        color: "text-neutral-300",
        delay: 300,
      },
    ],
  },
  {
    label: "fmt",
    command: "vite fmt ./src",
    lines: [
      { text: "", delay: 80 },
      {
        text: "  Checked 42 files in 4ms",
        color: "text-neutral-400",
        delay: 200,
      },
      { text: "", delay: 80 },
      { text: "  Fixed  src/App.tsx", color: "text-[#22ff73]", delay: 150 },
      {
        text: "  Fixed  src/components/Header.tsx",
        color: "text-[#22ff73]",
        delay: 100,
      },
      {
        text: "  Fixed  src/utils/format.ts",
        color: "text-[#22ff73]",
        delay: 100,
      },
      { text: "", delay: 80 },
      { text: "  3 files formatted", color: "text-neutral-300", delay: 200 },
      { text: "  39 files unchanged", color: "text-neutral-500", delay: 100 },
      { text: "", delay: 80 },
      { text: "  Done in 4ms", color: "text-neutral-400", delay: 200 },
    ],
  },
  {
    label: "test",
    command: "vite test",
    lines: [
      { text: "", delay: 80 },
      { text: "  RUN  v6.3.0", color: "text-[#b39aff]", delay: 300 },
      { text: "", delay: 80 },
      { text: "  src/utils.test.ts", color: "text-neutral-400", delay: 200 },
      {
        text: "    ✓ formats currency correctly",
        color: "text-[#22ff73]",
        delay: 150,
      },
      {
        text: "    ✓ parses date strings",
        color: "text-[#22ff73]",
        delay: 100,
      },
      {
        text: "    ✓ handles edge cases",
        color: "text-[#22ff73]",
        delay: 100,
      },
      { text: "  src/App.test.tsx", color: "text-neutral-400", delay: 200 },
      {
        text: "    ✓ renders heading",
        color: "text-[#22ff73]",
        delay: 100,
      },
      {
        text: "    ✓ handles click events",
        color: "text-[#22ff73]",
        delay: 100,
      },
      { text: "", delay: 80 },
      { text: "  Tests  5 passed (5)", color: "text-[#22ff73]", delay: 200 },
      { text: "  Time   38ms", color: "text-neutral-500", delay: 100 },
    ],
  },
  {
    label: "build",
    command: "vite build",
    lines: [
      { text: "", delay: 80 },
      {
        text: "  vite v6.3.0 building for production...",
        color: "text-[#b39aff]",
        delay: 400,
      },
      { text: "", delay: 80 },
      { text: "  transforming...", color: "text-neutral-500", delay: 300 },
      {
        text: "  ✓ 42 modules transformed.",
        color: "text-[#22ff73]",
        delay: 300,
      },
      { text: "", delay: 80 },
      { text: "  rendering chunks...", color: "text-neutral-500", delay: 200 },
      {
        text: "  computing gzip size...",
        color: "text-neutral-500",
        delay: 200,
      },
      { text: "", delay: 80 },
      {
        text: "  dist/index.html                 0.46 kB  |  gzip:  0.30 kB",
        color: "text-neutral-400",
        delay: 100,
      },
      {
        text: "  dist/assets/index-Dk2a9f.css    1.28 kB  |  gzip:  0.65 kB",
        color: "text-neutral-400",
        delay: 80,
      },
      {
        text: "  dist/assets/index-Ba3x7q.js   143.36 kB  |  gzip: 46.12 kB",
        color: "text-neutral-400",
        delay: 80,
      },
      { text: "", delay: 80 },
      { text: "  ✓ built in 218ms", color: "text-[#22ff73]", delay: 300 },
    ],
  },
]

// ---------------------------------------------------------------------------
// Root
// ---------------------------------------------------------------------------

export function TerminalAnimationRoot({
  tabs,
  defaultActiveTab = 0,
  activeTab: activeTabProp,
  onActiveTabChange,
  backgroundImage,
  alwaysDark = false,
  hideCursorOnComplete = true,
  className,
  children,
  ...props
}: TerminalAnimationRootProps) {
  const [activeTab, setActiveTab] = useControllableState({
    prop: activeTabProp,
    defaultProp: defaultActiveTab,
    onChange: onActiveTabChange,
  })

  const [visibleLines, setVisibleLines] = useState(0)
  const [commandTyped, setCommandTyped] = useState("")
  const [isTypingCommand, setIsTypingCommand] = useState(true)
  const [showCursor, setShowCursor] = useState(true)
  const timeoutRef = useRef<ReturnType<typeof setTimeout>[]>([])

  const clearTimeouts = useCallback(() => {
    timeoutRef.current.forEach(clearTimeout)
    timeoutRef.current = []
  }, [])

  const animateTab = useCallback(
    (tabIndex: number) => {
      clearTimeouts()
      setVisibleLines(0)
      setCommandTyped("")
      setIsTypingCommand(true)
      setShowCursor(true)

      const tab = tabs[tabIndex]
      if (!tab) {
        return
      }

      const command = tab.command
      let charIndex = 0

      const typeCommand = () => {
        if (charIndex <= command.length) {
          setCommandTyped(command.slice(0, charIndex))
          charIndex++
          const t = setTimeout(typeCommand, 25 + Math.random() * 35)
          timeoutRef.current.push(t)
        } else {
          const t = setTimeout(() => {
            setIsTypingCommand(false)
            showLines(0)
          }, 250)
          timeoutRef.current.push(t)
        }
      }

      const showLines = (lineIndex: number) => {
        if (lineIndex <= tab.lines.length) {
          setVisibleLines(lineIndex)
          if (lineIndex < tab.lines.length) {
            const delay = tab.lines[lineIndex].delay ?? 100
            const t = setTimeout(() => showLines(lineIndex + 1), delay)
            timeoutRef.current.push(t)
          } else if (hideCursorOnComplete) {
            const t = setTimeout(() => setShowCursor(false), 600)
            timeoutRef.current.push(t)
          }
        }
      }

      const t = setTimeout(typeCommand, 300)
      timeoutRef.current.push(t)
    },
    [clearTimeouts, hideCursorOnComplete, tabs]
  )

  useEffect(() => {
    animateTab(activeTab)
    return clearTimeouts
  }, [activeTab, animateTab, clearTimeouts])

  const currentTab = tabs[activeTab] ?? tabs[0]
  const safeActiveTab = Math.min(activeTab, tabs.length - 1)

  const value: TerminalAnimationContextValue = {
    activeTab: safeActiveTab,
    setActiveTab,
    commandTyped,
    isTypingCommand,
    showCursor,
    visibleLines,
    currentTab,
    tabs,
  }

  return (
    <TerminalAnimationContext.Provider value={value}>
      <div
        className={cn(alwaysDark && "dark", className)}
        data-slot="terminal-animation-root"
        {...props}
      >
        {backgroundImage && (
          <div
            aria-hidden
            className="absolute inset-0 bg-cover bg-center"
            data-slot="terminal-animation-background"
            style={{ backgroundImage: `url(${backgroundImage})` }}
          />
        )}
        {children}
      </div>
    </TerminalAnimationContext.Provider>
  )
}

// ---------------------------------------------------------------------------
// BackgroundGradient
// ---------------------------------------------------------------------------

export type TerminalAnimationBackgroundGradientProps =
  React.ComponentProps<"div">

const backgroundGradientClasses =
  "absolute inset-0 bg-gradient-to-br from-violet-600/40 via-fuchsia-600/30 to-indigo-950"

export function TerminalAnimationBackgroundGradient({
  className,
  ...props
}: TerminalAnimationBackgroundGradientProps) {
  return (
    <div
      aria-hidden
      className={cn(backgroundGradientClasses, className)}
      data-slot="terminal-animation-background-gradient"
      {...props}
    />
  )
}

// ---------------------------------------------------------------------------
// Container
// ---------------------------------------------------------------------------

export type TerminalAnimationContainerProps = React.ComponentProps<"div">

const containerClasses =
  "relative w-full max-w-[62rem] px-3 pt-10 pb-0 md:px-0 md:pt-28"

export function TerminalAnimationContainer({
  className,
  ...props
}: TerminalAnimationContainerProps) {
  return (
    <div
      className={cn(containerClasses, className)}
      data-slot="terminal-animation-container"
      {...props}
    />
  )
}

// ---------------------------------------------------------------------------
// Window
// ---------------------------------------------------------------------------

export type TerminalAnimationWindowProps = React.ComponentProps<"div"> & {
  /** Terminal window background color; defaults to bg-card when unset */
  backgroundColor?: string
  /** Minimum height of the terminal window */
  minHeight?: string
  /** Animate slide-up when element enters viewport */
  animateOnVisible?: boolean
}

const windowClasses = "relative flex flex-col overflow-hidden rounded-t-xl"

export function TerminalAnimationWindow({
  className,
  backgroundColor,
  minHeight = "28rem",
  animateOnVisible = true,
  style,
  ...props
}: TerminalAnimationWindowProps) {
  const windowRef = useRef<HTMLDivElement>(null)
  const [hasAnimated, setHasAnimated] = useState(false)

  useEffect(() => {
    if (!(animateOnVisible && windowRef.current)) {
      return
    }
    const el = windowRef.current
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry?.isIntersecting) {
          setHasAnimated(true)
        }
      },
      { threshold: 0.1 }
    )
    observer.observe(el)
    return () => observer.disconnect()
  }, [animateOnVisible])

  return (
    <div
      className={cn(
        windowClasses,
        !backgroundColor && "bg-card",
        animateOnVisible &&
          "transition-transform duration-1000 ease-[cubic-bezier(0.16,1,0.3,1)]",
        animateOnVisible && !hasAnimated && "translate-y-64",
        animateOnVisible && hasAnimated && "translate-y-0",
        className
      )}
      data-slot="terminal-animation-window"
      ref={windowRef}
      style={
        backgroundColor
          ? { backgroundColor, minHeight, ...style }
          : { minHeight, ...style }
      }
      {...props}
    />
  )
}

// ---------------------------------------------------------------------------
// Content
// ---------------------------------------------------------------------------

export type TerminalAnimationContentProps = React.ComponentProps<"div">

const contentClasses = "flex-1 px-6 py-6 sm:px-10 sm:py-8"

export function TerminalAnimationContent({
  className,
  ...props
}: TerminalAnimationContentProps) {
  return (
    <div
      className={cn(contentClasses, className)}
      data-slot="terminal-animation-content"
      {...props}
    />
  )
}

// ---------------------------------------------------------------------------
// BlinkingCursor
// ---------------------------------------------------------------------------

export type TerminalAnimationBlinkingCursorProps = React.ComponentProps<"span">

export function TerminalAnimationBlinkingCursor({
  className,
  ...props
}: TerminalAnimationBlinkingCursorProps) {
  // Requires `animate-caret-blink` utility in global CSS for downstream installs.
  return (
    <span
      aria-hidden
      className={cn(
        "animate-caret-blink bg-muted-foreground ml-0.5 inline-block h-[18px] w-[7px] translate-y-[3px] duration-1000",
        className
      )}
      data-slot="terminal-animation-blinking-cursor"
      {...props}
    />
  )
}

// ---------------------------------------------------------------------------
// CommandBar
// ---------------------------------------------------------------------------

export type TerminalAnimationCommandBarProps = React.ComponentProps<"div"> & {
  /** Custom cursor element when typing; defaults to unstyled block cursor */
  cursor?: ReactNode
}

export function TerminalAnimationCommandBar({
  className,
  cursor,
  ...props
}: TerminalAnimationCommandBarProps) {
  const { commandTyped, isTypingCommand, showCursor } =
    useTerminalAnimationContext()

  const defaultCursor = <span aria-hidden="true">▌</span>

  return (
    <div
      className={className}
      data-slot="terminal-animation-command"
      {...props}
    >
      {commandTyped}
      {isTypingCommand && showCursor && (cursor ?? defaultCursor)}
    </div>
  )
}

// ---------------------------------------------------------------------------
// OutputLine
// ---------------------------------------------------------------------------

export type TerminalAnimationOutputLineProps = React.ComponentProps<"div"> & {
  line: TerminalLine
  visible: boolean
}

export function TerminalAnimationOutputLine({
  line,
  visible,
  className,
  ...props
}: TerminalAnimationOutputLineProps) {
  if (!visible) {
    return null
  }
  return (
    <div
      className={className}
      data-slot="terminal-animation-output-line"
      {...props}
    >
      <span data-line-color={line.color}>{line.text || "\u00A0"}</span>
    </div>
  )
}

// ---------------------------------------------------------------------------
// Output
// ---------------------------------------------------------------------------

export type TerminalAnimationOutputProps = React.ComponentProps<"div"> & {
  /** Custom renderer for each line; receives line, index, and visibility */
  renderLine?: (
    line: TerminalLine,
    index: number,
    visible: boolean
  ) => ReactNode
}

export function TerminalAnimationOutput({
  className,
  renderLine,
  ...props
}: TerminalAnimationOutputProps) {
  const { isTypingCommand, visibleLines, currentTab, activeTab } =
    useTerminalAnimationContext()

  if (isTypingCommand) {
    return null
  }

  return (
    <div
      aria-atomic="false"
      aria-live="polite"
      className={className}
      data-slot="terminal-animation-output"
      role="log"
      {...props}
    >
      {currentTab.lines.map((line, i) => {
        const visible = i < visibleLines
        const key = `${activeTab}-${i}`
        if (renderLine) {
          const content = renderLine(line, i, visible)
          if (!(visible || content)) {
            return null
          }
          return <div key={key}>{content}</div>
        }
        return (
          <TerminalAnimationOutputLine
            key={key}
            line={line}
            visible={visible}
          />
        )
      })}
    </div>
  )
}

// ---------------------------------------------------------------------------
// TrailingPrompt
// ---------------------------------------------------------------------------

export type TerminalAnimationTrailingPromptProps = React.ComponentProps<"div">

export function TerminalAnimationTrailingPrompt({
  className,
  children,
  ...props
}: TerminalAnimationTrailingPromptProps) {
  const { isTypingCommand, showCursor, visibleLines, currentTab } =
    useTerminalAnimationContext()

  const show =
    !isTypingCommand && showCursor && visibleLines >= currentTab.lines.length

  if (!show) {
    return null
  }

  return (
    <div
      className={className}
      data-slot="terminal-animation-trailing-prompt"
      {...props}
    >
      {children}
    </div>
  )
}

// ---------------------------------------------------------------------------
// TabList
// ---------------------------------------------------------------------------

export type TerminalAnimationTabListProps = React.ComponentProps<"div">

export function TerminalAnimationTabList({
  className,
  ...props
}: TerminalAnimationTabListProps) {
  return (
    <div
      aria-label="Terminal commands"
      className={className}
      data-slot="terminal-animation-tab-list"
      role="tablist"
      {...props}
    />
  )
}

// ---------------------------------------------------------------------------
// TabTrigger
// ---------------------------------------------------------------------------

export type TerminalAnimationTabTriggerProps =
  React.ComponentPropsWithoutRef<"button"> & {
    /** Tab index to activate when clicked */
    index: number
    /** Merge props onto child element instead of rendering a button */
    asChild?: boolean
  }

export function TerminalAnimationTabTrigger({
  index,
  asChild = false,
  className,
  children,
  ...props
}: TerminalAnimationTabTriggerProps) {
  const { activeTab, setActiveTab } = useTerminalAnimationContext()
  const isActive = activeTab === index

  const triggerProps = {
    role: "tab" as const,
    "aria-selected": isActive,
    "data-state": isActive ? "active" : "inactive",
    onClick: () => setActiveTab(index),
    children,
  }

  if (asChild) {
    return <Slot {...triggerProps} {...props} className={className} />
  }

  return (
    <button
      data-slot="terminal-animation-tab-trigger"
      type="button"
      {...triggerProps}
      className={className}
      {...props}
    />
  )
}

// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------

export function useTerminalAnimation() {
  return useTerminalAnimationContext()
}
```

**Optionally copy the demo as a starting point.**

```tsx title="terminal-animation-demo.tsx"
"use client"

import { useState } from "react"

import { cn } from "@/lib/utils"

import {
  TerminalAnimationBackgroundGradient,
  TerminalAnimationBlinkingCursor,
  TerminalAnimationCommandBar,
  TerminalAnimationContainer,
  TerminalAnimationContent,
  TerminalAnimationOutput,
  TerminalAnimationRoot,
  TerminalAnimationTabList,
  TerminalAnimationTabTrigger,
  TerminalAnimationTrailingPrompt,
  TerminalAnimationWindow,
  type TabContent,
  type TerminalLine,
} from "../ui/terminal-animation"

export interface TerminalAnimationDemoProps {
  /** Tab content for each command; defaults to defaultTerminalTabs */
  tabs?: TabContent[]
  /** Background image URL; when unset, BackgroundGradient is used */
  backgroundImage?: string
  /** Force dark mode for the terminal regardless of page theme */
  alwaysDark?: boolean
}

const backgroundImage =
  "/component-images/terminal-animation/terminal-animation-bg-2.png"

const tabs: TabContent[] = [
  {
    label: "install",
    command: "npm install",
    lines: [
      { text: "", delay: 80 },
      {
        text: "added 1,247 packages in 12s",
        color: "text-[#6FF7CC]",
        delay: 400,
      },
      { text: "", delay: 80 },
      {
        text: "  Cult UI is looking for funding",
        color: "text-slate-400",
        delay: 150,
      },
      {
        text: "    run `npm fund cult-ui` for details",
        color: "text-slate-500",
        delay: 100,
      },
      {
        text: "  +-----------------------+",
        color: "text-[#ED42B5]",
        delay: 120,
      },
      {
        text: "  |       CULT UI         |",
        color: "text-[#ED42B5]",
        delay: 120,
      },
      {
        text: "  |   Shadcn expanded    |",
        color: "text-[#ED42B5]",
        delay: 120,
      },
      {
        text: "  +-----------------------+",
        color: "text-[#ED42B5]",
        delay: 160,
      },
      { text: "", delay: 80 },
      {
        text: "  found 0 vulnerabilities",
        color: "text-[#ADFA1F]",
        delay: 250,
      },
    ],
  },
  {
    label: "build",
    command: "npm run build",
    lines: [
      { text: "", delay: 80 },
      {
        text: "  ▲ Next.js 16.1.6",
        color: "text-slate-300",
        delay: 300,
      },
      { text: "", delay: 80 },
      {
        text: "  Creating an optimized production build...",
        color: "text-slate-400",
        delay: 250,
      },
      {
        text: "  ✓ Compiled successfully",
        color: "text-[#6FF7CC]",
        delay: 200,
      },
      {
        text: "  ✓ Linting and checking validity of types",
        color: "text-[#6FF7CC]",
        delay: 150,
      },
      {
        text: "  ✓ Generating static pages (12/12)",
        color: "text-[#6FF7CC]",
        delay: 150,
      },
      {
        text: "  Route (app)  /  142 kB  |  First Load JS 198 kB",
        color: "text-slate-500",
        delay: 150,
      },
      {
        text: "  Route (app)  /blog 61 kB | First Load JS 57 kB",
        color: "text-slate-500",
        delay: 150,
      },
      {
        text: "  Route (app)  /about 75 kB | First Load JS 92 kB",
        color: "text-slate-500",
        delay: 150,
      },
      { text: "", delay: 80 },
      {
        text: "  ✓ Build completed in 4.2s",
        color: "text-[#6FF7CC]",
        delay: 300,
      },
    ],
  },
  {
    label: "deploy",
    command: "vercel deploy --prod",
    lines: [
      { text: "", delay: 80 },
      { text: "  Vercel CLI 39.2.0", color: "text-slate-400", delay: 200 },
      { text: "", delay: 80 },
      {
        text: "  > Deploying to production...",
        color: "text-[#ED42B5]",
        delay: 300,
      },
      { text: "", delay: 80 },
      { text: "  ✓ Building", color: "text-[#6FF7CC]", delay: 250 },
      { text: "  ✓ Uploading", color: "text-[#6FF7CC]", delay: 200 },
      { text: "  ✓ Finalizing", color: "text-[#6FF7CC]", delay: 200 },
      { text: "", delay: 80 },
      {
        text: "  Production: https://aisdkagents.vercel.app",
        color: "text-[#ED42B5]",
        delay: 400,
      },
      { text: "", delay: 80 },
      {
        text: "  ✓ Deployment complete",
        color: "text-[#6FF7CC]",
        delay: 250,
      },
    ],
  },
  {
    label: "test",
    command: "npm test",
    lines: [
      { text: "", delay: 80 },
      {
        text: "  PASS  src/components/Button.test.tsx",
        color: "text-slate-400",
        delay: 200,
      },
      {
        text: "    ✓ renders correctly",
        color: "text-[#ADFA1F]",
        delay: 100,
      },
      {
        text: "    ✓ handles click events",
        color: "text-[#ADFA1F]",
        delay: 100,
      },
      {
        text: "  PASS  src/utils/format.test.ts",
        color: "text-slate-400",
        delay: 150,
      },
      {
        text: "    ✓ formats currency",
        color: "text-[#ADFA1F]",
        delay: 100,
      },
      {
        text: "    ✓ formats dates",
        color: "text-[#ADFA1F]",
        delay: 100,
      },
      { text: "", delay: 80 },
      {
        text: "  Test Suites: 2 passed, 2 total",
        color: "text-[#ADFA1F]",
        delay: 200,
      },
      {
        text: "  Tests:       4 passed, 4 total",
        color: "text-[#ADFA1F]",
        delay: 150,
      },
      { text: "  Time:        1.234 s", color: "text-slate-500", delay: 100 },
    ],
  },
]

export function TerminalAnimationDemo() {
  const [animationKey, setAnimationKey] = useState(0)

  return (
    <TerminalAnimationRoot
      key={animationKey}
      alwaysDark={true}
      backgroundImage={backgroundImage}
      className="bg-background relative flex w-full justify-center overflow-clip"
      defaultActiveTab={1}
      hideCursorOnComplete={false}
      tabs={tabs}
    >
      <button
        className="absolute top-4 left-4 z-20 rounded-md border border-white/25 bg-black/45 px-3 py-1.5 font-mono text-[11px] tracking-wide text-white/90 uppercase transition hover:bg-black/65"
        onClick={() => setAnimationKey((prev) => prev + 1)}
        type="button"
      >
        Refresh
      </button>
      <a
        className="absolute top-4 right-4 z-20 rounded-md border border-white/25 bg-black/45 px-3 py-1.5 font-mono text-[11px] tracking-wide text-white/90 uppercase transition hover:bg-black/65"
        download="terminal-animation-image-2.png"
        href={backgroundImage}
      >
        Download BG
      </a>
      {!backgroundImage && <TerminalAnimationBackgroundGradient />}
      <TerminalAnimationContainer className="max-w-[43rem]">
        <TerminalAnimationWindow className="outline-1 outline-offset-[2px] outline-white/30">
          <TerminalAnimationContent className="min-h-[26rem]">
            <div className="flex items-center gap-2 leading-relaxed">
              <span className="text-muted-foreground font-mono text-[10px] select-none md:text-sm">
                $
              </span>
              <TerminalAnimationCommandBar
                className="text-foreground font-mono text-[10px] md:text-sm"
                cursor={<TerminalAnimationBlinkingCursor />}
              />
            </div>

            <TerminalAnimationOutput
              className="mt-1"
              renderLine={(
                line: TerminalLine,
                _i: number,
                visible: boolean
              ) => {
                if (!visible) {
                  return null
                }
                return (
                  <div className="leading-relaxed">
                    <span
                      className={cn(
                        "font-mono text-[10px] md:text-sm",
                        line.color ?? "text-muted-foreground"
                      )}
                    >
                      {line.text || "\u00A0"}
                    </span>
                  </div>
                )
              }}
            />
            <TerminalAnimationTrailingPrompt className="mt-1 flex items-center gap-2 leading-relaxed">
              <span className="text-muted-foreground font-mono text-sm select-none">
                $
              </span>
              <TerminalAnimationBlinkingCursor />
            </TerminalAnimationTrailingPrompt>
          </TerminalAnimationContent>

          <div className="flex justify-center pb-6">
            <TerminalAnimationTabList className="border-border bg-muted/50 inline-flex items-center gap-0 rounded-lg border px-1 py-1">
              {tabs.map((tab, i) => (
                <TerminalAnimationTabTrigger
                  className={cn(
                    "cursor-pointer rounded-md px-3.5 py-1 font-mono text-sm transition-all duration-150",
                    "data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=active]:font-medium",
                    "data-[state=inactive]:text-muted-foreground data-[state=inactive]:hover:text-foreground"
                  )}
                  index={i}
                  key={tab.label}
                >
                  {tab.label}
                </TerminalAnimationTabTrigger>
              ))}
            </TerminalAnimationTabList>
          </div>
        </TerminalAnimationWindow>
      </TerminalAnimationContainer>
    </TerminalAnimationRoot>
  )
}
```

**Add this cursor blink utility to your global stylesheet (for example,{" "}
  <code>globals.css</code>).**

```css
@keyframes caret-blink {
  0%,
  49% {
    opacity: 1;
  }
  50%,
  100% {
    opacity: 0;
  }
}

@utility animate-caret-blink {
  animation: caret-blink 1s step-end infinite;
}
```

**Update import paths to match your project setup.**

## Usage

```tsx
import {
  TerminalAnimationRoot,
  TerminalAnimationContainer,
  TerminalAnimationWindow,
  TerminalAnimationContent,
  TerminalAnimationCommandBar,
  TerminalAnimationOutput,
  TerminalAnimationTabList,
  TerminalAnimationTabTrigger,
  type TabContent,
} from "@/components/ui/terminal-animation";

const tabs: TabContent[] = [
  {
    label: "dev",
    command: "npm run dev",
    lines: [
      { text: "  ▲ Next.js 16.1.6", delay: 300 },
      {
        text: "  - Local: http://localhost:3000",
        delay: 200,
        color: "text-cyan-400",
      },
    ],
  },
];
```

```tsx
<TerminalAnimationRoot tabs={tabs} defaultActiveTab={0}>
  <TerminalAnimationContainer>
    <TerminalAnimationWindow>
      <TerminalAnimationContent>
        <TerminalAnimationCommandBar className="font-mono text-sm" />
        <TerminalAnimationOutput className="mt-2 space-y-1" />
      </TerminalAnimationContent>

      <TerminalAnimationTabList className="flex gap-2 p-4">
        {tabs.map((tab, index) => (
          <TerminalAnimationTabTrigger key={tab.label} index={index}>
            {tab.label}
          </TerminalAnimationTabTrigger>
        ))}
      </TerminalAnimationTabList>
    </TerminalAnimationWindow>
  </TerminalAnimationContainer>
</TerminalAnimationRoot>
```

## Controlled Tabs

Use `activeTab` + `onActiveTabChange` when tab state is managed externally:

```tsx
const [activeTab, setActiveTab] = useState(1)

<TerminalAnimationRoot
  tabs={tabs}
  activeTab={activeTab}
  onActiveTabChange={setActiveTab}
>
  {/* terminal layout */}
</TerminalAnimationRoot>
```

## Custom Output Rendering

Use `renderLine` to fully control how each output line appears:

```tsx
<TerminalAnimationOutput
  renderLine={(line, index, visible) => {
    if (!visible) return null;

    return (
      <div className="font-mono text-sm leading-relaxed">
        <span className="text-muted-foreground mr-3 select-none">
          {String(index + 1).padStart(2, "0")}
        </span>
        <span className={line.color ?? "text-foreground"}>
          {line.text || "\u00A0"}
        </span>
      </div>
    );
  }}
/>
```

## API Reference

### Data Types

#### `TerminalLine`

| Property | Type     | Description                               |
| -------- | -------- | ----------------------------------------- |
| `text`   | `string` | Text content for an output line           |
| `color`  | `string` | Optional utility class for line color     |
| `delay`  | `number` | Delay (ms) before revealing the next line |

#### `TabContent`

| Property  | Type             | Description                                |
| --------- | ---------------- | ------------------------------------------ |
| `label`   | `string`         | Tab label                                  |
| `command` | `string`         | Command text to animate in the prompt      |
| `lines`   | `TerminalLine[]` | Output lines revealed after command typing |

### `TerminalAnimationRoot`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `tabs` | `TabContent[]` | - | Data source for command tabs and output |
| `defaultActiveTab` | `number` | `0` | Initial tab index (uncontrolled mode) |
| `activeTab` | `number` | - | Controlled active tab index |
| `onActiveTabChange` | `(index: number) => void` | - | Called when tab changes |
| `backgroundImage` | `string` | - | Optional full-bleed background image URL |
| `alwaysDark` | `boolean` | `false` | Forces dark terminal theme regardless of page theme |

### `TerminalAnimationWindow`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `backgroundColor` | `string` | - | Inline background color override |
| `minHeight` | `string` | `"28rem"` | Minimum height of the terminal area |
| `animateOnVisible` | `boolean` | `true` | Plays slide-up transition when entering viewport |

### `TerminalAnimationCommandBar`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `cursor` | `ReactNode` | block cursor | Custom cursor while command is typing |

### `TerminalAnimationOutput`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `renderLine` | `(line, index, visible) => ReactNode` | - | Custom renderer for each output line |

### `TerminalAnimationTabTrigger`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `index` | `number` | - | Target tab index |
| `asChild` | `boolean` | `false` | Renders via `Slot` to merge props into a custom child |

## Exports

The component includes a full set of primitives for composition:

- `TerminalAnimationRoot`
- `TerminalAnimationBackgroundGradient`
- `TerminalAnimationContainer`
- `TerminalAnimationWindow`
- `TerminalAnimationContent`
- `TerminalAnimationBlinkingCursor`
- `TerminalAnimationCommandBar`
- `TerminalAnimationOutput`
- `TerminalAnimationOutputLine`
- `TerminalAnimationTrailingPrompt`
- `TerminalAnimationTabList`
- `TerminalAnimationTabTrigger`
- `useTerminalAnimation`
- `defaultTerminalTabs`
