Code block
A plain code panel with line numbers, highlighted lines, and a copy button — no highlighter.
Dependency-free by design: no shiki, no prism, nothing to load and nothing to theme. It renders a pre/code pair, one row per line, with the numbers in a left column of select-none spans that are also aria-hidden — so copying the block or reading it aloud gives the code and not the scaffolding. One trailing newline is dropped, which is how every editor ends a file, so there is no phantom last line. The body is a focusable, named region, since a block that scrolls has to be reachable without a mouse. Reach for a real highlighter only when the language actually matters to the reader.
Install
$
npx shadcn@latest add @vibra/code-blockNeeds the @vibra registry in your components.json — set it up once.
Examples
import { CodeBlock } from "@/components/ui/code-block"
const code = `import { StatCard } from "@/components/ui/stat-card"
import { formatCurrency } from "@/lib/format"
export function RevenueCard({ revenue, delta }: Props) {
return (
<StatCard
label="Revenue"
value={formatCurrency(revenue)}
delta={delta}
description="vs last month"
/>
)
}`
export default function CodeBlockDemo() {
return (
<CodeBlock
className="w-full"
title="revenue-card.tsx"
language="tsx"
code={code}
showLineNumbers
highlightLines={[7, 8, 9]}
maxHeight={280}
/>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| code | string | — | The source. One trailing newline is dropped. |
| language | string | — | Shown as a chip in the header, and used to name the scroll region. |
| title | React.ReactNode | — | A file name or a caption; adds the header on its own. |
| showLineNumbers | boolean | false | Adds the unselectable gutter. |
| highlightLines | number[] | — | 1-based line numbers to tint with the accent — the lines a reader is meant to look at. |
| maxHeight | number | — | Pixels; the body scrolls past it. |
| copyable | boolean | true | Puts a copy button in the header, which is why a bare block still has one. |
| wrap | boolean | false | Wraps long lines instead of scrolling them sideways. |
| className | string | — | Merged onto the div root; the remaining div props are spread onto it too. |
Dependencies
Registry
Source
import * as React from "react"
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { CopyButton } from "@/components/ui/copy-button"
// `title` is content here — a file name shown in the header — not the HTML
// tooltip attribute.
export type CodeBlockProps = Omit<React.ComponentProps<"div">, "title"> & {
code: string
/** Shown as a chip in the header and used to name the scroll region. */
language?: string
/** A file name or a caption; adds the header on its own. */
title?: React.ReactNode
showLineNumbers?: boolean
/** 1-based line numbers to tint — the lines a reader is meant to look at. */
highlightLines?: number[]
/** Pixels; the body scrolls past it. */
maxHeight?: number
copyable?: boolean
wrap?: boolean
}
/**
* A plain, dependency-free code panel: no syntax highlighting, just a
* monospaced block with an optional gutter, highlighted lines, and a copy button.
*/
function CodeBlock({
className,
code,
language,
title,
showLineNumbers = false,
highlightLines,
maxHeight,
copyable = true,
wrap = false,
...props
}: CodeBlockProps) {
// One trailing newline is how every editor ends a file; rendering it would
// add a blank numbered line that is not in the source.
const lines = code.replace(/\n$/, "").split("\n")
const highlighted = new Set(highlightLines)
const hasHeader = Boolean(title) || Boolean(language) || copyable
const gutterWidth = `${String(lines.length).length + 1}ch`
return (
<div
data-slot="code-block"
data-wrap={wrap || undefined}
data-line-numbers={showLineNumbers || undefined}
className={cn("overflow-hidden rounded-lg border bg-muted/40", className)}
{...props}
>
{hasHeader ? (
<div
data-slot="code-block-header"
className="flex h-9 items-center gap-2 border-b px-3 text-xs text-muted-foreground"
>
{title ? (
<span data-slot="code-block-title" className="truncate font-medium text-foreground">
{title}
</span>
) : null}
{language ? (
<Badge variant="outline" className="font-mono">
{language}
</Badge>
) : null}
{copyable ? (
<CopyButton value={code} size="icon-xs" className="-me-1.5 ms-auto" />
) : null}
</div>
) : null}
<div
data-slot="code-block-body"
// A block that scrolls has to be reachable without a mouse, and a
// focusable scroller needs a name of its own.
tabIndex={0}
role="region"
aria-label={typeof title === "string" ? title : language ? `${language} code` : "Code"}
className="overflow-auto focus-ring"
style={maxHeight === undefined ? undefined : { maxHeight: `${maxHeight}px` }}
>
<pre className={cn("py-2 font-mono text-label leading-6", wrap ? "w-full" : "w-fit min-w-full")}>
<code data-slot="code-block-code" className={cn("block", wrap && "whitespace-pre-wrap")}>
{lines.map((line, index) => {
const number = index + 1
return (
<span
key={number}
data-slot="code-block-line"
data-line={number}
data-highlighted={highlighted.has(number) || undefined}
className={cn(
"flex min-h-6 px-3",
highlighted.has(number) && "bg-accent"
)}
>
{showLineNumbers ? (
<span
data-slot="code-block-line-number"
// Out of the copy buffer and out of the reading order:
// the numbers are scaffolding, not part of the code.
aria-hidden="true"
className="me-4 shrink-0 text-end tabular-nums text-muted-foreground select-none"
style={{ width: gutterWidth }}
>
{number}
</span>
) : null}
<span className={cn("min-w-0", wrap ? "break-words whitespace-pre-wrap" : "whitespace-pre")}>
{line}
</span>
</span>
)
})}
</code>
</pre>
</div>
</div>
)
}
export { CodeBlock }