Mini Terminal
A tiny shell for docs and onboarding: real commands you define, history with ↑/↓, Tab completion and clear.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/mini-terminal.json'use client'
import { useEffect, useRef, useState } from 'react'
import './mini-terminal.css'
type Entry = { cmd?: string; out: string; err?: boolean }
export interface MiniTerminalProps {
commands: Record<string, (args: string[]) => string>
prompt?: string
welcome?: string
title?: string
fill?: 'dark' | 'light'
className?: string
}
/** Fake shell with history and tab completion. */
export function MiniTerminal({ commands, prompt = '~ $', welcome = 'Type help to see commands.', title = 'sundae — zsh', fill = 'dark', className }: MiniTerminalProps) {
const [log, setLog] = useState<Entry[]>([{ out: welcome }])
const [v, setV] = useState('')
const [hist, setHist] = useState<string[]>([])
const [hi, setHi] = useState(-1)
const body = useRef<HTMLDivElement>(null)
const inp = useRef<HTMLInputElement>(null)
useEffect(() => { body.current?.scrollTo({ top: body.current.scrollHeight }) }, [log])
const all = { ...commands, help: () => `Commands: ${[...Object.keys(commands), 'clear', 'history'].join(', ')}`, history: () => hist.map((h, i) => `${i + 1} ${h}`).join('\n') || '(empty)' }
const run = () => {
const line = v.trim(); setV(''); setHi(-1)
if (!line) return setLog((l) => [...l, { cmd: '', out: '' }])
setHist((h) => [...h, line])
const [name, ...args] = line.split(/\s+/)
if (name === 'clear') return setLog([])
const f = (all as Record<string, (a: string[]) => string>)[name]
setLog((l) => [...l, f ? { cmd: line, out: f(args) } : { cmd: line, out: `command not found: ${name}`, err: true }])
}
const key = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') { e.preventDefault(); run() }
else if (e.key === 'ArrowUp' && hist.length) { e.preventDefault(); const i = hi < 0 ? hist.length - 1 : Math.max(0, hi - 1); setHi(i); setV(hist[i]) }
else if (e.key === 'ArrowDown' && hi >= 0) { e.preventDefault(); const i = hi + 1; if (i >= hist.length) { setHi(-1); setV('') } else { setHi(i); setV(hist[i]) } }
else if (e.key === 'Tab') { e.preventDefault(); const m = [...Object.keys(all), 'clear'].filter((c) => c.startsWith(v.trim())); if (m.length === 1) setV(m[0] + ' '); else if (m.length > 1 && v.trim()) setLog((l) => [...l, { cmd: v, out: m.join(' ') }]) }
else if (e.key === 'l' && e.ctrlKey) { e.preventDefault(); setLog([]) }
}
return (
<div className={['sd-trm', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-trm__top" aria-hidden><i /><i /><i /><span>{title}</span></div>
<div ref={body} className="sd-trm__body" onClick={() => inp.current?.focus()}>
<div role="log" aria-live="polite" aria-label="Terminal output">
{log.map((e, i) => (
<div key={i}>
{e.cmd !== undefined && <p className="sd-trm__cmd"><span className="sd-trm__p">{prompt}</span>{e.cmd}</p>}
{e.out && <p className={e.err ? 'sd-trm__out sd-trm__err' : 'sd-trm__out'}>{e.out}</p>}
</div>
))}
</div>
<div className="sd-trm__in"><span className="sd-trm__p" aria-hidden>{prompt}</span><input ref={inp} aria-label="Command" spellCheck={false} autoCapitalize="off" autoComplete="off" value={v} onChange={(e) => setV(e.target.value)} onKeyDown={key} /></div>
</div>
</div>
)
}.sd-trm { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-trm[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-trm :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-trm :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-trm .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-trm { width: min(100%, 360px); border-radius: 14px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; font: 12.5px/1.6 ui-monospace, SFMono-Regular, monospace; }
.sd-trm__top { display: flex; align-items: center; gap: 6px; height: 32px; padding: 0 12px; border-bottom: 1px solid var(--line); }
.sd-trm__top i { width: 10px; height: 10px; border-radius: 99px; box-shadow: inset 0 0 0 1.5px var(--line); }
.sd-trm__top span { flex: 1; text-align: center; font: 500 11.5px/1 ui-sans-serif, system-ui; color: var(--mute); margin-right: 36px; }
.sd-trm__body { height: 300px; overflow-y: auto; padding: 10px 12px; cursor: text; }
.sd-trm__out { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; color: color-mix(in srgb, var(--fg) 80%, transparent); }
.sd-trm__cmd { margin: 0; white-space: pre-wrap; }
.sd-trm__p { color: var(--mute); margin-right: 8px; }
.sd-trm__err { color: var(--fg); }
.sd-trm__err::before { content: '× '; font-weight: 700; }
.sd-trm__in { display: flex; }
.sd-trm__in input { flex: 1; min-width: 0; border: 0; background: none; color: var(--fg); font: inherit; outline: 0; caret-color: var(--fg); }
@media (prefers-reduced-motion: reduce) { .sd-trm *, .sd-trm *::before, .sd-trm *::after { animation: none !important; transition: none !important; } }Overview
Type a command and press Enter; output prints below with a prompt. ↑ and ↓ walk history, Tab completes command names, Ctrl+L clears.
Commands are plain functions you pass in, so it can demo a CLI without a backend.
When to use
- CLI documentation, onboarding, playful about pages.
When not to use
- Running real shell commands on a server.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/mini-terminal.json$
npx shadcn@latest add https://sundaeui.dev/r/mini-terminal.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/mini-terminal.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/mini-terminal.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy mini-terminal.tsx and mini-terminal.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { MiniTerminal } from '@/components/sundae/mini-terminal'
<MiniTerminal />Props
| Prop | Type | Default |
|---|---|---|
commands*Your commands. | Record<string, (args: string[]) => string> | — |
promptPrompt text. | string | '~ $' |
welcomeFirst line. | string | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Output is a log region; the input is a labelled text field.
- Everything works from the keyboard; there are no hidden gestures.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-trm; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- Enter
- Run
- ↑ / ↓
- History
- Tab
- Complete
- Ctrl + L
- Clear
FAQ
- Is Mini Terminal free?
- Yes. Mini Terminal is free for personal and commercial projects, with full source on this page.
- How do I install Mini Terminal?
- Run npx shadcn@latest add https://sundaeui.dev/r/mini-terminal.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Mini Terminal support keyboard and screen readers?
- Output is a log region; the input is a labelled text field. Everything works from the keyboard; there are no hidden gestures.
- Does Mini Terminal respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →API Key Row
Components · RefinedCode Tabs
Components · RefinedWebhook Endpoints
Components · RefinedIcon Picker
Components · RefinedToast Stack
Components · Refined- Pro
Sheet Drawer
Components · Refined