shadimage
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 · light
iconslucide-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))
}

More from easeui

Browse all components in this library →