Velora UI
All components

Terminal

A terminal window that types its lines in view.

1.2 KB gzippedDeps: motion/reactReduced-motion safeBase UI & Radix compatible
bash

$

Installation

npx shadcn@latest add https://velora.colorlib.com/r/terminal.json

Installs with: motion

Source

"use client";

import { useEffect, useRef, useState } from "react";
import { useInView, useReducedMotion } from "motion/react";

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

interface TerminalProps {
  /** Lines to type. Prefix with "$ " to render a prompt-colored command. */
  lines: string[];
  title?: string;
  /** ms per character */
  speed?: number;
  className?: string;
}

/**
 * A terminal window that types its lines when scrolled into view.
 */
export function Terminal({
  lines,
  title = "bash",
  speed = 18,
  className,
}: TerminalProps) {
  const ref = useRef<HTMLDivElement>(null);
  const inView = useInView(ref, { once: true, margin: "0px 0px -15% 0px" });
  const reducedMotion = useReducedMotion();
  const [progress, setProgress] = useState({ line: 0, char: 0 });

  const done =
    reducedMotion ||
    (progress.line >= lines.length - 1 &&
      progress.char >= (lines[lines.length - 1]?.length ?? 0));

  useEffect(() => {
    if (!inView || reducedMotion || done) return;
    const current = lines[progress.line] ?? "";
    const timeout = setTimeout(
      () =>
        setProgress((p) =>
          p.char < current.length
            ? { ...p, char: p.char + 1 }
            : { line: p.line + 1, char: 0 }
        ),
      progress.char === 0 ? 260 : speed
    );
    return () => clearTimeout(timeout);
  }, [inView, reducedMotion, done, progress, lines, speed]);

  const visibleLines = reducedMotion
    ? lines
    : lines
        .slice(0, progress.line + 1)
        .map((l, i) => (i === progress.line ? l.slice(0, progress.char) : l));

  return (
    <div
      ref={ref}
      data-slot="terminal"
      className={cn(
        "w-full overflow-hidden rounded-xl border bg-neutral-950 font-mono text-sm shadow-xl",
        className
      )}
    >
      <div className="flex items-center gap-3 border-b border-white/10 px-4 py-2.5">
        <div className="flex gap-1.5">
          <span className="size-3 rounded-full bg-red-500/80" />
          <span className="size-3 rounded-full bg-yellow-500/80" />
          <span className="size-3 rounded-full bg-green-500/80" />
        </div>
        <span className="text-xs text-neutral-500">{title}</span>
      </div>
      <div className="min-h-32 space-y-1.5 p-4 text-neutral-300">
        {visibleLines.map((line, i) => {
          const isCommand = lines[i]?.startsWith("$ ");
          const isLast = i === visibleLines.length - 1;
          return (
            <p key={i} className="leading-relaxed">
              {isCommand ? (
                <>
                  <span className="text-emerald-400">$ </span>
                  <span className="text-neutral-100">{line.slice(2)}</span>
                </>
              ) : (
                <span className="text-neutral-400">{line}</span>
              )}
              {isLast && !done && (
                <span className="animate-pulse text-neutral-100">▍</span>
              )}
            </p>
          );
        })}
      </div>
    </div>
  );
}