COMPONENT / boot-sequence-loader
Boot Sequence Loader_
Terminal boot log that prints one line at a time, fills each status in --signal and ends on a blinking cursor. Shows the full log under reduced motion. 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/boot-sequence-loader
If shadcn reports Unknown registry "@cybr", step 1 is missing from that project's components.json.
Preview
[0000.77] handshake uplink READY
"use client";
import { useEffect, useRef, useState, type ComponentProps } from "react";
import { useReducedMotion } from "@/lib/use-reduced-motion";
import { cn } from "@/lib/utils";
export type BootLine = { text: string; status: string };
const DEFAULT_LINE_MS = 1000;
// Boot log that prints one line per lineDuration and fills each line's status
// in --signal when the next step starts, then calls onComplete once. The log is
// aria-hidden; a visually hidden status region announces only the final line.
// Static-first: the server, no-JS and reduced-motion render is the full log.
export function BootSequenceLoader({
lines,
lineDuration = DEFAULT_LINE_MS,
onComplete,
className,
...props
}: Omit<ComponentProps<"div">, "children"> & {
lines: BootLine[];
lineDuration?: number;
onComplete?: () => void;
}) {
const reduced = useReducedMotion();
const [step, setStep] = useState<number | null>(null);
const onCompleteRef = useRef(onComplete);
useEffect(() => {
onCompleteRef.current = onComplete;
});
const count = lines.length;
const duration = Number.isFinite(lineDuration) && lineDuration > 0 ? lineDuration : DEFAULT_LINE_MS;
const signature = lines.map((line) => `${line.text}\u0000${line.status}`).join("\n");
useEffect(() => {
// Deferred so the hydration pass, which always reports reduced motion,
// cancels it if the real preference turns out to allow motion.
if (reduced) {
const timer = setTimeout(() => onCompleteRef.current?.(), 0);
return () => clearTimeout(timer);
}
let current = -1;
let timer: ReturnType<typeof setTimeout>;
const advance = () => {
current += 1;
setStep(current);
if (current >= count) onCompleteRef.current?.();
else timer = setTimeout(advance, duration);
};
timer = setTimeout(advance, 0);
return () => {
clearTimeout(timer);
setStep(null);
};
}, [reduced, signature, count, duration]);
const settled = reduced || step === null;
const shown = settled ? count : Math.min(count, step + 1);
const done = settled ? count : step;
const last = lines[count - 1];
const announcement = done >= count && last ? `${last.text.replace(/[.\s]+$/, "")} ${last.status}` : "";
return (
<div
className={cn("bg-ground-void px-8 py-6 font-mono text-xs leading-[1.65] text-label", className)}
{...props}
>
<p role="status" className="sr-only">
{announcement}
</p>
<div aria-hidden="true">
{lines.slice(0, shown).map((line, i) => (
<div key={i} className="whitespace-pre">
{line.text}
{i < done && <span className="text-signal">{line.status}</span>}
</div>
))}
<span className="inline-block h-3.75 w-2.25 bg-current align-[-2px] text-signal cursor-blink" />
</div>
</div>
);
}
Repo source. The shadcn CLI rewrites import aliases to match your project.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| lines | { text: string; status: string }[] | — | Log lines in order. text prints in --label; status is appended in --signal once the line completes. Include any dot leaders and trailing space in text. |
| lineDuration | number | 1000 | Milliseconds per line. Values that are not finite or not above 0 fall back to 1000. |
| onComplete | () => void | — | Called once after the last status fills in, or straight after mount under reduced motion. |
| 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 { BootSequenceLoader } from "@/components/ui/boot-sequence-loader";
export function Splash({ onReady }: { onReady: () => void }) {
return (
<BootSequenceLoader
onComplete={onReady}
lines={[
{ text: "[0000.01] init kernel ...... ", status: "OK" },
{ text: "[0000.42] handshake uplink . ", status: "READY" },
]}
/>
);
}
Behaviour
- Trigger: plays once when it mounts. Changing the lines or the duration plays it again.
- Timing: line 1 appears at once; every
lineDurationthe current line gets its status and the next line appears. With 4 lines at 1000ms the log finishes after 4 seconds. - Cursor: a
--signalblock under the lines that blinks once a second. - Finished: every line with its status, the cursor still blinking, and
onCompletecalled once. - Static: before hydration, without JavaScript and under
prefers-reduced-motion, it renders the finished log with a solid cursor. On the client it then restarts from the first line when motion is allowed. - Empty: an empty
linesarray renders the panel with only the cursor and callsonCompletestraight away.
Accessibility
- The log and cursor are
aria-hidden. A visually hiddenrole="status"region announces only the final line, such as "[0000.77] handshake uplink READY", once the sequence finishes. Lines are not announced one by one. - If the loader guards real work, announce the real outcome as well. This component only plays the sequence you give it.
- The cursor blinks at 1Hz, under the 3-flashes-per-second limit (WCAG 2.3.1). Under
prefers-reduced-motionnothing prints and the cursor is solid. --labelon--ground-voidis 9.19:1 and--signalon--ground-voidis 5.74:1. If you override colours withclassName, keep text at 4.5:1 or above.