Console.
TurtleDeck's log console: timestamped lines in four levels that stay scrolled to the newest entry, over a command row with an orange prompt.
"use client";
import * as React from "react";
import { TerminalSquareIcon } from "lucide-react";
import { Console, ConsoleHeader, ConsoleInput, ConsoleLine, ConsoleLines } from "@/components/ui/console";
import { StatusDot } from "@/components/ui/status-dot";
type Level = "info" | "system" | "error" | "input";
type Entry = { id: number; level: Level; time: string; text: string };
const replies: Record<string, { level: Level; text: string }[]> = {
help: [
{ level: "system", text: "Commands: fuel, pos, inv, dig, home, clear, help" },
],
fuel: [{ level: "info", text: "Fuel: 812 / 20000" }],
pos: [{ level: "info", text: "Position: -142, 38, 207 facing north" }],
inv: [{ level: "info", text: "Inventory: 41 cobblestone, 12 coal, 3 iron ore (7 of 16 slots)" }],
dig: [
{ level: "info", text: "Dug stone at -142, 38, 206" },
{ level: "info", text: "Moved forward" },
],
home: [
{ level: "system", text: "Pathing to home at 0, 64, 0" },
{ level: "error", text: "Blocked at -140, 38, 206: bedrock" },
],
};
function stamp(offset = 0) {
const d = new Date(Date.UTC(2026, 0, 1, 14, 2, 10 + offset));
return d.toISOString().slice(11, 19);
}
const initial: Entry[] = [
{ id: 1, level: "system", time: stamp(0), text: "Connected to quarry-2 (id 14)" },
{ id: 2, level: "info", time: stamp(1), text: "Mining layer 38, 212 blocks left" },
{ id: 3, level: "info", time: stamp(4), text: "Deposited 64 cobblestone into chest" },
{ id: 4, level: "error", time: stamp(9), text: "Lava found at -141, 37, 208. Skipping block." },
{ id: 5, level: "system", time: stamp(12), text: "Type help for commands" },
];
export default function ConsoleDemo() {
const [lines, setLines] = React.useState<Entry[]>(initial);
const nextId = React.useRef(initial.length + 1);
const tick = React.useRef(20);
function run(command: string) {
const name = command.toLowerCase().split(/\s+/)[0];
if (name === "clear") {
setLines([]);
return;
}
const out = replies[name] ?? [{ level: "error" as const, text: `Unknown command: ${name}. Try help.` }];
const t = tick.current++;
const add = [{ level: "input" as const, text: command }, ...out].map((line) => ({
...line,
id: nextId.current++,
time: stamp(t),
}));
setLines((prev) => [...prev, ...add]);
}
return (
<Console className="h-80 w-full max-w-xl">
<ConsoleHeader>
<span className="flex items-center gap-2">
<TerminalSquareIcon />
quarry-2 / console
</span>
<span className="flex items-center gap-2">
<StatusDot />
Live
</span>
</ConsoleHeader>
<ConsoleLines>
{lines.map((line) => (
<ConsoleLine key={line.id} level={line.level} time={line.time}>
{line.text}
</ConsoleLine>
))}
</ConsoleLines>
<ConsoleInput placeholder="Type a command, e.g. fuel" onCommand={run} />
</Console>
);
}Installation#
With the CLI
$npx lanterncn add consoleThis also installs the Lantern theme and any Lantern UI components it depends on. The shadcn CLI works directly too: npx shadcn@latest add httptim/lantern-ui/console. See Installation if your project is not set up yet.
Manually
Install the dependencies
npm install class-variance-authorityCopy the source into your project
components/ui/console.tsx"use client"; import * as React from "react"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; /** TurtleDeck's log console: a panel of timestamped lines over a command prompt. */ function Console({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="console" className={cn( "flex min-h-0 flex-col overflow-hidden rounded-lg border bg-[#0c110f] font-mono text-[12px] leading-relaxed text-foreground", className, )} {...props} /> ); } function ConsoleHeader({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="console-header" className={cn( "flex items-center justify-between gap-3 border-b bg-card px-3 py-2 text-[10px] tracking-[0.2em] text-muted-foreground uppercase [&_svg]:size-3.5 [&_svg]:text-primary", className, )} {...props} /> ); } /** * The scrolling log region. Sticks to the bottom as lines arrive unless the reader has scrolled up. */ function ConsoleLines({ className, children, autoScroll = true, ...props }: React.ComponentProps<"div"> & { autoScroll?: boolean }) { const ref = React.useRef<HTMLDivElement>(null); const pinned = React.useRef(true); const onScroll = React.useCallback(() => { const el = ref.current; if (!el) return; pinned.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24; }, []); React.useLayoutEffect(() => { const el = ref.current; if (!el || !autoScroll) return; const scroll = () => { if (pinned.current) el.scrollTop = el.scrollHeight; }; scroll(); const observer = new MutationObserver(scroll); observer.observe(el, { childList: true, subtree: true, characterData: true }); return () => observer.disconnect(); }, [autoScroll]); return ( <div ref={ref} role="log" aria-live="polite" tabIndex={0} data-slot="console-lines" onScroll={onScroll} className={cn( "min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 py-2.5 outline-none focus-visible:ring-2 focus-visible:ring-ring/25 focus-visible:ring-inset", className, )} {...props} > {children} </div> ); } const consoleLineVariants = cva("flex gap-3 py-px break-words whitespace-pre-wrap", { variants: { level: { info: "text-foreground", system: "text-muted-foreground", error: "text-destructive", input: "text-success", }, }, defaultVariants: { level: "info" }, }); function ConsoleLine({ className, level = "info", time, children, ...props }: React.ComponentProps<"div"> & VariantProps<typeof consoleLineVariants> & { time?: React.ReactNode }) { return ( <div data-slot="console-line" data-level={level} className={cn(consoleLineVariants({ level }), className)} {...props}> {time != null && ( <time className="shrink-0 text-muted-foreground/55 tabular-nums select-none">{time}</time> )} <span className="min-w-0 flex-1"> {level === "input" && ( <span aria-hidden="true" className="mr-1.5 text-primary select-none"> > </span> )} {children} </span> </div> ); } /** Command row with an orange prompt glyph. Submitting calls onCommand and clears the field. */ function ConsoleInput({ className, prompt = ">", onCommand, onKeyDown, "aria-label": ariaLabel = "Command", ...props }: Omit<React.ComponentProps<"input">, "value" | "defaultValue"> & { prompt?: React.ReactNode; onCommand?: (command: string) => void; }) { const [value, setValue] = React.useState(""); const history = React.useRef<string[]>([]); const cursor = React.useRef(-1); return ( <form data-slot="console-input" className={cn( "flex items-center gap-2 border-t bg-card px-3 focus-within:bg-secondary/60 transition-colors", className, )} onSubmit={(event) => { event.preventDefault(); const command = value.trim(); if (!command) return; history.current.push(command); cursor.current = -1; onCommand?.(command); setValue(""); }} > <span aria-hidden="true" className="font-semibold text-primary select-none"> {prompt} </span> <input type="text" autoComplete="off" autoCapitalize="off" spellCheck={false} aria-label={ariaLabel} value={value} onChange={(event) => setValue(event.target.value)} onKeyDown={(event) => { onKeyDown?.(event); if (event.defaultPrevented) return; const past = history.current; if (event.key === "ArrowUp" && past.length) { event.preventDefault(); cursor.current = cursor.current < 0 ? past.length - 1 : Math.max(0, cursor.current - 1); setValue(past[cursor.current]); } else if (event.key === "ArrowDown" && cursor.current >= 0) { event.preventDefault(); cursor.current += 1; if (cursor.current >= past.length) { cursor.current = -1; setValue(""); } else { setValue(past[cursor.current]); } } }} className="h-10 min-w-0 flex-1 bg-transparent font-mono text-[12px] text-foreground caret-primary outline-none placeholder:text-muted-foreground/60" {...props} /> </form> ); } export { Console, ConsoleHeader, ConsoleLines, ConsoleLine, ConsoleInput, consoleLineVariants };Update the import paths to match your project
The source imports
cnfrom@/lib/utils.
Usage#
import {
Console,
ConsoleHeader,
ConsoleLines,
ConsoleLine,
ConsoleInput,
} from "@/components/ui/console";<Console className="h-80">
<ConsoleLines>
<ConsoleLine level="system" time="14:02:10">Connected to quarry-2</ConsoleLine>
<ConsoleLine level="error" time="14:02:19">Lava found</ConsoleLine>
</ConsoleLines>
<ConsoleInput onCommand={(cmd) => run(cmd)} />
</Console>Levels are info (foreground), system (muted), error (destructive) and input (green, with a prompt glyph). ConsoleLines only follows new lines while the reader is at the bottom, so scrolling up to read history is not interrupted.
ConsoleInput clears itself on submit and keeps a history you can step through with the up and down arrow keys. Try help, fuel, pos, inv, dig, home and clear in the demo.