UIPackage
Menu

Terminal

terminal ui
Edit on GitHub

Terminal/command-line display with a macOS-style title bar, command history, prompt character, dark/light themes, auto-scroll, optional typing animation, and a line slot for custom formatting.

Also available for Vue ->

Installation

$ npx shadcn@latest add https://uipkge.dev/r/react/terminal.json
Named registry: npx shadcn@latest add @uipkge-react/terminal Installs to: components/ui/terminal/

Examples

Props

Name Type / Values Default Required
lines

Command history to render.

TerminalLine[] required
title

Window title shown in the title bar. Default 'bash'.

string optional
promptChar

Prompt character. Default '$'.

string optional
theme

Color theme. Default 'dark'.

'dark' | 'light' optional
autoScroll

Auto-scroll to bottom when new lines arrive. Default true.

boolean optional
typing

Animate lines typing in one-by-one. Default false.

boolean optional
typingSpeed

Typing speed in ms per line. Default 120.

number optional
maxHeight

Max height before scrolling. Default '400px'.

string optional

Schema

Type aliases from this item's source — use them to shape the data you pass in.

TerminalLine
interface TerminalLine {
  /** Prompt prefix shown before the command. Omit for output-only lines. */
  prompt?: string
  /** Command text shown after the prompt. */
  command?: string
  /** Output lines rendered below the command. */
  output?: string
  /** Override the line type: 'command' renders prompt+command, 'output' renders plain text. */
  type?: 'command' | 'output'
}

Files installed (2)

  • components/ui/terminal/Terminal.tsx 5.4 kB
    'use client'
    
    import * as React from 'react'
    import { cn } from '@/lib/utils'
    
    export interface TerminalLine {
      /** Prompt prefix shown before the command. Omit for output-only lines. */
      prompt?: string
      /** Command text shown after the prompt. */
      command?: string
      /** Output lines rendered below the command. */
      output?: string
      /** Override the line type: 'command' renders prompt+command, 'output' renders plain text. */
      type?: 'command' | 'output'
    }
    
    export interface TerminalProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'title'> {
      /** Command history to render. */
      lines: TerminalLine[]
      /** Window title shown in the title bar. Default 'bash'. */
      title?: string
      /** Prompt character. Default '$'. */
      promptChar?: string
      /** Color theme. Default 'dark'. */
      theme?: 'dark' | 'light'
      /** Auto-scroll to bottom when new lines arrive. Default true. */
      autoScroll?: boolean
      /** Animate lines typing in one-by-one. Default false. */
      typing?: boolean
      /** Typing speed in ms per line. Default 120. */
      typingSpeed?: number
      /** Max height before scrolling. Default '400px'. */
      maxHeight?: string
    }
    
    const Terminal = React.forwardRef<HTMLDivElement, TerminalProps>(
      (
        {
          lines,
          title = 'bash',
          promptChar = '$',
          theme = 'dark',
          autoScroll = true,
          typing = false,
          typingSpeed = 120,
          maxHeight = '400px',
          className,
          ...props
        },
        ref,
      ) => {
        const bodyRef = React.useRef<HTMLDivElement | null>(null)
        const [visibleCount, setVisibleCount] = React.useState(typing ? 0 : lines.length)
        const typingTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
    
        const resolvedLines = React.useMemo(
          () =>
            lines.map((l) => ({
              ...l,
              type: l.type ?? (l.prompt || l.command ? 'command' : 'output'),
              prompt: l.prompt ?? (l.type === 'output' ? '' : promptChar),
            })),
          [lines, promptChar],
        )
    
        const shownLines = resolvedLines.slice(0, visibleCount)
    
        const scrollToBottom = React.useCallback(() => {
          if (!autoScroll || !bodyRef.current) return
          bodyRef.current.scrollTop = bodyRef.current.scrollHeight
        }, [autoScroll])
    
        // Reset/advance visible count when the lines array changes.
        React.useEffect(() => {
          if (typing) {
            // Reset typing animation when lines change.
            setVisibleCount(0)
            return
          }
          setVisibleCount(lines.length)
          // Scroll on the next paint once the new lines are rendered.
          const raf = requestAnimationFrame(scrollToBottom)
          return () => cancelAnimationFrame(raf)
          // eslint-disable-next-line react-hooks/exhaustive-deps
        }, [lines.length, typing])
    
        // Drive the typing animation whenever visibleCount is below the total.
        React.useEffect(() => {
          if (!typing) return
          if (visibleCount >= lines.length) return
    
          typingTimerRef.current = setTimeout(() => {
            setVisibleCount((c) => c + 1)
            scrollToBottom()
          }, typingSpeed)
    
          return () => {
            if (typingTimerRef.current) {
              clearTimeout(typingTimerRef.current)
              typingTimerRef.current = null
            }
          }
        }, [typing, visibleCount, lines.length, typingSpeed, scrollToBottom])
    
        // Initial scroll-to-bottom for the non-typing case.
        React.useEffect(() => {
          if (!typing) scrollToBottom()
          // eslint-disable-next-line react-hooks/exhaustive-deps
        }, [])
    
        return (
          <div
            ref={ref}
            data-uipkge=""
            data-slot="terminal"
            data-theme={theme}
            className={cn(
              'relative overflow-hidden rounded-lg border font-mono text-sm shadow-sm',
              theme === 'dark' ? 'border-zinc-800 bg-zinc-950 text-zinc-200' : 'border-zinc-200 bg-zinc-50 text-zinc-800',
              className,
            )}
            {...props}
          >
            {/* Title bar */}
            <div
              className={cn(
                'flex items-center gap-2 border-b px-4 py-2.5',
                theme === 'dark' ? 'border-zinc-800 bg-zinc-900' : 'border-zinc-200 bg-zinc-100',
              )}
            >
              <div className="flex gap-1.5">
                <span className="size-3 rounded-full bg-red-500" />
                <span className="size-3 rounded-full bg-yellow-500" />
                <span className="size-3 rounded-full bg-green-500" />
              </div>
              <span className={cn('ml-2 text-xs', theme === 'dark' ? 'text-zinc-400' : 'text-zinc-500')}>{title}</span>
            </div>
    
            {/* Body */}
            <div ref={bodyRef} className="overflow-auto p-4 leading-relaxed" style={{ maxHeight }}>
              {shownLines.map((line, i) => (
                <div key={i} data-slot="terminal-line" className="break-words whitespace-pre-wrap">
                  {line.type === 'command' && (
                    <div data-slot="terminal-command" className="flex flex-wrap items-baseline gap-x-1.5">
                      <span
                        className={cn('shrink-0 font-semibold', theme === 'dark' ? 'text-green-400' : 'text-green-600')}
                      >
                        {line.prompt}
                      </span>
                      <span>{line.command}</span>
                    </div>
                  )}
                  {line.output && (
                    <div data-slot="terminal-output" className="text-zinc-400">
                      {line.output}
                    </div>
                  )}
                </div>
              ))}
            </div>
          </div>
        )
      },
    )
    Terminal.displayName = 'Terminal'
    
    export { Terminal }
  • components/ui/terminal/index.ts 0.1 kB
    export { Terminal, type TerminalProps, type TerminalLine } from './Terminal'

Raw manifest: https://uipkge.dev/r/react/terminal.json