Code Block
A copyable code block with line numbers, language selection, and lightweight language-aware syntax highlighting.
registry/corr/components/code-block.tsx
"use client"
import * as React from "react"
import { CheckIcon, CopyIcon, TerminalIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { cn } from "@/lib/utils"
export type CodeBlockLanguage =
| "tsx"
| "ts"
| "jsx"
| "js"
| "json"
| "css"
| "bash"
| "python"
| "curl"
| "text"
export type CodeBlockSnippet = {
language: CodeBlockLanguage
label?: string
code: string
}
export type CodeBlockProps = {
code?: string
language?: CodeBlockLanguage
snippets?: CodeBlockSnippet[]
defaultLanguage?: CodeBlockLanguage
label?: string
showLineNumbers?: boolean
allowLanguageSelection?: boolean
className?: string
contentClassName?: string
}
const languageLabels: Record<CodeBlockLanguage, string> = {
tsx: "TSX",
ts: "TypeScript",
jsx: "JSX",
js: "JavaScript",
json: "JSON",
css: "CSS",
bash: "Shell",
python: "Python",
curl: "cURL",
text: "Text",
}
const languageKeywords: Record<CodeBlockLanguage, string[]> = {
tsx: [
"import",
"from",
"export",
"function",
"return",
"const",
"let",
"type",
"interface",
"extends",
"satisfies",
"as",
],
ts: [
"import",
"from",
"export",
"function",
"return",
"const",
"let",
"type",
"interface",
"extends",
"satisfies",
"as",
],
jsx: ["import", "from", "export", "function", "return", "const", "let"],
js: ["import", "from", "export", "function", "return", "const", "let"],
json: ["true", "false", "null"],
css: [
"display",
"grid",
"flex",
"color",
"background",
"border",
"padding",
"margin",
],
bash: ["npm", "npx", "pnpm", "bun", "cd", "cp", "ssh", "sudo"],
python: [
"import",
"from",
"def",
"return",
"if",
"else",
"for",
"in",
"with",
"as",
"True",
"False",
"None",
],
curl: ["curl", "GET", "POST", "PUT", "PATCH", "DELETE"],
text: [],
}
const tokenPattern =
/(\/\/.*|#.*|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`|\b[A-Za-z_$][A-Za-z0-9_$-]*\b|\b\d+(?:\.\d+)?\b|[{}()[\].,;:<>/=+\-*|&!])/g
function getTokenClass(token: string, language: CodeBlockLanguage) {
const keywords = languageKeywords[language] ?? []
if (
token.startsWith("//") ||
(["bash", "python", "curl"].includes(language) && token.startsWith("#"))
) {
return "text-muted-foreground"
}
if (token.startsWith('"') || token.startsWith("'") || token.startsWith("`")) {
return language === "json"
? "text-emerald-700 dark:text-emerald-300"
: "text-green-700 dark:text-green-300"
}
if (keywords.includes(token)) {
return "text-sky-700 dark:text-sky-300"
}
if (language === "json" && /^(true|false|null)$/.test(token)) {
return "text-purple-700 dark:text-purple-300"
}
if (/^\d/.test(token)) {
return "text-amber-700 dark:text-amber-300"
}
if (/^[A-Z]/.test(token) && ["tsx", "jsx", "ts", "js"].includes(language)) {
return "text-violet-700 dark:text-violet-300"
}
if (/^[{}()[\].,;:<>/=+\-*|&!]$/.test(token)) {
return "text-muted-foreground"
}
if (language === "css" && token.startsWith("--")) {
return "text-purple-700 dark:text-purple-300"
}
return "text-foreground"
}
function highlightLine(line: string, language: CodeBlockLanguage) {
const nodes: React.ReactNode[] = []
let cursor = 0
for (const match of line.matchAll(tokenPattern)) {
const token = match[0]
const index = match.index ?? 0
if (index > cursor) {
nodes.push(line.slice(cursor, index))
}
nodes.push(
<span key={`${index}-${token}`} className={getTokenClass(token, language)}>
{token}
</span>
)
cursor = index + token.length
}
if (cursor < line.length) {
nodes.push(line.slice(cursor))
}
return nodes
}
function CodeLine({
line,
lineNumber,
language,
showLineNumbers,
}: {
line: string
lineNumber: number
language: CodeBlockLanguage
showLineNumbers: boolean
}) {
return (
<div className="table-row">
{showLineNumbers ? (
<span className="table-cell w-10 pr-5 text-right text-muted-foreground select-none">
{lineNumber}
</span>
) : null}
<span className="table-cell min-w-0 whitespace-pre">
{highlightLine(line || " ", language)}
</span>
</div>
)
}
export function CodeBlock({
code,
language = "tsx",
snippets,
defaultLanguage,
label = "Code",
showLineNumbers = true,
allowLanguageSelection = true,
className,
contentClassName,
}: CodeBlockProps) {
const availableSnippets = React.useMemo<CodeBlockSnippet[]>(
() =>
snippets?.length
? snippets
: [
{
language,
code: code ?? "",
},
],
[code, language, snippets]
)
const initialLanguage =
defaultLanguage ?? availableSnippets[0]?.language ?? language
const [activeLanguage, setActiveLanguage] =
React.useState<CodeBlockLanguage>(initialLanguage)
const [copied, setCopied] = React.useState(false)
const activeSnippet =
availableSnippets.find((snippet) => snippet.language === activeLanguage) ??
availableSnippets[0]
const activeCode = activeSnippet?.code ?? ""
const activeLabel =
activeSnippet?.label ?? languageLabels[activeSnippet?.language ?? language]
const selectable = allowLanguageSelection && availableSnippets.length > 1
async function copyCode() {
await navigator.clipboard.writeText(activeCode)
setCopied(true)
window.setTimeout(() => setCopied(false), 1400)
}
return (
<div
className={cn(
"overflow-hidden rounded-lg border bg-card text-card-foreground",
className
)}
>
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2 border-b bg-muted/30 px-3 py-2">
<div className="flex min-w-0 items-center gap-2 text-xs font-medium text-muted-foreground">
<TerminalIcon className="size-3.5 shrink-0" />
<span className="truncate">{label}</span>
</div>
<div className="flex items-center gap-2">
{selectable ? (
<Select
value={activeLanguage}
onValueChange={(value) =>
setActiveLanguage(value as CodeBlockLanguage)
}
>
<SelectTrigger size="sm" className="h-7">
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
{availableSnippets.map((snippet) => (
<SelectItem
key={snippet.language}
value={snippet.language}
>
{snippet.label ?? languageLabels[snippet.language]}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span className="rounded-md border bg-background px-2 py-1 text-[11px] font-medium text-muted-foreground">
{activeLabel}
</span>
)}
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={copyCode}
className="rounded-md"
>
<span
className="t-icon-swap inline-grid size-3.5 place-items-center"
data-state={copied ? "b" : "a"}
aria-hidden="true"
>
<span className="t-icon inline-flex size-3.5 items-center justify-center" data-icon="a">
<CopyIcon className="size-3.5" />
</span>
<span className="t-icon inline-flex size-3.5 items-center justify-center" data-icon="b">
<CheckIcon className="size-3.5" />
</span>
</span>
<span className="sr-only">Copy code</span>
</Button>
</div>
</div>
<pre
className={cn(
"max-h-[28rem] min-w-0 overflow-auto p-4 text-xs leading-6",
contentClassName
)}
>
<code className="table min-w-max border-spacing-0 font-mono">
{activeCode.split("\n").map((line, index) => (
<CodeLine
key={`${index}-${line}`}
line={line}
lineNumber={index + 1}
language={activeSnippet?.language ?? language}
showLineNumbers={showLineNumbers}
/>
))}
</code>
</pre>
</div>
)
}
- Visual style
- terminal-mono
- Interaction
- dropdown
DependenciesBundle weight · light
- icons
- lucide-react