COMPONENT / typewriter-terminal
Typewriter Terminal_
Terminal panel that types its lines out once on mount, with optional prompt lines and a blinking block cursor. Requires @cybr/tokens and @cybr/effects.
Install
1. Map the
@cybrnamespace, once per project. Add this inside yourcomponents.json, or into its existing"registries"object. Nocomponents.jsonyet? Runnpx shadcn@latest initfirst to create one."registries": { "@cybr": "https://cybr-ui.com/r/{name}.json" }2. Add components:
$ npx shadcn@latest add @cybr/typewriter-terminal
If shadcn reports Unknown registry "@cybr", step 1 is missing from that project's components.json.
Preview
> boot --style=brutal
mounting /dev/concrete ...... ok
loading neon.drv ............ ok
> _ready
"use client";
import { useEffect, useState, type ComponentProps } from "react";
import { useReducedMotion } from "@/lib/use-reduced-motion";
import { cn } from "@/lib/utils";
export type TerminalLine = { text: string; prompt?: boolean };
const TICK_MS = 50;
const DEFAULT_CPS = 40;
function charsPerTick(charsPerSecond: number) {
const cps = Number.isFinite(charsPerSecond) && charsPerSecond > 0 ? charsPerSecond : DEFAULT_CPS;
return (cps * TICK_MS) / 1000;
}
// Shares a typed-character budget across lines in order. The cursor sits on the
// first unfinished line, or the last line once everything is typed.
function typedLines(lines: string[], budget: number) {
let left = budget;
let cursor = -1;
const shown = lines.map((line, i) => {
const visible = line.slice(0, Math.max(0, Math.min(line.length, left)));
if (cursor === -1 && left < line.length) cursor = i;
left -= line.length;
return visible;
});
return { shown, cursor: cursor === -1 ? lines.length - 1 : cursor };
}
// Terminal panel that types its lines out once on mount, with a blinking block
// cursor. Screen readers get every full line from a visually hidden copy; the
// typed view is aria-hidden. Static-first: the server, no-JS and reduced-motion
// render shows every line with the cursor on the last one.
export function TypewriterTerminal({
lines,
promptSymbol = "> ",
charsPerSecond = DEFAULT_CPS,
className,
...props
}: Omit<ComponentProps<"div">, "children"> & {
lines: TerminalLine[];
promptSymbol?: string;
charsPerSecond?: number;
}) {
const reduced = useReducedMotion();
const [tick, setTick] = useState<number | null>(null);
const full = lines.length > 0 ? lines.map((line) => (line.prompt ? promptSymbol : "") + line.text) : [""];
const prompts = lines.length > 0 ? lines.map((line) => Boolean(line.prompt)) : [false];
const perTick = charsPerTick(charsPerSecond);
const total = full.reduce((sum, line) => sum + line.length, 0);
const signature = full.join("\n");
useEffect(() => {
if (reduced) return;
const end = Math.ceil(total / perTick);
let current = 0;
const timer = setInterval(() => {
current += 1;
setTick(current);
if (current >= end) clearInterval(timer);
}, TICK_MS);
return () => {
clearInterval(timer);
setTick(null);
};
}, [signature, total, perTick, reduced]);
const { shown, cursor } =
reduced || tick === null ? { shown: full, cursor: full.length - 1 } : typedLines(full, Math.floor(tick * perTick));
return (
<div
className={cn(
"bg-ground-void px-8 py-6 font-mono text-[0.9375rem] leading-[1.75] text-ink",
className,
)}
{...props}
>
<div className="sr-only">
{full.map((line, i) => (
<p key={i}>{line}</p>
))}
</div>
<div aria-hidden="true">
{shown.map((line, i) => (
<div key={i} className="min-h-6.5 whitespace-pre">
<span className={prompts[i] ? "text-signal" : undefined}>{line}</span>
{i === cursor && (
<span className="inline-block h-4.5 w-2.5 bg-current align-[-3px] text-signal cursor-blink" />
)}
</div>
))}
</div>
</div>
);
}
Repo source. The shadcn CLI rewrites import aliases to match your project.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| lines | { text: string; prompt?: boolean }[] | — | Lines in order. Prompt lines get promptSymbol in front and render in --signal; other lines render in --ink. |
| promptSymbol | string | "> " | Prefix for prompt lines. It is typed out as part of the line. |
| charsPerSecond | number | 40 | Typing speed across all lines. Values that are not finite or not above 0 fall back to 40. |
| className | string | — | Extra classes, merged after the defaults with cn(). |
| ...props | Omit<ComponentProps<"div">, "children"> | — | Every other native <div> attribute and ref is forwarded. |
Usage
Needs both stylesheets from @cybr/tokens and @cybr/effects, and @cybr/use-reduced-motion arrives with them. After the install, import them in app/globals.css:
@import "tailwindcss";
@import "../styles/cybr-tokens.css";
@import "../styles/cybr-effects.css";
import { TypewriterTerminal } from "@/components/ui/typewriter-terminal";
export function Boot() {
return (
<TypewriterTerminal
lines={[
{ text: "boot --style=brutal", prompt: true },
{ text: "mounting /dev/concrete ...... ok" },
]}
/>
);
}
Behaviour
- Trigger: types once when it mounts. Changing the lines or speed types them again.
- Timing: advances every 50ms at
charsPerSecond(40 by default, so 2 characters a tick), line by line. - Cursor: a 10×18px
--signalblock that blinks once a second after the line being typed, then stays on the last line. - Finished: every line shown, cursor blinking on the last line.
- Static: Before hydration, without JavaScript and under
prefers-reduced-motion, it renders every line in full with the cursor on the last line. On the client it can show the full text for one tick before typing starts. - Empty: an empty
linesarray renders the panel with one empty line and the cursor.
Accessibility
- A visually hidden copy holds every full line, prompt symbols included. The typed view and the cursor are
aria-hidden, so screen readers read the whole transcript once instead of partial lines. - Typing runs once. The cursor blinks at 1Hz over a small area, under the 3-flashes-per-second limit (WCAG 2.3.1).
- Under
prefers-reduced-motionnothing types and the cursor is a solid block. --inkon--ground-voidis 18.09:1 and--signalon--ground-voidis 5.74:1. If you override colours withclassName, keep text at 4.5:1 or above.