terminal
A Core block/component from Magic UI — clean-room reimplemented for Domphy (see methodology). Call terminal() with no arguments for a working demo, or edit the code below live.
Implementation notes
IntersectionObserver gates the first line's start when startOnView is on (fails open — starts immediately — if IntersectionObserver is unavailable, e.g. non-browser test runtimes). Typing lines reveal via a setInterval character loop (default ~60ms/char per the researchNote); fade lines use the motion() patch with a State whose value is set after a delayed setTimeout so the entrance is genuinely deferred rather than firing on mount. Auto-sequencing computes cumulative start delays from each line's own duration; explicit per-line delay overrides and can overlap/parallel with neighboring lines as the spec allows. Traffic-light dots and the cursor render as color-glyphs (SVG fill=currentColor / a text block character) rather than backgroundColor fills, specifically to satisfy the tone-background-inherit doctor rule while still being visually a solid vivid dot/block.
Status: ported · Reference: Magic UI original
// magicui "Terminal" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). A
// decorative macOS-style terminal window that plays back a scripted,
// pre-authored sequence of typed commands and faded-in output lines. This is
// a marketing/decorative widget, not a real shell — no input is read and
// nothing is executed.
import type { DomphyElement, ElementNode, Listener, State, StyleObject } from "@domphy/core";
import { hashString, toState } from "@domphy/core";
import { type MotionKeyframe, motion, small } from "@domphy/ui";
import { type ThemeColor, themeColor, themeSpacing } from "@domphy/theme";
export interface TerminalTypingLine {
type: "typing";
/** Full command text, revealed one character at a time. */
text: string;
/** Characters revealed per second. Defaults to ~16.7 (≈60ms/character). */
charsPerSecond?: number;
/** Explicit start delay in ms — overrides automatic sequencing for this line. */
delay?: number;
/** Host tag for the line row. Defaults to "div". */
tag?: "div" | "p";
/** Optional color tone override (e.g. a highlighted command). Defaults to "neutral". */
color?: ThemeColor;
}
export interface TerminalFadeLine {
type: "fade";
/** Output text, revealed as one fully-formed block. */
text: string;
/** Explicit start delay in ms — overrides automatic sequencing for this line. */
delay?: number;
/** Optional color tone override (e.g. success green for a "done" line). Defaults to "neutral". */
color?: ThemeColor;
/** Host tag for the line row. Defaults to "div". */
tag?: "div" | "p";
}
export type TerminalLine = TerminalTypingLine | TerminalFadeLine;
export interface TerminalProps {
/** Ordered script of typed-command and fade-output lines. Defaults to a demo install script. */
lines?: TerminalLine[];
/** Auto-sequence lines one after another (each waits for the previous to finish). Defaults to true. */
sequence?: boolean;
/** Only start playback once the window scrolls into view. Defaults to true. */
startOnView?: boolean;
/** Window title shown centered in the header strip. Defaults to "zsh". */
title?: string;
style?: StyleObject;
}
const DEFAULT_LINES: TerminalLine[] = [
{ type: "typing", text: "npx domphy@latest init" },
{ type: "fade", text: "Scaffolding your project…" },
{ type: "fade", text: "Installing dependencies…" },
{ type: "typing", text: "npm run dev" },
{ type: "fade", text: "✔ Ready on http://localhost:3000", color: "success" },
];
const DEFAULT_CHARS_PER_SECOND = 1000 / 60; // ~60ms per character
const FADE_LINE_DURATION_MS = 350;
const LINE_GAP_MS = 150;
const CURSOR_KEYFRAMES = { "0%,49%": { opacity: 1 }, "50%,100%": { opacity: 0 } };
const CURSOR_ANIMATION_NAME = `terminal-cursor-${hashString(JSON.stringify(CURSOR_KEYFRAMES))}`;
/** Computes each line's start delay (ms): explicit `delay` wins, otherwise the
* running total of every previous line's own duration when `sequence` is on. */
function computeSchedule(lines: TerminalLine[], sequence: boolean): number[] {
const delays: number[] = [];
let cumulative = 0;
for (const line of lines) {
const startDelay = line.delay ?? (sequence ? cumulative : 0);
delays.push(startDelay);
const duration =
line.type === "typing"
? (line.text.length / (line.charsPerSecond ?? DEFAULT_CHARS_PER_SECOND)) * 1000
: FADE_LINE_DURATION_MS;
cumulative = startDelay + duration + LINE_GAP_MS;
}
return delays;
}
// A solid-filled circular glyph, not a themed "surface" — painted via `fill:
// currentColor` + a fixed-shift `color` (same idiom as icon()/badge()'s own
// fixed-shift `color`) rather than `backgroundColor`, so it reads as a vivid
// indicator dot without tripping the tone-background-inherit surface rule.
function trafficLightDot(color: ThemeColor): DomphyElement<"span"> {
return {
span: [
{
svg: [{ circle: null, cx: "12", cy: "12", r: "12" }],
viewBox: "0 0 24 24",
fill: "currentColor",
role: "img",
ariaHidden: "true",
style: { width: "100%", height: "100%" },
} as DomphyElement<"svg">,
],
_key: color,
style: {
display: "inline-block",
width: themeSpacing(3),
height: themeSpacing(3),
color: (listener: Listener) => themeColor(listener, "shift-9", color),
},
};
}
// A solid block glyph (not a `backgroundColor` fill) so its fixed-shift tone
// reads as a text-color glyph rather than a hardcoded surface — same idiom as
// trafficLightDot() above.
function blinkingCursor(): DomphyElement<"span"> {
const element = {
span: "▊",
ariaHidden: "true",
style: {
display: "inline-block",
marginInlineStart: themeSpacing(1),
color: (listener: Listener) => themeColor(listener, "shift-9"),
animation: `${CURSOR_ANIMATION_NAME} 1s steps(1) infinite`,
[`@keyframes ${CURSOR_ANIMATION_NAME}`]: CURSOR_KEYFRAMES,
},
};
return element as DomphyElement<"span">;
}
function typingLineElement(
line: TerminalTypingLine,
startDelayMs: number,
started: State<boolean>,
): DomphyElement {
const revealed = toState("");
const tag = line.tag ?? "div";
const charsPerSecond = line.charsPerSecond ?? DEFAULT_CHARS_PER_SECOND;
const intervalMs = Math.max(16, 1000 / charsPerSecond);
return {
[tag]: [
{ span: "$ ", ariaHidden: "true", style: { color: (listener: Listener) => themeColor(listener, "shift-6") } },
{ span: (listener: Listener) => revealed.get(listener) },
blinkingCursor(),
],
style: {
display: "flex",
alignItems: "center",
whiteSpace: "pre",
color: (listener: Listener) => themeColor(listener, "shift-9", line.color ?? "neutral"),
},
_onMount: (node: ElementNode) => {
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
let intervalHandle: ReturnType<typeof setInterval> | null = null;
const runTyping = () => {
let index = 0;
intervalHandle = setInterval(() => {
index += 1;
revealed.set(line.text.slice(0, index));
if (index >= line.text.length && intervalHandle) {
clearInterval(intervalHandle);
intervalHandle = null;
}
}, intervalMs);
};
const schedule = () => {
timeoutHandle = setTimeout(runTyping, startDelayMs);
};
const update = (value: boolean) => {
if (value) schedule();
};
update(started.get());
const release = started.addListener(update);
node.addHook("Remove", () => {
release();
if (timeoutHandle) clearTimeout(timeoutHandle);
if (intervalHandle) clearInterval(intervalHandle);
});
},
// The host tag is a runtime-computed string (`line.tag ?? "div"`), so it
// can't be narrowed to one arm of the DomphyElement tag union statically.
} as unknown as DomphyElement;
}
function fadeLineElement(
line: TerminalFadeLine,
startDelayMs: number,
started: State<boolean>,
): DomphyElement {
const initialFrame: MotionKeyframe = { opacity: 0, y: 6 };
const revealFrame: MotionKeyframe = { opacity: 1, y: 0 };
const frame = toState<MotionKeyframe>(initialFrame);
const tag = line.tag ?? "div";
return {
[tag]: line.text,
style: {
color: (listener: Listener) => themeColor(listener, "shift-9", line.color ?? "neutral"),
},
$: [motion({ initial: initialFrame, animate: frame, transition: { duration: FADE_LINE_DURATION_MS, easing: "ease-out" } })],
_onMount: (node: ElementNode) => {
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
const reveal = () => {
timeoutHandle = setTimeout(() => frame.set(revealFrame), startDelayMs);
};
const update = (value: boolean) => {
if (value) reveal();
};
update(started.get());
const release = started.addListener(update);
node.addHook("Remove", () => {
release();
if (timeoutHandle) clearTimeout(timeoutHandle);
});
},
// Same runtime-computed-tag caveat as typingLineElement() above.
} as unknown as DomphyElement;
}
/**
* Decorative macOS-style terminal window that plays back a scripted sequence
* of typed commands and faded-in output. Call with no arguments for a
* working demo — a five-line install script that types/fades in on view.
*/
function terminal(props: TerminalProps = {}): DomphyElement<"div"> {
const lines = props.lines ?? DEFAULT_LINES;
const sequence = props.sequence ?? true;
const startOnView = props.startOnView ?? true;
const title = props.title ?? "zsh";
const started = toState(!startOnView);
const delays = computeSchedule(lines, sequence);
const lineElements = lines.map((line, index) => ({
...(line.type === "typing"
? typingLineElement(line, delays[index], started)
: fadeLineElement(line, delays[index], started)),
_key: `line-${index}`,
}));
return {
div: [
{
div: [
{ div: [trafficLightDot("danger"), trafficLightDot("warning"), trafficLightDot("success")], style: { display: "flex", gap: themeSpacing(2), justifySelf: "start" } },
{ small: title, $: [small()], style: { justifySelf: "center" } },
{ div: null, style: { justifySelf: "end" } },
],
dataTone: "shift-16",
style: {
display: "grid",
gridTemplateColumns: "1fr auto 1fr",
alignItems: "center",
paddingBlock: themeSpacing(2.5),
paddingInline: themeSpacing(4),
backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
color: (listener: Listener) => themeColor(listener, "shift-9"),
},
},
{
div: lineElements as DomphyElement[],
style: {
display: "flex",
flexDirection: "column",
gap: themeSpacing(2),
paddingBlock: themeSpacing(4),
paddingInline: themeSpacing(4),
minHeight: themeSpacing(48),
},
},
],
dataTone: "shift-17",
_onMount: (node: ElementNode) => {
if (!startOnView || started.get()) return;
if (typeof IntersectionObserver !== "function") {
// No IntersectionObserver support (e.g. non-browser test runtime) —
// fail open and start immediately rather than never playing.
started.set(true);
return;
}
const element = node.domElement as Element;
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
started.set(true);
observer.disconnect();
}
});
observer.observe(element);
node.addHook("Remove", () => observer.disconnect());
},
style: {
overflow: "hidden",
borderRadius: themeSpacing(3),
backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
color: (listener: Listener) => themeColor(listener, "shift-9"),
outline: (listener: Listener) => `1px solid ${themeColor(listener, "shift-14")}`,
outlineOffset: "-1px",
boxShadow: (listener: Listener) => `0 ${themeSpacing(6)} ${themeSpacing(16)} ${themeColor(listener, "shift-4", "neutral")}`,
...(props.style ?? {}),
},
};
}
export { terminal };