shadimage

Code Block

A plug-and-play syntax-highlighted code block for documentation and developer interfaces.

registry/sonaui/code-block/code-block.tsx

"use client";

import {
  SiJavascript,
  SiPython,
  SiTypescript,
} from "@icons-pack/react-simple-icons";
import { codeToHtml, type ShikiTransformer } from "shiki";
import { Check, Copy, Terminal } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { cn } from "@/lib/sona-utils";

export interface CodeBlockProps {
  /** Source code to render. */
  code: string;
  /** Language identifier used for syntax highlighting. @default "tsx" */
  language?: string;
  /** Optional filename shown in the header. @default undefined */
  filename?: string;
  /** Shows a numbered gutter. @default false */
  showLineNumbers?: boolean;
  /** Lines to emphasize, as numbers or a comma-separated range. @default undefined */
  highlightLines?: number[] | string;
  /** Removes leading diff markers and styles added or removed lines. @default false */
  showDiff?: boolean;
  /** Lines to keep visually focused. @default undefined */
  focusLines?: number[] | string;
  /** Additional classes for the code block. @default undefined */
  className?: string;
}

function parseLines(value: number[] | string | undefined) {
  if (!value) return new Set<number>();
  if (Array.isArray(value)) return new Set(value);
  const lines = new Set<number>();
  value.split(",").forEach((part) => {
    const [start, end] = part.split("-").map(Number);
    if (!Number.isFinite(start)) return;
    for (let line = start; line <= (Number.isFinite(end) ? end : start); line++)
      lines.add(line);
  });
  return lines;
}

function languageIcon(language: string) {
  const normalized = language.toLowerCase();
  if (["ts", "tsx", "typescript"].includes(normalized))
    return <SiTypescript className="size-4" />;
  if (["js", "jsx", "javascript"].includes(normalized))
    return <SiJavascript className="size-4" />;
  if (["py", "python"].includes(normalized))
    return <SiPython className="size-4" />;
  if (["bash", "sh", "shell"].includes(normalized))
    return <Terminal className="size-4" />;
  return null;
}

function createLineTransformers(
  code: string,
  options: Pick<CodeBlockProps, "highlightLines" | "focusLines" | "showDiff">,
): ShikiTransformer[] {
  const highlighted = parseLines(options.highlightLines);
  const focused = parseLines(options.focusLines);
  const originalLines = code.split("\n");
  return [
    {
      name: "code-block-lines",
      preprocess(source) {
        return options.showDiff
          ? source.split("\n").map((line) => line.replace(/^[-+ ]/, "")).join("\n")
          : source;
      },
      line(node, lineNumber) {
        node.properties["data-line-number"] = String(lineNumber);
        if (highlighted.has(lineNumber)) node.properties["data-highlighted"] = "true";
        if (focused.has(lineNumber)) node.properties["data-focused"] = "true";
        if (options.showDiff) {
          const original = originalLines[lineNumber - 1] ?? "";
          if (original.startsWith("+")) node.properties["data-diff"] = "added";
          if (original.startsWith("-")) node.properties["data-diff"] = "removed";
        }
      },
    },
  ];
}

export default function CodeBlock({
  code,
  language = "tsx",
  filename,
  showLineNumbers = false,
  highlightLines,
  showDiff = false,
  focusLines,
  className,
}: CodeBlockProps) {
  const [copied, setCopied] = useState(false);
  const emphasized = parseLines(highlightLines);
  const focused = parseLines(focusLines);
  const lines = useMemo(() => code.split("\n"), [code]);
  const source = useMemo(() => {
    const source = showDiff
      ? lines.map((line) => line.replace(/^[-+ ]/, "")).join("\n")
      : code;
    return source;
  }, [code, lines, showDiff]);
  const [html, setHtml] = useState("");
  useEffect(() => {
    let active = true;
    void codeToHtml(source, { lang: language, themes: { light: "github-light", dark: "github-dark" }, defaultColor: "light-dark()", transformers: createLineTransformers(code, { highlightLines, focusLines, showDiff }) }).then((result) => {
      if (active) setHtml(result.replace(/^<pre[^>]*><code>/, "").replace(/<\/code><\/pre>$/, ""));
    });
    return () => { active = false; };
  }, [code, focusLines, highlightLines, language, showDiff, source]);

  async function copyCode() {
    await navigator.clipboard.writeText(
      showDiff
        ? lines.map((line) => line.replace(/^[-+ ]/, "")).join("\n")
        : code,
    );
    setCopied(true);
    window.setTimeout(() => setCopied(false), 1400);
  }

  return (
    <div
      className={cn(
        "relative w-full overflow-hidden rounded-xl border border-border/60 bg-card",
        className,
      )}
    >
      {(filename || language) && (
        <div className="flex h-11 items-center justify-between border-b border-border/60 px-4 text-xs text-muted-foreground">
          <span className="flex items-center gap-2">
            {languageIcon(language)}
            {filename ?? language}
          </span>
          <button
            type="button"
            onClick={copyCode}
            className="rounded-md p-1.5 hover:bg-accent"
            aria-label="Copy code"
          >
            {copied ? (
              <Check className="size-4" />
            ) : (
              <Copy className="size-4" />
            )}
          </button>
        </div>
      )}
      <div className="overflow-x-auto p-4 text-[.8125rem] leading-normal">
        <div
          className={cn(
            "flex min-w-max",
            focusLines && "[&_.line:not([data-focused])]:opacity-45",
          )}
        >
          {showLineNumbers && (
            <div className="mr-4 select-none text-right text-muted-foreground">
              {lines.map((_, i) => (
                <div key={i}>{i + 1}</div>
              ))}
            </div>
          )}
          <div
            className="th-code-content block w-full whitespace-pre [&_.line]:inline-block [&_.line]:w-full [&_.line]:min-w-full [&_.th-line]:inline-block [&_.th-line]:w-full [&_.th-line]:min-w-full [&_.line[data-highlighted]]:bg-primary/10 [&_.line[data-diff=added]]:bg-green-500/10 [&_.line[data-diff=removed]]:bg-red-500/10 [&_.line[data-focused]]:font-medium"
            dangerouslySetInnerHTML={{ __html: html }}
          />
        </div>
      </div>
    </div>
  );
}
Visual style
terminal-mono

DependenciesBundle weight · light

icons
lucide-react