Code Block
Code display with built-in dependency-free syntax highlighting via theme tokens, line numbers, line highlights, diff gutters, copy button and collapsible max-height.
Example
Installation
npx shadcn@latest add https://hirael.com/r/code-block.jsonUsage
import { CodeBlock, CodeBlockHeader, CodeBlockContent } from "@/components/code-block"Paths assume the default @/ alias from your components.json. The example above shows the parts composed.
API
<CodeBlock />
+ native element props| Prop | Type | Default |
|---|---|---|
codeRaw code to display. A string child is accepted as an alternative. | string | none |
language | string | none |
filename | string | none |
highlightTokenize and color the code when the language has a built-in grammar. | boolean | true |
showLineNumbers | boolean | true |
highlightLines | number[] | [] |
addedLines | number[] | [] |
removedLines | number[] | [] |
wrap | boolean | false |
maxHeight | number | none |
copyable | boolean | true |
<CodeBlockHeader />
+ native element propsNo props of its own; forwards everything to the underlying element.
<CodeBlockContent />
+ native element propsNo props of its own; forwards everything to the underlying element.
Component source
'use client';
import * as React from 'react';
import { ChevronDown, ChevronUp, FileCode } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Badge } from '@/registry/hirael/bases/radix/ui/badge';
import { Button } from '@/registry/hirael/bases/radix/ui/button';
import { CopyButton } from '@/registry/hirael/bases/radix/components/copy-button';
type TokenType =
| 'comment'
| 'string'
| 'keyword'
| 'literal'
| 'number'
| 'type'
| 'function'
| 'property'
| 'tag'
| 'punctuation'
| 'plain';
interface Token {
type: TokenType;
content: string;
}
interface Rule {
type: TokenType;
re: string;
}
const TOKEN_CLASSES: Record<TokenType, string | undefined> = {
comment: 'italic text-muted-foreground',
string: 'text-chart-2',
keyword: 'text-chart-1',
literal: 'text-chart-3',
number: 'text-chart-3',
type: 'text-chart-4',
function: 'text-chart-5',
property: 'text-chart-1',
tag: 'text-chart-1',
punctuation: 'text-muted-foreground',
plain: undefined,
};
const JS_RULES: Rule[] = [
{ type: 'comment', re: String.raw`\/\/[^\n]*|\/\*[\s\S]*?(?:\*\/|$)` },
{
type: 'string',
re: String.raw`'(?:[^'\\\n]|\\.)*'|"(?:[^"\\\n]|\\.)*"|\`(?:[^\`\\]|\\[\s\S])*\``,
},
{
type: 'keyword',
re: String.raw`\b(?:import|export|from|default|const|let|var|function|return|if|else|for|while|do|switch|case|break|continue|new|class|extends|implements|interface|type|enum|public|private|protected|readonly|static|async|await|yield|try|catch|finally|throw|typeof|instanceof|in|of|as|satisfies|keyof|infer|declare|namespace|abstract|this|super|void|delete|get|set)\b`,
},
{
type: 'literal',
re: String.raw`\b(?:true|false|null|undefined|NaN|Infinity)\b`,
},
{
type: 'number',
re: String.raw`\b0[xXbBoO]\w+\b|\b\d[\d_]*(?:\.\d[\d_]*)?(?:[eE][+-]?\d+)?n?\b`,
},
{ type: 'function', re: String.raw`\b[a-z_$][\w$]*(?=\s*\()` },
{ type: 'type', re: String.raw`\b[A-Z][\w$]*\b` },
{ type: 'punctuation', re: String.raw`[{}()[\]<>,;.:?!=+\-*/%&|^~]+` },
];
const JSON_RULES: Rule[] = [
{ type: 'property', re: String.raw`"(?:[^"\\]|\\.)*"(?=\s*:)` },
{ type: 'string', re: String.raw`"(?:[^"\\]|\\.)*"` },
{ type: 'literal', re: String.raw`\b(?:true|false|null)\b` },
{ type: 'number', re: String.raw`-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b` },
{ type: 'punctuation', re: String.raw`[{}[\],:]+` },
];
const BASH_RULES: Rule[] = [
{ type: 'comment', re: String.raw`#[^\n]*` },
{ type: 'string', re: String.raw`'[^']*'|"(?:[^"\\\n]|\\.)*"` },
{ type: 'type', re: String.raw`\$\{[^}]*\}|\$\w+` },
{
type: 'keyword',
re: String.raw`\b(?:if|then|else|elif|fi|for|in|do|done|while|until|case|esac|function|return|exit|export|local|readonly|set|source|alias|echo|cd|sudo)\b`,
},
{ type: 'property', re: String.raw`(?<=\s)--?[\w-]+\b` },
{ type: 'number', re: String.raw`\b\d+\b` },
{ type: 'punctuation', re: String.raw`[|&;<>(){}[\]=]+` },
];
const CSS_RULES: Rule[] = [
{ type: 'comment', re: String.raw`\/\*[\s\S]*?(?:\*\/|$)` },
{ type: 'string', re: String.raw`'[^']*'|"[^"]*"` },
{ type: 'keyword', re: String.raw`@[\w-]+|!important\b` },
{ type: 'number', re: String.raw`#[0-9a-fA-F]{3,8}\b` },
{ type: 'type', re: String.raw`[.#][\w-]+` },
{ type: 'property', re: String.raw`[\w-]+(?=\s*:)` },
{
type: 'number',
re: String.raw`-?\d[\d.]*(?:px|rem|em|vh|vw|vmin|vmax|fr|deg|ch|ex|s|ms|%)?`,
},
{ type: 'function', re: String.raw`\b[\w-]+(?=\()` },
{ type: 'punctuation', re: String.raw`[{}();:,>~+*]+` },
];
const HTML_RULES: Rule[] = [
{ type: 'comment', re: String.raw`<!--[\s\S]*?(?:-->|$)` },
{ type: 'keyword', re: String.raw`<!DOCTYPE[^>]*>` },
{ type: 'tag', re: String.raw`<\/?[a-zA-Z][\w:-]*` },
{ type: 'property', re: String.raw`[\w-]+(?==)` },
{ type: 'string', re: String.raw`"[^"]*"|'[^']*'` },
{ type: 'literal', re: String.raw`&\w+;` },
{ type: 'punctuation', re: String.raw`\/?>|=` },
];
const GRAMMARS: Record<string, Rule[]> = {
js: JS_RULES,
jsx: JS_RULES,
ts: JS_RULES,
tsx: JS_RULES,
javascript: JS_RULES,
typescript: JS_RULES,
json: JSON_RULES,
jsonc: JSON_RULES,
bash: BASH_RULES,
sh: BASH_RULES,
shell: BASH_RULES,
zsh: BASH_RULES,
css: CSS_RULES,
scss: CSS_RULES,
less: CSS_RULES,
html: HTML_RULES,
xml: HTML_RULES,
svg: HTML_RULES,
};
const tokenize = (code: string, language?: string): Token[][] | null => {
const rules = language ? GRAMMARS[language.toLowerCase()] : undefined;
if (!rules) return null;
const master = new RegExp(rules.map((rule) => `(${rule.re})`).join('|'), 'g');
const tokens: Token[] = [];
let last = 0;
for (const match of code.matchAll(master)) {
const index = match.index ?? 0;
if (index > last) {
tokens.push({ type: 'plain', content: code.slice(last, index) });
}
const groupIndex = match.slice(1).findIndex((group) => group !== undefined);
tokens.push({ type: rules[groupIndex].type, content: match[0] });
last = index + match[0].length;
}
if (last < code.length) {
tokens.push({ type: 'plain', content: code.slice(last) });
}
const lines: Token[][] = [[]];
for (const token of tokens) {
const parts = token.content.split('\n');
parts.forEach((part, partIndex) => {
if (partIndex > 0) lines.push([]);
if (part) lines[lines.length - 1].push({ type: token.type, content: part });
});
}
return lines;
};
interface CodeBlockContextValue {
code: string;
language?: string;
filename?: string;
highlight: boolean;
showLineNumbers: boolean;
highlightLines: number[];
addedLines: number[];
removedLines: number[];
wrap: boolean;
maxHeight?: number;
copyable: boolean;
expanded: boolean;
setExpanded: React.Dispatch<React.SetStateAction<boolean>>;
}
const CodeBlockContext = React.createContext<CodeBlockContextValue | null>(null);
const useCodeBlock = () => {
const context = React.useContext(CodeBlockContext);
if (!context) {
throw new Error('useCodeBlock must be used within a <CodeBlock />');
}
return context;
};
export interface CodeBlockProps extends React.ComponentProps<'div'> {
/** Raw code to display. A string child is accepted as an alternative. */
code?: string;
language?: string;
filename?: string;
/** Tokenize and color the code when the language has a built-in grammar. */
highlight?: boolean;
showLineNumbers?: boolean;
highlightLines?: number[];
addedLines?: number[];
removedLines?: number[];
wrap?: boolean;
maxHeight?: number;
copyable?: boolean;
}
const CodeBlock = ({
code,
language,
filename,
highlight = true,
showLineNumbers = true,
highlightLines = [],
addedLines = [],
removedLines = [],
wrap = false,
maxHeight,
copyable = true,
className,
children,
...props
}: CodeBlockProps) => {
const [expanded, setExpanded] = React.useState(false);
const rawCode = (code ?? (typeof children === 'string' ? children : '')).replace(/\n$/, '');
const hasCustomChildren = children != null && typeof children !== 'string';
const showHeader = Boolean(filename || language || copyable);
const contextValue = React.useMemo<CodeBlockContextValue>(
() => ({
code: rawCode,
language,
filename,
highlight,
showLineNumbers,
highlightLines,
addedLines,
removedLines,
wrap,
maxHeight,
copyable,
expanded,
setExpanded,
}),
[
rawCode,
language,
filename,
highlight,
showLineNumbers,
highlightLines,
addedLines,
removedLines,
wrap,
maxHeight,
copyable,
expanded,
],
);
return (
<CodeBlockContext.Provider value={contextValue}>
<div
data-slot="code-block"
className={cn('overflow-hidden rounded-md border border-border bg-card text-card-foreground', className)}
{...props}
>
{hasCustomChildren ? (
children
) : (
<>
{showHeader && <CodeBlockHeader />}
<CodeBlockContent />
</>
)}
</div>
</CodeBlockContext.Provider>
);
};
const CodeBlockHeader = ({ className, children, ...props }: React.ComponentProps<'div'>) => {
const { code, language, filename, copyable } = useCodeBlock();
return (
<div
data-slot="code-block-header"
className={cn('flex min-h-10 items-center gap-2 border-b border-border px-3 py-1', className)}
{...props}
>
{children ?? (
<>
{filename && (
<span
data-slot="code-block-filename"
className="flex items-center gap-1.5 font-mono text-xs text-foreground"
>
<FileCode className="size-3.5 text-muted-foreground" aria-hidden />
{filename}
</span>
)}
{language && (
<Badge data-slot="code-block-language" variant="outline">
{language}
</Badge>
)}
{copyable && <CopyButton value={code} size="sm" className="ms-auto" />}
</>
)}
</div>
);
};
const CodeBlockContent = ({ className, ...props }: React.ComponentProps<'div'>) => {
const {
code,
language,
highlight,
showLineNumbers,
highlightLines,
addedLines,
removedLines,
wrap,
maxHeight,
expanded,
setExpanded,
} = useCodeBlock();
const preRef = React.useRef<HTMLPreElement>(null);
const [overflowing, setOverflowing] = React.useState(false);
const lines = code.split('\n');
const tokenLines = React.useMemo(() => (highlight ? tokenize(code, language) : null), [highlight, code, language]);
const hasDiff = addedLines.length > 0 || removedLines.length > 0;
const collapsible = maxHeight != null;
const collapsed = collapsible && !expanded;
React.useEffect(() => {
if (maxHeight == null) return;
const pre = preRef.current;
if (!pre) return;
const update = () => setOverflowing(pre.scrollHeight > maxHeight);
update();
const observer = new ResizeObserver(update);
observer.observe(pre);
return () => observer.disconnect();
}, [maxHeight, code, wrap]);
return (
<div data-slot="code-block-content" className={cn('text-sm', className)} {...props}>
<div
data-slot="code-block-viewport"
className={cn('relative', collapsed && 'overflow-hidden')}
style={collapsed ? { maxHeight } : undefined}
>
<pre
ref={preRef}
dir="ltr"
className={cn(
'overflow-x-auto py-3 font-mono text-[13px] leading-6',
wrap && 'whitespace-pre-wrap break-words',
)}
>
<code data-slot="code-block-code" className="block w-fit min-w-full">
{lines.map((line, index) => {
const lineNumber = index + 1;
const highlighted = highlightLines.includes(lineNumber);
const added = addedLines.includes(lineNumber);
const removed = removedLines.includes(lineNumber);
return (
<span
key={lineNumber}
data-slot="code-block-line"
data-line-number={lineNumber}
data-highlighted={highlighted || undefined}
data-diff={added ? 'added' : removed ? 'removed' : undefined}
className={cn(
'flex border-s-2 border-transparent pe-4',
showLineNumbers || hasDiff ? 'ps-2' : 'ps-4',
highlighted && 'border-primary bg-accent',
added && 'bg-primary/10',
removed && 'bg-destructive/10',
)}
>
{showLineNumbers && (
<span
aria-hidden
data-slot="code-block-line-number"
data-line-number={lineNumber}
className="w-8 shrink-0 select-none pe-3 text-end text-muted-foreground before:content-[attr(data-line-number)]"
/>
)}
{hasDiff && (
<span
aria-hidden
data-slot="code-block-diff-marker"
className={cn(
'w-4 shrink-0 select-none',
added && "text-primary before:content-['+']",
removed && "text-destructive before:content-['−']",
)}
/>
)}
<span className="flex-1">
{line.length === 0
? ''
: (tokenLines?.[index]?.map((token, tokenIndex) =>
token.type === 'plain' ? (
token.content
) : (
<span key={tokenIndex} data-token={token.type} className={TOKEN_CLASSES[token.type]}>
{token.content}
</span>
),
) ?? line)}
</span>
</span>
);
})}
</code>
</pre>
{collapsed && overflowing && (
<div
aria-hidden
data-slot="code-block-fade"
className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-linear-to-t from-card to-transparent"
/>
)}
</div>
{collapsible && (overflowing || expanded) && (
<div data-slot="code-block-expand" className="flex justify-center border-t border-border p-1">
<Button
type="button"
variant="ghost"
size="sm"
aria-expanded={expanded}
onClick={() => setExpanded((value) => !value)}
className="h-7 gap-1 text-xs text-muted-foreground hover:text-foreground"
>
{expanded ? 'Show less' : 'Show more'}
{expanded ? <ChevronUp aria-hidden /> : <ChevronDown aria-hidden />}
</Button>
</div>
)}
</div>
);
};
export { CodeBlock, CodeBlockHeader, CodeBlockContent };
Dependencies
shadcn registry
badgebuttoncopy-button
npm
lucide-react