easeui
componentcode-blocksource: ok · refreshed
Code Block
A code block with a label, a copy button, and light coloring for keywords, strings, comments and numbers, with no highlighter dependency.
npx shadcn add "https://easeui.dev/r/code-block.json"free
Where it goes
comment
Visual style
terminal-mono
Use cases
community
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
Files3 · source cached 10/8/2026, 7:44:13 AM
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))
}