Code Block
A code block with a label, a copy button, and light coloring for keywords, strings, comments and numbers, with no highlighter dependency.
function greet(name) { // Say hello, politely const message = `Hello, ${name}!`; return message;}import { CodeBlock } from "@/components/motion/code-block";
const CODE = `function greet(name) {
// Say hello, politely
const message = \`Hello, \${name}!\`;
return message;
}`;
export function CodeBlockPreview() {
return (
<div className="w-full max-w-md">
<CodeBlock code={CODE} label="greet.js" />
</div>
);
}
"use client";
// easeui.dev/components/agents/code-block
import { FileCode } from "lucide-react";
import { CopyButton } from "@/components/motion/copy-button";
import { cn } from "@/lib/utils";
export interface CodeBlockProps {
code: string;
/** Shown in the header, such as a language name or a filename. Default "code". */
label?: string;
className?: string;
}
const KEYWORDS = new Set([
"const", "let", "var", "function", "return", "if", "else", "for", "while", "do", "switch", "case",
"break", "continue", "class", "interface", "type", "extends", "implements", "import", "from",
"export", "default", "async", "await", "new", "this", "super", "try", "catch", "finally", "throw",
"typeof", "instanceof", "in", "of", "true", "false", "null", "undefined", "void", "yield", "static",
"public", "private", "protected", "readonly", "def", "elif", "lambda", "pass", "with", "as", "None",
"True", "False", "self",
]);
type TokenType = "keyword" | "string" | "comment" | "number" | "plain";
type Token = { text: string; type: TokenType };
const TOKEN_PATTERN =
/(\/\/.*$|#.*$|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`|\b\d+(?:\.\d+)?\b|\b[A-Za-z_$][\w$]*\b)/g;
/** A best-effort tokenizer for keywords, strings, comments and numbers. Not a real parser. */
function tokenize(line: string): Token[] {
const tokens: Token[] = [];
let last = 0;
for (const match of line.matchAll(TOKEN_PATTERN)) {
const index = match.index ?? 0;
if (index > last) tokens.push({ text: line.slice(last, index), type: "plain" });
const text = match[0];
const type: TokenType =
text.startsWith("//") || text.startsWith("#")
? "comment"
: /^["'`]/.test(text)
? "string"
: /^\d/.test(text)
? "number"
: KEYWORDS.has(text)
? "keyword"
: "plain";
tokens.push({ text, type });
last = index + text.length;
}
if (last < line.length) tokens.push({ text: line.slice(last), type: "plain" });
return tokens;
}
const TOKEN_CLASS: Record<TokenType, string> = {
keyword: "text-accent",
string: "text-success",
comment: "italic text-muted-foreground",
number: "text-warning",
plain: "text-foreground",
};
/** A code block with a label, a copy button, and light syntax coloring. No highlighter dependency. */
export function CodeBlock({ code, label = "code", className }: CodeBlockProps) {
const lines = code.replace(/\n$/, "").split("\n");
return (
<figure
className={cn(
"overflow-hidden rounded-2xl bg-card font-mono text-[13px] leading-relaxed shadow-[0_0_0_1px_var(--border)]",
className,
)}
>
<figcaption className="flex h-11 items-center justify-between gap-3 border-b border-border pl-4 pr-1.5">
<span className="flex min-w-0 items-center gap-2 font-sans text-xs text-muted-foreground">
<FileCode aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{label}</span>
</span>
<CopyButton value={code} />
</figcaption>
<pre className="max-h-96 overflow-x-auto overflow-y-auto py-4">
<code>
{lines.map((line, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: lines only append or grow in place while streaming.
<div key={index} className="px-4">
{line.length === 0
? " "
: tokenize(line).map((token, tokenIndex) => (
// biome-ignore lint/suspicious/noArrayIndexKey: tokens are recomputed fresh from the line on every render.
<span key={tokenIndex} className={TOKEN_CLASS[token.type]}>
{token.text}
</span>
))}
</div>
))}
</code>
</pre>
</figure>
);
}
Installation
$ bunx --bun shadcn add @easeui/code-block
- 1
Set up the theme tokens
Do this once per project. Follow the theme setup or skip it if you already ran shadcn init.
- 2
Install the dependencies
terminal npm install clsx lucide-react tailwind-merge - 3
Add the source files
components/motion/code-block.tsx "use client"; // easeui.dev/components/agents/code-block import { FileCode } from "lucide-react"; import { CopyButton } from "@/components/motion/copy-button"; import { cn } from "@/lib/utils"; export interface CodeBlockProps { code: string; /** Shown in the header, such as a language name or a filename. Default "code". */ label?: string; className?: string; } const KEYWORDS = new Set([ "const", "let", "var", "function", "return", "if", "else", "for", "while", "do", "switch", "case", "break", "continue", "class", "interface", "type", "extends", "implements", "import", "from", "export", "default", "async", "await", "new", "this", "super", "try", "catch", "finally", "throw", "typeof", "instanceof", "in", "of", "true", "false", "null", "undefined", "void", "yield", "static", "public", "private", "protected", "readonly", "def", "elif", "lambda", "pass", "with", "as", "None", "True", "False", "self", ]); type TokenType = "keyword" | "string" | "comment" | "number" | "plain"; type Token = { text: string; type: TokenType }; const TOKEN_PATTERN = /(\/\/.*$|#.*$|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`|\b\d+(?:\.\d+)?\b|\b[A-Za-z_$][\w$]*\b)/g; /** A best-effort tokenizer for keywords, strings, comments and numbers. Not a real parser. */ function tokenize(line: string): Token[] { const tokens: Token[] = []; let last = 0; for (const match of line.matchAll(TOKEN_PATTERN)) { const index = match.index ?? 0; if (index > last) tokens.push({ text: line.slice(last, index), type: "plain" }); const text = match[0]; const type: TokenType = text.startsWith("//") || text.startsWith("#") ? "comment" : /^["'`]/.test(text) ? "string" : /^\d/.test(text) ? "number" : KEYWORDS.has(text) ? "keyword" : "plain"; tokens.push({ text, type }); last = index + text.length; } if (last < line.length) tokens.push({ text: line.slice(last), type: "plain" }); return tokens; } const TOKEN_CLASS: Record<TokenType, string> = { keyword: "text-accent", string: "text-success", comment: "italic text-muted-foreground", number: "text-warning", plain: "text-foreground", }; /** A code block with a label, a copy button, and light syntax coloring. No highlighter dependency. */ export function CodeBlock({ code, label = "code", className }: CodeBlockProps) { const lines = code.replace(/\n$/, "").split("\n"); return ( <figure className={cn( "overflow-hidden rounded-2xl bg-card font-mono text-[13px] leading-relaxed shadow-[0_0_0_1px_var(--border)]", className, )} > <figcaption className="flex h-11 items-center justify-between gap-3 border-b border-border pl-4 pr-1.5"> <span className="flex min-w-0 items-center gap-2 font-sans text-xs text-muted-foreground"> <FileCode aria-hidden="true" className="h-3.5 w-3.5 shrink-0" /> <span className="truncate">{label}</span> </span> <CopyButton value={code} /> </figcaption> <pre className="max-h-96 overflow-x-auto overflow-y-auto py-4"> <code> {lines.map((line, index) => ( // biome-ignore lint/suspicious/noArrayIndexKey: lines only append or grow in place while streaming. <div key={index} className="px-4"> {line.length === 0 ? " " : tokenize(line).map((token, tokenIndex) => ( // biome-ignore lint/suspicious/noArrayIndexKey: tokens are recomputed fresh from the line on every render. <span key={tokenIndex} className={TOKEN_CLASS[token.type]}> {token.text} </span> ))} </div> ))} </code> </pre> </figure> ); }components/motion/copy-button.tsx "use client"; import { Check, Copy } from "lucide-react"; import { type ButtonHTMLAttributes, forwardRef, useEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; type CopyState = "idle" | "copied" | "failed"; export interface CopyButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children" | "value" | "onCopy"> { /** Text written to the clipboard. */ value: string; /** Visible text. Leave it out for an icon only button. */ label?: string; /** Visible text after copying. Default "Copied". */ copiedLabel?: string; /** How long the copied state stays, in ms. Default 1500. */ timeout?: number; /** Called after the text reaches the clipboard. */ onCopied?: (value: string) => void; } // Both icons, and both labels, share a grid cell and trade places with a fade and a small // scale. Sharing the cell keeps the button the same width in either state. const SWAP = "col-start-1 row-start-1 transition-[opacity,scale] duration-200 ease-out motion-reduce:transition-none"; const SHOWN = "scale-100 opacity-100"; const ICON_HIDDEN = "scale-50 opacity-0"; const TEXT_HIDDEN = "scale-[0.97] opacity-0"; export const CopyButton = forwardRef<HTMLButtonElement, CopyButtonProps>(function CopyButton( { value, label, copiedLabel = "Copied", timeout = 1500, onCopied, className, onClick, ...props }, ref, ) { const [state, setState] = useState<CopyState>("idle"); const timer = useRef<ReturnType<typeof setTimeout> | null>(null); useEffect( () => () => { if (timer.current) clearTimeout(timer.current); }, [], ); const copy = async () => { try { await navigator.clipboard.writeText(value); setState("copied"); onCopied?.(value); } catch { // Clipboard access can be blocked, for example in an insecure context. setState("failed"); } if (timer.current) clearTimeout(timer.current); timer.current = setTimeout(() => setState("idle"), timeout); }; const copied = state === "copied"; const status = copied ? "Copied to clipboard" : state === "failed" ? "Could not copy" : ""; return ( <button ref={ref} type="button" aria-label={label ?? "Copy"} data-state={state} onClick={(event) => { onClick?.(event); if (!event.defaultPrevented) void copy(); }} className={cn( "relative inline-flex h-9 shrink-0 touch-manipulation select-none items-center justify-center gap-2 rounded-full bg-card text-sm font-medium text-foreground outline-none", "shadow-[0_0_0_1px_var(--border)] transition-[background-color,scale] duration-150 ease-out hover:bg-muted active:scale-[0.97] motion-reduce:active:scale-100", "focus-visible:ring-2 focus-visible:ring-foreground/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background", label ? "px-3.5" : "w-9 after:absolute after:-inset-1", className, )} {...props} > <span aria-hidden="true" className="grid place-items-center"> <Copy className={cn(SWAP, "h-4 w-4", copied ? ICON_HIDDEN : SHOWN)} /> <Check className={cn(SWAP, "h-4 w-4", copied ? SHOWN : ICON_HIDDEN)} /> </span> {label ? ( <span aria-hidden="true" className="grid whitespace-nowrap"> <span className={cn(SWAP, copied ? TEXT_HIDDEN : SHOWN)}>{label}</span> <span className={cn(SWAP, copied ? SHOWN : TEXT_HIDDEN)}>{copiedLabel}</span> </span> ) : null} <span aria-live="polite" className="sr-only"> {status} </span> </button> ); });lib/utils.ts import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }
API reference
| Prop | Type | Default | Description |
|---|---|---|---|
| code | string | - | - |
| label? | string | code | Shown in the header, such as a language name or a filename. Default "code". |
| className? | string | - | - |
Updated