All components

Animated Terminal

Animated Terminal is a React and Tailwind CSS data component from Cult UI, licensed MIT. Copy it and paste it into your project.

Cult UI MIT data

What it is

Animated Terminal is a React and Tailwind CSS data component from Cult UI, licensed MIT. Copy it and paste it into your project.

How to use it

Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.

Open in the catalogue

Five free copies a month. No card.

Needs

npm i @radix-ui/react-slot @radix-ui/react-use-controllable-state

Licence

This component comes from Cult UI and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.

Animated Terminal

apps/www/registry/default/ui/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-center bg-cover"
            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(
        "ml-0.5 inline-block h-[18px] w-[7px] translate-y-[3px] animate-caret-blink bg-muted-foreground 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()
}

Similar components