Code viewer
File tabs, line numbers, highlighted lines, copy and expand, with a dependency-free highlighter.
$
pnpm dlx shadcn@latest add https://lodeui.com/r/code-viewer.json1import { Button } from "@/components/ui/button";2 3export function SaveBar({ dirty, onSave }: Props) {4 // Only show when there's something to save.5 if (!dirty) return null;6 return (7 <div className="flex justify-end gap-2">8 <Button variant="ghost">Discard</Button>9 <Button onClick={onSave}>Save changes</Button>10 </div>11 );12}components/examples/code-viewer-demo.tsx
"use client";
import { CodeViewer } from "@/components/ui/code-viewer";
const tsx = `import { Button } from "@/components/ui/button";
export function SaveBar({ dirty, onSave }: Props) {
// Only show when there's something to save.
if (!dirty) return null;
return (
<div className="flex justify-end gap-2">
<Button variant="ghost">Discard</Button>
<Button onClick={onSave}>Save changes</Button>
</div>
);
}`;
const json = `{
"name": "@lode/save-bar",
"version": "1.2.0",
"dependencies": { "motion": "^14.0.0" },
"private": false
}`;
export default function CodeViewerDemo() {
return (
<CodeViewer
className="w-full max-w-2xl"
highlight={[5, 6]}
files={[
{ name: "save-bar.tsx", code: tsx, language: "tsx" },
{ name: "package.json", code: json, language: "json" },
{ name: "install.sh", code: "# add it to your project\nnpx shadcn@latest add @lode/save-bar --yes", language: "bash" },
]}
/>
);
}When to use
- Showing code samples with file tabs, highlighted lines and copy.
When not to use
- Editing code: use an editor.
- Comparing versions: use the diff viewer.
Installation
$
pnpm dlx shadcn@latest add https://lodeui.com/r/code-viewer.json1Install the dependencies
npm install motion lucide-react2Copy the source into your project
components/ui/code-viewer.tsx"use client"; import * as React from "react"; import { AnimatePresence, motion } from "motion/react"; import { Check, ChevronDown, Copy } from "lucide-react"; import { cn } from "@/lib/utils"; export type CodeLanguage = "tsx" | "ts" | "js" | "json" | "bash" | "css" | "text"; export interface CodeFile { name: string; code: string; language?: CodeLanguage; } export interface CodeViewerProps { files: CodeFile[]; lineNumbers?: boolean; /** 1-based line numbers to highlight in the first file. */ highlight?: number[]; /** Collapse to this many px; shows an expand button. */ maxHeight?: number; className?: string; } const KEYWORDS = new Set( "import export from default function return const let var if else for while of in new class extends async await type interface as typeof keyof true false null undefined try catch throw switch case break continue".split(" "), ); type Tok = { text: string; cls?: string }; /** A tiny tokenizer: enough for readable TS/TSX, JSON, CSS and shell, with no dependency. */ export function tokenize(code: string, lang: CodeLanguage): Tok[][] { return code.split("\n").map((line) => { if (lang === "text") return [{ text: line }]; const out: Tok[] = []; const re = lang === "bash" ? /(#.*$)|("(?:[^"\\]|\\.)*"|'[^']*')|(\$\w+|\$\{[^}]+\})|(--?[\w-]+)|(^\s*[\w.-]+)|(\s+)|([^\s]+)/g : /(\/\/.*$|\/\*.*?\*\/)|("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)|(\b\d[\d_.]*\b)|(<\/?[A-Za-z][\w.]*|\/?>)|([A-Za-z_$][\w$]*)(?=\s*\()|([A-Za-z_$][\w$-]*)|(\s+)|(.)/g; let m: RegExpExecArray | null; while ((m = re.exec(line))) { if (lang === "bash") { const [, com, str, v, flag, cmd, ws] = m; out.push({ text: m[0], cls: com ? "c-com" : str ? "c-str" : v ? "c-var" : flag ? "c-key" : cmd ? "c-fn" : ws ? undefined : undefined }); continue; } const [, com, str, num, tag, fn, word] = m; if (com) out.push({ text: m[0], cls: "c-com" }); else if (str) out.push({ text: m[0], cls: lang === "json" && /^\s*:/.test(line.slice(m.index + m[0].length)) ? "c-prop" : "c-str" }); else if (num) out.push({ text: m[0], cls: "c-num" }); else if (tag) out.push({ text: m[0], cls: "c-tag" }); else if (fn) out.push({ text: m[0], cls: KEYWORDS.has(fn) ? "c-key" : "c-fn" }); else if (word) out.push({ text: m[0], cls: KEYWORDS.has(word) ? "c-key" : /^[A-Z]/.test(word) ? "c-type" : undefined }); else out.push({ text: m[0] }); } return out; }); } /** Code with file tabs, line numbers, highlighted lines, copy and expand. */ export function CodeViewer({ files, lineNumbers = true, highlight = [], maxHeight, className }: CodeViewerProps) { const id = React.useId(); const [tab, setTab] = React.useState(0); const [copied, setCopied] = React.useState(false); const [expanded, setExpanded] = React.useState(false); const file = files[tab]; const lines = React.useMemo(() => tokenize(file.code.replace(/\n$/, ""), file.language ?? "tsx"), [file]); const collapsible = maxHeight !== undefined && lines.length * 22 > maxHeight; return ( <div className={cn("lode-code overflow-hidden rounded-lg border border-border bg-card font-mono text-[13px]", className)}> <div className="flex items-center border-b border-border pr-1.5"> <div role="tablist" className="flex min-w-0 flex-1 overflow-x-auto"> {files.map((f, i) => ( <button key={f.name} role="tab" type="button" aria-selected={i === tab} onClick={() => setTab(i)} className={cn("relative shrink-0 px-3.5 py-2.5 text-xs transition-colors", i === tab ? "text-foreground" : "text-muted-foreground hover:text-foreground")} > {f.name} {i === tab && <motion.span layoutId={`${id}-tab`} className="absolute inset-x-2 bottom-0 h-px bg-foreground" />} </button> ))} </div> <button type="button" aria-label={copied ? "Copied" : "Copy code"} onClick={async () => { await navigator.clipboard.writeText(file.code); setCopied(true); setTimeout(() => setCopied(false), 1400); }} className="grid size-8 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground" > <AnimatePresence mode="popLayout" initial={false}> <motion.span key={String(copied)} initial={{ scale: 0.5, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.5, opacity: 0 }}> {copied ? <Check className="size-4" /> : <Copy className="size-4" />} </motion.span> </AnimatePresence> </button> </div> <div className="relative"> <pre className="overflow-x-auto py-3 leading-[22px]" style={{ maxHeight: collapsible && !expanded ? maxHeight : undefined, overflowY: collapsible && !expanded ? "hidden" : undefined }}> <code> {lines.map((toks, i) => ( <div key={i} className={cn("flex px-4", tab === 0 && highlight.includes(i + 1) && "bg-[color-mix(in_oklab,var(--chart-1)_12%,transparent)] shadow-[inset_2px_0_0_var(--chart-1)]")}> {lineNumbers && <span className="mr-4 w-6 shrink-0 select-none text-right text-muted-foreground/50">{i + 1}</span>} <span className="whitespace-pre"> {toks.length ? toks.map((t, j) => <span key={j} className={t.cls}>{t.text}</span>) : " "} </span> </div> ))} </code> </pre> {collapsible && ( <div className={cn("flex justify-center pb-3", !expanded && "absolute inset-x-0 bottom-0 bg-gradient-to-t from-card via-card/90 to-transparent pt-12")}> <button type="button" onClick={() => setExpanded((e) => !e)} className="flex items-center gap-1 rounded-md border border-border bg-card px-3 py-1 font-sans text-xs hover:bg-accent"> {expanded ? "Collapse" : `Show all ${lines.length} lines`} <ChevronDown className={cn("size-3.5 transition-transform", expanded && "rotate-180")} /> </button> </div> )} </div> </div> ); }
Usage
Import it from @/components/ui/code-viewer after installing.
example.tsx
import { CodeViewer } from "@/components/ui/code-viewer";
export function Snippet() {
return <CodeViewer files={[{ name: "client.ts", language: "ts", code: "export const api = createClient();" }]} highlight={[1]} />;
}API reference
CodeViewer
Code with file tabs, line numbers, highlighted lines, copy and expand. No highlighter dependency.
| Prop | Type | Default | Description |
|---|---|---|---|
| files | { name; code; language?: "tsx" | "ts" | "js" | "json" | "bash" | "css" | "text" }[] | – | One tab per file. |
| lineNumbers | boolean | true | Gutter numbers. |
| highlight | number[] | [] | 1-based lines to highlight in the first file. |
| maxHeight | number | – | Collapse to this height with an expand button. |
tokenize
The tokenizer used inside, for your own renderers.
| Prop | Type | Default | Description |
|---|---|---|---|
| code / language | string / CodeLanguage | – | Returns tokens per line. |
Keyboard interactions
| Keys | Action |
|---|---|
| Tab | Reach file tabs, copy and expand |
| EnterorSpace | Switch tab or copy |
Accessibility
- File tabs are a tablist; copy announces success.
Motion
- The tab indicator slides; expand animates height.
Responsive behavior
- Lines scroll horizontally; they never wrap.
Performance
- Tokenizing is linear; fine for files of a few thousand lines.
Notes for AI
- For full syntax accuracy on the server, use shiki instead.
This page as Markdown, for assistants and coding agents:
/components/code-viewer/markdownThe full library index for assistants is at /llms.txt.
Lode222 doc pagesllms.txt