Code Block
An opinionated, server-highlighted code block. Ships Shiki with the One Light / One Dark Pro theme pair, a copy button, optional line numbers, and dual-theme dark mode with zero client-side highlighting JavaScript.
Installation
$
Usage
import { CodeBlock } from "@/components/ui/code-block"Examples
Default
import { Button } from "@/components/ui/button"
export function Example() {
return <Button variant="outline">Click me</Button>
}Line numbers
Prefix each line with its number via showLineNumbers.
import { Button } from "@/components/ui/button"
export function Example() {
return <Button variant="outline">Click me</Button>
}Languages
Bundled languages: tsx, ts, jsx, js, bash, css, json, html. An unknown language renders as plain text.
npx shadcn@latest add https://ui.mattdowney.com/r/button.json{
"name": "ui.md",
"type": "registry:ui"
}API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| code*CodeBlock | string | - | The source to highlight. Leading and trailing whitespace is trimmed. |
| languageCodeBlock | string | "tsx" | Language grammar to highlight with. Bundled: tsx, ts, jsx, js, bash, css, json, html. Extend the LANGUAGES list in the source to add more; an unknown language falls back to plain text. |
| showLineNumbersCodeBlock | boolean | false | Prefixes each line with its line number. |
| classNameCodeBlock | string | - | Merged onto the Card frame. |
import { createHighlighter, type Highlighter } from "shiki"
import { cn } from "@/registry/lib/utils"
import { Card } from "@/registry/ui/card"
import { CopyButton } from "@/registry/ui/code-block-copy-button"
// UI.MD ships an opinionated highlighter: Shiki with the One Light / One Dark Pro
// theme pair. Both color schemes are baked into one render as CSS variables; the
// dark tokens activate through the `.shiki` rule this item installs, so there is
// no client-side highlighting JavaScript.
const THEMES = { light: "one-light", dark: "one-dark-pro" } as const
// Languages bundled by default. Add to this list to highlight more; an unknown
// language falls back to plain text instead of throwing.
const LANGUAGES = ["tsx", "ts", "jsx", "js", "bash", "css", "json", "html"]
// Module-level singleton so the grammar and theme load happens once per server
// process, not once per code block.
let highlighterPromise: Promise<Highlighter> | null = null
function getHighlighter() {
if (!highlighterPromise) {
highlighterPromise = createHighlighter({
themes: [THEMES.light, THEMES.dark],
langs: LANGUAGES,
})
}
return highlighterPromise
}
export async function CodeBlock({
code,
language = "tsx",
showLineNumbers = false,
className,
}: {
code: string
language?: string
showLineNumbers?: boolean
className?: string
}) {
const highlighter = await getHighlighter()
const lang = highlighter.getLoadedLanguages().includes(language)
? language
: "text"
const trimmed = code.trim()
const html = highlighter.codeToHtml(trimmed, { lang, themes: THEMES })
// Single-line snippets center the copy button vertically; multi-line pins it
// to the top-right so it never sits over a wrapped line.
const singleLine = !trimmed.includes("\n")
return (
<Card className={cn("group/code relative", className)}>
<CopyButton
text={trimmed}
className={cn(
"absolute right-2 z-10",
singleLine ? "top-1/2 -translate-y-1/2" : "top-2"
)}
/>
<div
className={cn(
// CodeBlock owns its inner <pre> completely, so a surrounding prose
// renderer (Prose, Tailwind Typography) can't paint a second surface
// on it: the Card is the only card. bg/padding were already reset;
// rounded/shadow are reset too, or Prose's [&_pre] ring draws a
// container-in-container inside this Card.
"overflow-x-auto p-4 pointer-coarse:pr-12 text-sm [&_pre]:!bg-transparent [&_pre]:!p-0 [&_pre]:!rounded-none [&_pre]:!shadow-none [&_code]:!text-xs [&_code]:!leading-relaxed",
showLineNumbers &&
"[&_.line]:before:mr-4 [&_.line]:before:inline-block [&_.line]:before:w-4 [&_.line]:before:text-right [&_.line]:before:text-subtle-foreground [&_.line]:before:content-[counter(line)] [&_.line]:before:counter-increment-[line] [&_pre]:counter-reset-[line]"
)}
dangerouslySetInnerHTML={{ __html: html }}
/>
</Card>
)
}"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { Check, Copy } from "lucide-react"
import { cn } from "@/registry/lib/utils"
export function CopyButton({
text,
className,
}: {
text: string
className?: string
}) {
const [copied, setCopied] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
}
}, [])
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(text)
} catch {
return
}
setCopied(true)
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => setCopied(false), 1500)
}, [text])
return (
<button
type="button"
onClick={handleCopy}
aria-label={copied ? "Copied" : "Copy to clipboard"}
className={cn(
// Revealed on hover/focus, always visible on touch where hover doesn't exist.
"inline-flex size-7 pointer-coarse:size-11 items-center justify-center rounded-md bg-card text-subtle-foreground opacity-0 outline-none transition motion-reduce:transition-[opacity,color,background-color] hover:bg-muted hover:text-foreground group-hover/code:opacity-100 group-focus-within/code:opacity-100 focus-visible:opacity-100 focus-visible:ring-[3px] focus-visible:ring-ring-focus pointer-coarse:opacity-100",
className
)}
>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
</button>
)
}