Developer

Terminal

Shows a command-line session of prompts, commands and output, with a copy button on each command.

Built on
Async Clipboard APICSS generated content
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
2.8 kB
Dependencies
React
pnpm install
Packages: +38
Done in 2.4s
Code
import { Terminal, TerminalCommand, TerminalOutput } from "@/components/equip/terminal";

export default function Demo() {
  return (
    <Terminal label="Install steps" style={{ width: 224 }}>
      <TerminalCommand>pnpm install</TerminalCommand>
      <TerminalOutput>Packages: +38</TerminalOutput>
      <TerminalOutput tone="ok">Done in 2.4s</TerminalOutput>
    </Terminal>
  );
}

Examples

Session with a window title

Each command has its own copy button; the prompt is drawn by CSS, so selecting the whole session never picks it up.

~/projects/web
equip login
Signed in as ops@example.com
equip deploy --prod --region eu-west-1
Building 214 modules
Uploading 3.1 MB to eu-west-1
Live at https://web-7f3a9c.example.com
Code
import { Terminal, TerminalCommand, TerminalOutput } from "@/components/equip/terminal";

export default function Example() {
  return (
    <Terminal label="Deploy to production" title="~/projects/web" style={{ maxWidth: 440 }}>
      <TerminalCommand>equip login</TerminalCommand>
      <TerminalOutput tone="ok">Signed in as ops@example.com</TerminalOutput>
      <TerminalCommand>equip deploy --prod --region eu-west-1</TerminalCommand>
      <TerminalOutput>Building 214 modules</TerminalOutput>
      <TerminalOutput>Uploading 3.1 MB to eu-west-1</TerminalOutput>
      <TerminalOutput tone="ok">Live at https://web-7f3a9c.example.com</TerminalOutput>
    </Terminal>
  );
}

Warnings and errors

Tone only colours the text, so keep words like warning and error in the output itself, as real tools do.

pnpm db:migrate
Applying 0042_add_invoices_index
warning: index build will lock invoices for about 4s
error: relation "invoice_lines" does not exist
Code
import { Terminal, TerminalCommand, TerminalOutput } from "@/components/equip/terminal";

export default function Example() {
  return (
    <Terminal label="Database migration" style={{ maxWidth: 440 }}>
      <TerminalCommand>pnpm db:migrate</TerminalCommand>
      <TerminalOutput>Applying 0042_add_invoices_index</TerminalOutput>
      <TerminalOutput tone="warn">warning: index build will lock invoices for about 4s</TerminalOutput>
      <TerminalOutput tone="danger">error: relation "invoice_lines" does not exist</TerminalOutput>
    </Terminal>
  );
}

Custom prompt

winget install Equip.Cli
Successfully installed
equip --version
equip 2.8.1
Code
import { Terminal, TerminalCommand, TerminalOutput } from "@/components/equip/terminal";

export default function Example() {
  return (
    <Terminal label="PowerShell setup" prompt="PS>" style={{ maxWidth: 440 }}>
      <TerminalCommand>winget install Equip.Cli</TerminalCommand>
      <TerminalOutput tone="ok">Successfully installed</TerminalOutput>
      <TerminalCommand>equip --version</TerminalCommand>
      <TerminalOutput>equip 2.8.1</TerminalOutput>
    </Terminal>
  );
}

Props

PropTypeDefaultWhat it does
labelstringnoneTerminal: names the session for assistive tech.
titleReactNodenoneTerminal: a window title above the session.
promptstring"$"Terminal: the glyph before every command, drawn by CSS.
childrenReactNodenoneTerminalCommand: the command text that is shown and copied.
tone"default" | "ok" | "warn" | "danger""default"TerminalOutput: colours the output line.
...restComponentProps<"div">noneEvery native div attribute, on each of the three.

Anatomy

root.eq-terminalA labelled group.
title.eq-terminal-title
body.eq-terminal-body
command.eq-terminal-commandIts prompt is generated content.
command text.eq-terminal-text
copy.eq-terminal-copyShows on hover and focus; always shown on touch.
output.eq-terminal-output
status.eq-terminal-srAnnounces Copied.

Keyboard

TabMoves to each command's copy button, which appears as it takes focus.
EnterSpaceCopies that command.

Notes

  • The prompt is drawn by CSS, so it is never selected, copied or read aloud.
  • Copy takes the command's rendered text, so children may include highlighted spans.
  • Tone only colours the output; keep words like warning and error in the text itself, as real tools do.
  • It follows the surface tokens like every other part rather than forcing a dark theme.
  • The file is a client component only for the copy buttons; everything else renders on the server.
  • Copy falls back to a temporary textarea and execCommand where the async clipboard is unavailable.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/terminal.tsx124 lines
"use client";

import { useEffect, useRef, useState } from "react";
import type { ComponentProps, CSSProperties, ReactNode } from "react";
import "./terminal.css";

export type TerminalProps = Omit<ComponentProps<"div">, "title"> & {
  /** Names the session for assistive tech, such as "Install steps". */
  label: string;
  /** A window title shown above the session. */
  title?: ReactNode;
  /** The glyph before every command. Drawn by CSS, so it is never selected or copied. */
  prompt?: string;
};

export type TerminalCommandProps = ComponentProps<"div">;

export type TerminalOutputProps = ComponentProps<"div"> & {
  tone?: "default" | "ok" | "warn" | "danger";
};

const COPIED_MS = 1600;

type CopyState = "idle" | "copied" | "failed";

/** Last resort for browsers without the async clipboard: a throwaway textarea inside the part. */
function legacyCopy(text: string, host: HTMLElement) {
  const active = document.activeElement;
  const area = document.createElement("textarea");
  area.value = text;
  area.setAttribute("readonly", "");
  area.setAttribute("aria-hidden", "true");
  area.style.position = "fixed";
  area.style.inset = "0 auto auto 0";
  area.style.opacity = "0";
  area.style.pointerEvents = "none";
  host.appendChild(area);
  area.select();
  let ok = false;
  try {
    ok = document.execCommand("copy");
  } catch {
    ok = false;
  }
  host.removeChild(area);
  if (active instanceof HTMLElement) active.focus();
  return ok;
}

export function Terminal({ label, title, prompt = "$", className, style, children, ...rest }: TerminalProps) {
  return (
    <div
      role="group"
      aria-label={label}
      {...rest}
      className={["eq-terminal", className].filter(Boolean).join(" ")}
      style={{ "--_prompt": JSON.stringify(prompt), ...style } as CSSProperties}
    >
      {title != null && <div className="eq-terminal-title">{title}</div>}
      <div className="eq-terminal-body">{children}</div>
    </div>
  );
}

export function TerminalCommand({ className, children, ...rest }: TerminalCommandProps) {
  const rowRef = useRef<HTMLDivElement>(null);
  const textRef = useRef<HTMLElement>(null);
  const [copied, setCopied] = useState<CopyState>("idle");

  useEffect(() => {
    if (copied === "idle") return;
    const t = window.setTimeout(() => setCopied("idle"), COPIED_MS);
    return () => window.clearTimeout(t);
  }, [copied]);

  async function handleCopy() {
    const text = textRef.current?.textContent ?? "";
    let ok = false;
    try {
      if (navigator.clipboard?.writeText) {
        await navigator.clipboard.writeText(text);
        ok = true;
      }
    } catch {
      ok = false;
    }
    if (!ok && rowRef.current) ok = legacyCopy(text, rowRef.current);
    setCopied(ok ? "copied" : "failed");
  }

  return (
    <div {...rest} ref={rowRef} className={["eq-terminal-command", className].filter(Boolean).join(" ")}>
      <code ref={textRef} className="eq-terminal-text">
        {children}
      </code>
      <button
        type="button"
        className="eq-terminal-copy"
        data-state={copied}
        aria-label="Copy command"
        title={copied === "copied" ? "Copied" : copied === "failed" ? "Copy failed" : "Copy command"}
        onClick={handleCopy}
      >
        {copied === "copied" ? (
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
            <path d="M3.5 8.5l3 3 6-7" pathLength={1} />
          </svg>
        ) : (
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
            <rect x="5.5" y="5.5" width="8" height="8" rx="1.5" />
            <path d="M10.5 3.5v-.5A1.5 1.5 0 0 0 9 1.5H4A1.5 1.5 0 0 0 2.5 3v5A1.5 1.5 0 0 0 4 9.5h.5" />
          </svg>
        )}
      </button>
      <span className="eq-terminal-sr" role="status">
        {copied === "copied" ? "Copied" : copied === "failed" ? "Copy failed" : ""}
      </span>
    </div>
  );
}

export function TerminalOutput({ tone = "default", className, ...rest }: TerminalOutputProps) {
  return <div {...rest} className={["eq-terminal-output", className].filter(Boolean).join(" ")} data-tone={tone} />;
}
components/equip/terminal.css173 lines
@layer equip {
  .eq-terminal {
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    min-width: 0;
    border: 1px solid var(--eq-line);
    border-radius: var(--eq-radius-l);
    background: var(--eq-surface);
    color: var(--eq-ink);
    overflow: hidden;
  }

  .eq-terminal-title {
    padding: var(--eq-space-1) var(--eq-space-3);
    border-bottom: 1px solid var(--eq-line);
    overflow: hidden;
    color: var(--eq-ink-2);
    font: var(--eq-weight) var(--eq-text-xs) / 1.6 var(--eq-font);
    text-align: center;
    white-space: nowrap;
    text-overflow: ellipsis;
  }

  .eq-terminal-body {
    padding: var(--eq-space-2) var(--eq-space-2) var(--eq-space-2) var(--eq-space-3);
    font-family: var(--eq-font-mono);
    font-variation-settings: var(--eq-font-mono-settings);
    font-size: var(--eq-text-s);
    font-weight: var(--eq-weight);
    line-height: 1.6;
  }

  .eq-terminal-command {
    display: flex;
    align-items: flex-start;
    gap: var(--eq-space-2);
  }

  .eq-terminal-command + .eq-terminal-command,
  .eq-terminal-output + .eq-terminal-command {
    margin-top: var(--eq-space-2);
  }

  /* The prompt is generated content: never selected, never copied, not read aloud. */
  .eq-terminal-command::before {
    content: var(--_prompt);
    flex: none;
    color: var(--eq-ink-3);
    user-select: none;
    -webkit-user-select: none;
  }

  @supports (content: "a" / "b") {
    .eq-terminal-command::before {
      content: var(--_prompt) / "";
    }
  }

  .eq-terminal-text {
    flex: 1 1 auto;
    min-width: 0;
    font: inherit;
    font-variation-settings: inherit;
    white-space: pre-wrap;
    overflow-wrap: anywhere;
  }

  .eq-terminal-copy {
    display: grid;
    flex: none;
    place-items: center;
    width: 24px;
    height: 24px;
    /* Centred on the first line without making the row taller. */
    margin-block: calc((1lh - 24px) / 2);
    padding: 0;
    border: 0;
    border-radius: var(--eq-radius-s);
    background: transparent;
    color: var(--eq-ink-2);
    font: inherit;
    cursor: pointer;
    transition:
      opacity var(--eq-dur) var(--eq-ease),
      background-color var(--eq-dur) var(--eq-ease),
      color var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur) var(--eq-ease);
  }

  .eq-terminal-copy > svg {
    width: 14px;
    height: 14px;
  }

  /* With a pointer the button waits for hover or focus; on touch it is always there. */
  @media (hover: hover) {
    .eq-terminal-copy {
      opacity: 0;
    }

    .eq-terminal-command:is(:hover, :focus-within) .eq-terminal-copy,
    .eq-terminal-copy:not([data-state="idle"]) {
      opacity: 1;
    }

    .eq-terminal-copy:hover {
      background: var(--eq-tint);
      color: var(--eq-ink);
    }
  }

  .eq-terminal-copy:active {
    scale: var(--eq-press);
  }

  .eq-terminal-copy:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }

  .eq-terminal-copy[data-state="copied"] {
    color: var(--eq-ok);
  }

  .eq-terminal-copy[data-state="failed"] {
    color: var(--eq-danger);
  }

  /* Copied: the tick draws itself in. */
  .eq-terminal-copy[data-state="copied"] path {
    stroke-dasharray: 1;
    stroke-dashoffset: 0;
    transition: stroke-dashoffset var(--eq-dur-move) var(--eq-ease);
  }

  @starting-style {
    .eq-terminal-copy[data-state="copied"] path {
      stroke-dashoffset: 1;
    }
  }

  .eq-terminal-output {
    padding-inline-end: calc(24px + var(--eq-space-2));
    color: var(--eq-ink-2);
    white-space: pre-wrap;
    overflow-wrap: anywhere;
  }

  .eq-terminal-output[data-tone="ok"] {
    color: var(--eq-ok);
  }

  .eq-terminal-output[data-tone="warn"] {
    color: var(--eq-warn);
  }

  .eq-terminal-output[data-tone="danger"] {
    color: var(--eq-danger);
  }

  /* Read by screen readers, never seen. */
  .eq-terminal-sr {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
    border: 0;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index