Code Block
Editor-style code block with a filename tab, a top-right spring-crossfade copy button, a bottom status bar with language and line count, line numbers, built-in theme-aware syntax highlighting, and line emphasis.
code-block.tsx
"use client";
import { Check, Copy } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useCallback, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils";
const COPY_RESET_MS = 2000;
/** Transform-only spring for the copy icon swap — stays off the layout thread. */
const ICON_SPRING = {
type: "spring",
stiffness: 600,
damping: 30,
mass: 0.6,
} as const;
type TokenKind =
| "call"
| "comment"
| "keyword"
| "number"
| "operator"
| "plain"
| "property"
| "punctuation"
| "string"
| "tag"
| "type";
type Token = {
kind: TokenKind;
value: string;
};
const KEYWORDS = new Set([
"abstract",
"as",
"async",
"await",
"break",
"case",
"catch",
"class",
"const",
"continue",
"def",
"default",
"delete",
"do",
"elif",
"else",
"enum",
"export",
"extends",
"false",
"finally",
"fn",
"for",
"from",
"function",
"if",
"implements",
"import",
"in",
"instanceof",
"interface",
"let",
"match",
"new",
"None",
"not",
"null",
"of",
"pass",
"private",
"protected",
"public",
"readonly",
"return",
"satisfies",
"static",
"struct",
"switch",
"this",
"throw",
"true",
"try",
"type",
"typeof",
"undefined",
"var",
"void",
"while",
"yield",
]);
const WHITESPACE_RE = /^\s+/;
const BLOCK_COMMENT_RE = /^\/\*[\s\S]*?(?:\*\/|$)/;
const LINE_COMMENT_RE = /^(?:\/\/|#)[^\n]*/;
const TEMPLATE_STRING_RE = /^`(?:\\.|[\s\S])*?`/;
const DOUBLE_QUOTED_STRING_RE = /^"(?:\\.|[^"\\])*"/;
const SINGLE_QUOTED_STRING_RE = /^'(?:\\.|[^'\\])*'/;
const TAG_RE = /^<\/?[A-Za-z][\w.-]*|^\/?>/;
const ATTRIBUTE_RE = /^[A-Za-z_$][\w$-]*(?==)/;
const PROPERTY_RE = /^[A-Za-z_$][\w$-]*(?=\s*:)/;
const IDENTIFIER_RE = /^[A-Za-z_$][\w$]*/;
const TYPE_IDENTIFIER_RE = /^[A-Z]/;
const NUMBER_RE = /^-?\d+(?:\.\d+)?/;
const OPERATOR_RE =
/^(?:=>|===|!==|==|!=|<=|>=|\+\+|--|\|\||&&|\?\?|\.\.\.|[=<>+\-*/%!?&|])/;
const PUNCTUATION_RE = /^[{}()[\],.;:]/;
const STATIC_MATCHERS: { kind: TokenKind; regex: RegExp }[] = [
{ kind: "plain", regex: WHITESPACE_RE },
{ kind: "comment", regex: BLOCK_COMMENT_RE },
{ kind: "comment", regex: LINE_COMMENT_RE },
{ kind: "string", regex: TEMPLATE_STRING_RE },
{ kind: "string", regex: DOUBLE_QUOTED_STRING_RE },
{ kind: "string", regex: SINGLE_QUOTED_STRING_RE },
{ kind: "tag", regex: TAG_RE },
{ kind: "property", regex: ATTRIBUTE_RE },
{ kind: "property", regex: PROPERTY_RE },
{ kind: "number", regex: NUMBER_RE },
{ kind: "operator", regex: OPERATOR_RE },
{ kind: "punctuation", regex: PUNCTUATION_RE },
];
/** Theme-aware token palette — light colors on white, brighter ones in dark. */
const TOKEN_CLASS_NAMES: Record<TokenKind, string> = {
plain: "text-zinc-800 dark:text-zinc-200",
comment: "text-zinc-400 italic dark:text-zinc-500",
keyword: "text-rose-600 dark:text-rose-400",
string: "text-emerald-600 dark:text-emerald-400",
number: "text-amber-600 dark:text-amber-400",
property: "text-sky-600 dark:text-sky-400",
call: "text-violet-600 dark:text-violet-400",
type: "text-indigo-600 dark:text-indigo-400",
tag: "text-teal-600 dark:text-teal-400",
operator: "text-zinc-500 dark:text-zinc-400",
punctuation: "text-zinc-400 dark:text-zinc-500",
};
function readStaticToken(source: string): Token | null {
for (const matcher of STATIC_MATCHERS) {
const value = matcher.regex.exec(source)?.[0];
if (value) {
return { kind: matcher.kind, value };
}
}
return null;
}
function identifierKind(identifier: string, source: string): TokenKind {
if (KEYWORDS.has(identifier)) {
return "keyword";
}
if (TYPE_IDENTIFIER_RE.test(identifier)) {
return "type";
}
if (source.startsWith(`${identifier}(`)) {
return "call";
}
return "plain";
}
function readDynamicToken(source: string): Token {
const identifier = IDENTIFIER_RE.exec(source)?.[0];
if (identifier) {
return { kind: identifierKind(identifier, source), value: identifier };
}
return { kind: "plain", value: source[0] ?? "" };
}
function tokenizeLine(line: string): Token[] {
const tokens: Token[] = [];
let cursor = 0;
while (cursor < line.length) {
const source = line.slice(cursor);
const token = readStaticToken(source) ?? readDynamicToken(source);
tokens.push(token);
cursor += token.value.length;
}
return tokens;
}
function CopyButton({
copied,
onCopy,
reducedMotion,
floating = false,
}: {
copied: boolean;
onCopy: () => void;
reducedMotion: boolean;
/** Glass-chip styling for when the button floats over the code area. */
floating?: boolean;
}) {
const iconTransition = reducedMotion ? { duration: 0 } : ICON_SPRING;
return (
<motion.button
aria-label={copied ? "Copied" : "Copy code"}
className={cn(
"flex h-7 items-center gap-1.5 rounded-md px-2 font-medium text-xs outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-ring/60",
floating &&
"border border-border/70 bg-background/85 shadow-sm backdrop-blur-sm",
copied
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground"
)}
data-slot="code-block-copy"
onClick={onCopy}
type="button"
whileTap={reducedMotion ? undefined : { scale: 0.96 }}
>
<span className="relative size-3.5">
<AnimatePresence initial={false}>
{copied ? (
<motion.span
animate={{ opacity: 1, scale: 1 }}
className="absolute inset-0 flex items-center justify-center"
exit={{ opacity: 0, scale: 0.95 }}
initial={{ opacity: 0, scale: 0.95 }}
key="check"
transition={iconTransition}
>
<Check className="size-3.5" strokeWidth={2.5} />
</motion.span>
) : (
<motion.span
animate={{ opacity: 1, scale: 1 }}
className="absolute inset-0 flex items-center justify-center"
exit={{ opacity: 0, scale: 0.95 }}
initial={{ opacity: 0, scale: 0.95 }}
key="copy"
transition={iconTransition}
>
<Copy className="size-3.5" />
</motion.span>
)}
</AnimatePresence>
</span>
{/* Both labels overlay a fixed-width slot, so the swap never shifts layout. */}
<span className="relative">
<span aria-hidden className="invisible">
Copied
</span>
<span
aria-hidden
className={cn(
"absolute inset-0 text-left transition-opacity duration-200",
copied ? "opacity-0" : "opacity-100"
)}
>
Copy
</span>
<span
aria-hidden
className={cn(
"absolute inset-0 text-left transition-opacity duration-200",
copied ? "opacity-100" : "opacity-0"
)}
>
Copied
</span>
</span>
</motion.button>
);
}
export type CodeBlockProps = {
/** Source code to render. Leading and trailing blank lines are trimmed. */
code: string;
/** Language shown in the bottom status bar and used for the tab accent dot. */
language?: string;
/** Filename shown inside the editor-style tab at the top. */
filename?: string;
/** Show the line-number gutter. */
showLineNumbers?: boolean;
/** 1-based line numbers to emphasize with a tinted row and accent bar. */
highlightLines?: number[];
/** Max body height before the code scrolls, e.g. 320 or "20rem". */
maxHeight?: number | string;
/** Called with the code after it is written to the clipboard. */
onCopy?: (code: string) => void;
/** Extra classes for the outer shell. */
className?: string;
};
export function CodeBlock({
code,
language,
filename,
showLineNumbers = true,
highlightLines,
maxHeight = 384,
onCopy,
className,
}: CodeBlockProps) {
const reducedMotion = useReducedMotion() ?? false;
const [copied, setCopied] = useState(false);
const resetTimer = useRef<number | null>(null);
const lines = useMemo(() => {
const trimmed = code.replace(/^\n+|\s+$/g, "");
return trimmed.split("\n").map((line) => tokenizeLine(line));
}, [code]);
const highlighted = useMemo(
() => new Set(highlightLines ?? []),
[highlightLines]
);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(code);
} catch {
// Clipboard API can be unavailable in iframes or without permission —
// fall back to a hidden textarea and the legacy copy command.
const textarea = document.createElement("textarea");
textarea.value = code;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
const succeeded = document.execCommand("copy");
textarea.remove();
if (!succeeded) {
return;
}
}
setCopied(true);
onCopy?.(code);
if (resetTimer.current !== null) {
window.clearTimeout(resetTimer.current);
}
resetTimer.current = window.setTimeout(() => {
setCopied(false);
resetTimer.current = null;
}, COPY_RESET_MS);
}, [code, onCopy]);
const hasTab = Boolean(filename);
return (
<div
className={cn(
"overflow-hidden rounded-xl border border-border/80 bg-background shadow-[0_1px_2px_rgba(15,23,42,0.05),0_16px_40px_-20px_rgba(15,23,42,0.22)] dark:bg-zinc-950 dark:shadow-[0_1px_2px_rgba(0,0,0,0.4),0_16px_40px_-20px_rgba(0,0,0,0.55)]",
className
)}
data-copied={copied || undefined}
data-slot="code-block"
>
{hasTab ? (
// Editor-style tab strip: the active tab joins the code area below it,
// with the copy button pinned to the strip's right edge.
<div className="flex items-end justify-between gap-3 border-border/70 border-b bg-gradient-to-b from-muted/70 to-muted/40 pt-2 pr-1.5 pl-2.5 dark:from-white/[0.05] dark:to-white/[0.02]">
<div className="relative -mb-px flex min-w-0 items-center gap-2 rounded-t-lg border border-border/70 border-b-0 bg-background px-3.5 py-1.5 dark:bg-zinc-950">
<span className="truncate font-mono text-[12.5px] text-foreground/80">
{filename}
</span>
</div>
<div className="mb-1 shrink-0">
<CopyButton
copied={copied}
onCopy={handleCopy}
reducedMotion={reducedMotion}
/>
</div>
</div>
) : null}
<div className="relative">
{hasTab ? null : (
// No tab strip — the copy button floats over the code, top right.
<div className="absolute top-2 right-2 z-10">
<CopyButton
copied={copied}
floating
onCopy={handleCopy}
reducedMotion={reducedMotion}
/>
</div>
)}
<div
className="overflow-auto [scrollbar-width:thin] [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-border/80 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar]:h-2 [&::-webkit-scrollbar]:w-2"
style={{ maxHeight }}
>
<pre className="min-w-max py-3.5 font-mono text-[13px] leading-6 selection:bg-indigo-500/25">
<code className="block">
{lines.map((tokens, index) => {
const lineNumber = index + 1;
const isHighlighted = highlighted.has(lineNumber);
return (
<span
className={cn(
"relative block pr-5 transition-colors duration-100",
showLineNumbers ? "pl-0" : "pl-4",
isHighlighted
? "bg-gradient-to-r from-indigo-500/[0.12] via-indigo-500/[0.04] to-transparent dark:from-indigo-400/[0.16] dark:via-indigo-400/[0.05]"
: "hover:bg-muted/60 dark:hover:bg-white/[0.04]",
isHighlighted &&
!showLineNumbers &&
"shadow-[inset_2px_0_0_0_#6366f1]"
)}
key={lineNumber}
>
{showLineNumbers ? (
// Opaque gutter cell: long lines slide underneath it on
// horizontal scroll instead of showing through the digits.
<span
aria-hidden
className={cn(
"sticky left-0 z-10 mr-4 inline-block w-11 select-none border-border/60 border-r pr-3 text-right text-xs tabular-nums leading-6",
isHighlighted
? "bg-[color-mix(in_srgb,#6366f1_10%,var(--color-background))] text-indigo-600 shadow-[inset_2px_0_0_0_#6366f1] dark:bg-[color-mix(in_srgb,#818cf8_14%,#09090b)] dark:text-indigo-400"
: "bg-background text-muted-foreground/50 dark:bg-zinc-950"
)}
>
{lineNumber}
</span>
) : null}
{tokens.length === 0 ? (
"\n"
) : (
<>
{tokens.map((token, tokenIndex) => (
<span
className={TOKEN_CLASS_NAMES[token.kind]}
key={`${lineNumber}-${token.kind}-${tokenIndex}`}
>
{token.value}
</span>
))}
{"\n"}
</>
)}
</span>
);
})}
</code>
</pre>
</div>
</div>
{/* Status bar: language and line count, plus a copy confirmation. */}
<div className="flex items-center justify-between gap-3 border-border/70 border-t bg-muted/60 px-4 py-1.5 text-[11px] text-muted-foreground dark:bg-white/[0.04]">
<div className="flex min-w-0 items-center gap-2">
{language ? (
<>
<span className="truncate font-mono uppercase tracking-wider">
{language}
</span>
<span aria-hidden className="text-muted-foreground/50">
·
</span>
</>
) : null}
<span className="shrink-0 tabular-nums">
{lines.length} {lines.length === 1 ? "line" : "lines"}
</span>
</div>
</div>
</div>
);
}
- Where it goes
- stats
- Visual style
- terminal-mono
- Interaction
- animated-counter
DependenciesBundle weight · medium
- animation
- motion
- icons
- lucide-react