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 installPackages: +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 loginSigned in as ops@example.com
equip deploy --prod --region eu-west-1Building 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:migrateApplying 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.CliSuccessfully installed
equip --versionequip 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
| Prop | Type | Default | What it does |
|---|---|---|---|
label | string | none | Terminal: names the session for assistive tech. |
title | ReactNode | none | Terminal: a window title above the session. |
prompt | string | "$" | Terminal: the glyph before every command, drawn by CSS. |
children | ReactNode | none | TerminalCommand: the command text that is shown and copied. |
tone | "default" | "ok" | "warn" | "danger" | "default" | TerminalOutput: colours the output line. |
...rest | ComponentProps<"div"> | none | Every native div attribute, on each of the three. |
Anatomy
| root | .eq-terminal | A labelled group. |
|---|---|---|
| title | .eq-terminal-title | |
| body | .eq-terminal-body | |
| command | .eq-terminal-command | Its prompt is generated content. |
| command text | .eq-terminal-text | |
| copy | .eq-terminal-copy | Shows on hover and focus; always shown on touch. |
| output | .eq-terminal-output | |
| status | .eq-terminal-sr | Announces Copied. |
Keyboard
| Tab | Moves to each command's copy button, which appears as it takes focus. |
|---|---|
| EnterSpace | Copies 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;
}
}