Skip to content
6

Code Block

Code display with built-in dependency-free syntax highlighting via theme tokens, line numbers, line highlights, diff gutters, copy button and collapsible max-height.

Example

Installation

npx shadcn@latest add https://hirael.com/r/code-block.json

Usage

import { CodeBlock, CodeBlockHeader, CodeBlockContent } from "@/components/code-block"

Paths assume the default @/ alias from your components.json. The example above shows the parts composed.

API

<CodeBlock />

+ native element props
PropTypeDefault
code

Raw code to display. A string child is accepted as an alternative.

stringnone
languagestringnone
filenamestringnone
highlight

Tokenize and color the code when the language has a built-in grammar.

booleantrue
showLineNumbersbooleantrue
highlightLinesnumber[][]
addedLinesnumber[][]
removedLinesnumber[][]
wrapbooleanfalse
maxHeightnumbernone
copyablebooleantrue

<CodeBlockHeader />

+ native element props

No props of its own; forwards everything to the underlying element.

<CodeBlockContent />

+ native element props

No props of its own; forwards everything to the underlying element.

Component source

'use client';

import * as React from 'react';
import { ChevronDown, ChevronUp, FileCode } from 'lucide-react';

import { cn } from '@/lib/utils';
import { Badge } from '@/registry/hirael/bases/radix/ui/badge';
import { Button } from '@/registry/hirael/bases/radix/ui/button';
import { CopyButton } from '@/registry/hirael/bases/radix/components/copy-button';

type TokenType =
  | 'comment'
  | 'string'
  | 'keyword'
  | 'literal'
  | 'number'
  | 'type'
  | 'function'
  | 'property'
  | 'tag'
  | 'punctuation'
  | 'plain';

interface Token {
  type: TokenType;
  content: string;
}

interface Rule {
  type: TokenType;
  re: string;
}

const TOKEN_CLASSES: Record<TokenType, string | undefined> = {
  comment: 'italic text-muted-foreground',
  string: 'text-chart-2',
  keyword: 'text-chart-1',
  literal: 'text-chart-3',
  number: 'text-chart-3',
  type: 'text-chart-4',
  function: 'text-chart-5',
  property: 'text-chart-1',
  tag: 'text-chart-1',
  punctuation: 'text-muted-foreground',
  plain: undefined,
};

const JS_RULES: Rule[] = [
  { type: 'comment', re: String.raw`\/\/[^\n]*|\/\*[\s\S]*?(?:\*\/|$)` },
  {
    type: 'string',
    re: String.raw`'(?:[^'\\\n]|\\.)*'|"(?:[^"\\\n]|\\.)*"|\`(?:[^\`\\]|\\[\s\S])*\``,
  },
  {
    type: 'keyword',
    re: String.raw`\b(?:import|export|from|default|const|let|var|function|return|if|else|for|while|do|switch|case|break|continue|new|class|extends|implements|interface|type|enum|public|private|protected|readonly|static|async|await|yield|try|catch|finally|throw|typeof|instanceof|in|of|as|satisfies|keyof|infer|declare|namespace|abstract|this|super|void|delete|get|set)\b`,
  },
  {
    type: 'literal',
    re: String.raw`\b(?:true|false|null|undefined|NaN|Infinity)\b`,
  },
  {
    type: 'number',
    re: String.raw`\b0[xXbBoO]\w+\b|\b\d[\d_]*(?:\.\d[\d_]*)?(?:[eE][+-]?\d+)?n?\b`,
  },
  { type: 'function', re: String.raw`\b[a-z_$][\w$]*(?=\s*\()` },
  { type: 'type', re: String.raw`\b[A-Z][\w$]*\b` },
  { type: 'punctuation', re: String.raw`[{}()[\]<>,;.:?!=+\-*/%&|^~]+` },
];

const JSON_RULES: Rule[] = [
  { type: 'property', re: String.raw`"(?:[^"\\]|\\.)*"(?=\s*:)` },
  { type: 'string', re: String.raw`"(?:[^"\\]|\\.)*"` },
  { type: 'literal', re: String.raw`\b(?:true|false|null)\b` },
  { type: 'number', re: String.raw`-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b` },
  { type: 'punctuation', re: String.raw`[{}[\],:]+` },
];

const BASH_RULES: Rule[] = [
  { type: 'comment', re: String.raw`#[^\n]*` },
  { type: 'string', re: String.raw`'[^']*'|"(?:[^"\\\n]|\\.)*"` },
  { type: 'type', re: String.raw`\$\{[^}]*\}|\$\w+` },
  {
    type: 'keyword',
    re: String.raw`\b(?:if|then|else|elif|fi|for|in|do|done|while|until|case|esac|function|return|exit|export|local|readonly|set|source|alias|echo|cd|sudo)\b`,
  },
  { type: 'property', re: String.raw`(?<=\s)--?[\w-]+\b` },
  { type: 'number', re: String.raw`\b\d+\b` },
  { type: 'punctuation', re: String.raw`[|&;<>(){}[\]=]+` },
];

const CSS_RULES: Rule[] = [
  { type: 'comment', re: String.raw`\/\*[\s\S]*?(?:\*\/|$)` },
  { type: 'string', re: String.raw`'[^']*'|"[^"]*"` },
  { type: 'keyword', re: String.raw`@[\w-]+|!important\b` },
  { type: 'number', re: String.raw`#[0-9a-fA-F]{3,8}\b` },
  { type: 'type', re: String.raw`[.#][\w-]+` },
  { type: 'property', re: String.raw`[\w-]+(?=\s*:)` },
  {
    type: 'number',
    re: String.raw`-?\d[\d.]*(?:px|rem|em|vh|vw|vmin|vmax|fr|deg|ch|ex|s|ms|%)?`,
  },
  { type: 'function', re: String.raw`\b[\w-]+(?=\()` },
  { type: 'punctuation', re: String.raw`[{}();:,>~+*]+` },
];

const HTML_RULES: Rule[] = [
  { type: 'comment', re: String.raw`<!--[\s\S]*?(?:-->|$)` },
  { type: 'keyword', re: String.raw`<!DOCTYPE[^>]*>` },
  { type: 'tag', re: String.raw`<\/?[a-zA-Z][\w:-]*` },
  { type: 'property', re: String.raw`[\w-]+(?==)` },
  { type: 'string', re: String.raw`"[^"]*"|'[^']*'` },
  { type: 'literal', re: String.raw`&\w+;` },
  { type: 'punctuation', re: String.raw`\/?>|=` },
];

const GRAMMARS: Record<string, Rule[]> = {
  js: JS_RULES,
  jsx: JS_RULES,
  ts: JS_RULES,
  tsx: JS_RULES,
  javascript: JS_RULES,
  typescript: JS_RULES,
  json: JSON_RULES,
  jsonc: JSON_RULES,
  bash: BASH_RULES,
  sh: BASH_RULES,
  shell: BASH_RULES,
  zsh: BASH_RULES,
  css: CSS_RULES,
  scss: CSS_RULES,
  less: CSS_RULES,
  html: HTML_RULES,
  xml: HTML_RULES,
  svg: HTML_RULES,
};

const tokenize = (code: string, language?: string): Token[][] | null => {
  const rules = language ? GRAMMARS[language.toLowerCase()] : undefined;
  if (!rules) return null;

  const master = new RegExp(rules.map((rule) => `(${rule.re})`).join('|'), 'g');
  const tokens: Token[] = [];
  let last = 0;
  for (const match of code.matchAll(master)) {
    const index = match.index ?? 0;
    if (index > last) {
      tokens.push({ type: 'plain', content: code.slice(last, index) });
    }
    const groupIndex = match.slice(1).findIndex((group) => group !== undefined);
    tokens.push({ type: rules[groupIndex].type, content: match[0] });
    last = index + match[0].length;
  }
  if (last < code.length) {
    tokens.push({ type: 'plain', content: code.slice(last) });
  }

  const lines: Token[][] = [[]];
  for (const token of tokens) {
    const parts = token.content.split('\n');
    parts.forEach((part, partIndex) => {
      if (partIndex > 0) lines.push([]);
      if (part) lines[lines.length - 1].push({ type: token.type, content: part });
    });
  }
  return lines;
};

interface CodeBlockContextValue {
  code: string;
  language?: string;
  filename?: string;
  highlight: boolean;
  showLineNumbers: boolean;
  highlightLines: number[];
  addedLines: number[];
  removedLines: number[];
  wrap: boolean;
  maxHeight?: number;
  copyable: boolean;
  expanded: boolean;
  setExpanded: React.Dispatch<React.SetStateAction<boolean>>;
}

const CodeBlockContext = React.createContext<CodeBlockContextValue | null>(null);

const useCodeBlock = () => {
  const context = React.useContext(CodeBlockContext);
  if (!context) {
    throw new Error('useCodeBlock must be used within a <CodeBlock />');
  }
  return context;
};

export interface CodeBlockProps extends React.ComponentProps<'div'> {
  /** Raw code to display. A string child is accepted as an alternative. */
  code?: string;
  language?: string;
  filename?: string;
  /** Tokenize and color the code when the language has a built-in grammar. */
  highlight?: boolean;
  showLineNumbers?: boolean;
  highlightLines?: number[];
  addedLines?: number[];
  removedLines?: number[];
  wrap?: boolean;
  maxHeight?: number;
  copyable?: boolean;
}

const CodeBlock = ({
  code,
  language,
  filename,
  highlight = true,
  showLineNumbers = true,
  highlightLines = [],
  addedLines = [],
  removedLines = [],
  wrap = false,
  maxHeight,
  copyable = true,
  className,
  children,
  ...props
}: CodeBlockProps) => {
  const [expanded, setExpanded] = React.useState(false);

  const rawCode = (code ?? (typeof children === 'string' ? children : '')).replace(/\n$/, '');
  const hasCustomChildren = children != null && typeof children !== 'string';
  const showHeader = Boolean(filename || language || copyable);

  const contextValue = React.useMemo<CodeBlockContextValue>(
    () => ({
      code: rawCode,
      language,
      filename,
      highlight,
      showLineNumbers,
      highlightLines,
      addedLines,
      removedLines,
      wrap,
      maxHeight,
      copyable,
      expanded,
      setExpanded,
    }),
    [
      rawCode,
      language,
      filename,
      highlight,
      showLineNumbers,
      highlightLines,
      addedLines,
      removedLines,
      wrap,
      maxHeight,
      copyable,
      expanded,
    ],
  );

  return (
    <CodeBlockContext.Provider value={contextValue}>
      <div
        data-slot="code-block"
        className={cn('overflow-hidden rounded-md border border-border bg-card text-card-foreground', className)}
        {...props}
      >
        {hasCustomChildren ? (
          children
        ) : (
          <>
            {showHeader && <CodeBlockHeader />}
            <CodeBlockContent />
          </>
        )}
      </div>
    </CodeBlockContext.Provider>
  );
};

const CodeBlockHeader = ({ className, children, ...props }: React.ComponentProps<'div'>) => {
  const { code, language, filename, copyable } = useCodeBlock();

  return (
    <div
      data-slot="code-block-header"
      className={cn('flex min-h-10 items-center gap-2 border-b border-border px-3 py-1', className)}
      {...props}
    >
      {children ?? (
        <>
          {filename && (
            <span
              data-slot="code-block-filename"
              className="flex items-center gap-1.5 font-mono text-xs text-foreground"
            >
              <FileCode className="size-3.5 text-muted-foreground" aria-hidden />
              {filename}
            </span>
          )}
          {language && (
            <Badge data-slot="code-block-language" variant="outline">
              {language}
            </Badge>
          )}
          {copyable && <CopyButton value={code} size="sm" className="ms-auto" />}
        </>
      )}
    </div>
  );
};

const CodeBlockContent = ({ className, ...props }: React.ComponentProps<'div'>) => {
  const {
    code,
    language,
    highlight,
    showLineNumbers,
    highlightLines,
    addedLines,
    removedLines,
    wrap,
    maxHeight,
    expanded,
    setExpanded,
  } = useCodeBlock();
  const preRef = React.useRef<HTMLPreElement>(null);
  const [overflowing, setOverflowing] = React.useState(false);

  const lines = code.split('\n');
  const tokenLines = React.useMemo(() => (highlight ? tokenize(code, language) : null), [highlight, code, language]);
  const hasDiff = addedLines.length > 0 || removedLines.length > 0;
  const collapsible = maxHeight != null;
  const collapsed = collapsible && !expanded;

  React.useEffect(() => {
    if (maxHeight == null) return;
    const pre = preRef.current;
    if (!pre) return;
    const update = () => setOverflowing(pre.scrollHeight > maxHeight);
    update();
    const observer = new ResizeObserver(update);
    observer.observe(pre);
    return () => observer.disconnect();
  }, [maxHeight, code, wrap]);

  return (
    <div data-slot="code-block-content" className={cn('text-sm', className)} {...props}>
      <div
        data-slot="code-block-viewport"
        className={cn('relative', collapsed && 'overflow-hidden')}
        style={collapsed ? { maxHeight } : undefined}
      >
        <pre
          ref={preRef}
          dir="ltr"
          className={cn(
            'overflow-x-auto py-3 font-mono text-[13px] leading-6',
            wrap && 'whitespace-pre-wrap break-words',
          )}
        >
          <code data-slot="code-block-code" className="block w-fit min-w-full">
            {lines.map((line, index) => {
              const lineNumber = index + 1;
              const highlighted = highlightLines.includes(lineNumber);
              const added = addedLines.includes(lineNumber);
              const removed = removedLines.includes(lineNumber);

              return (
                <span
                  key={lineNumber}
                  data-slot="code-block-line"
                  data-line-number={lineNumber}
                  data-highlighted={highlighted || undefined}
                  data-diff={added ? 'added' : removed ? 'removed' : undefined}
                  className={cn(
                    'flex border-s-2 border-transparent pe-4',
                    showLineNumbers || hasDiff ? 'ps-2' : 'ps-4',
                    highlighted && 'border-primary bg-accent',
                    added && 'bg-primary/10',
                    removed && 'bg-destructive/10',
                  )}
                >
                  {showLineNumbers && (
                    <span
                      aria-hidden
                      data-slot="code-block-line-number"
                      data-line-number={lineNumber}
                      className="w-8 shrink-0 select-none pe-3 text-end text-muted-foreground before:content-[attr(data-line-number)]"
                    />
                  )}
                  {hasDiff && (
                    <span
                      aria-hidden
                      data-slot="code-block-diff-marker"
                      className={cn(
                        'w-4 shrink-0 select-none',
                        added && "text-primary before:content-['+']",
                        removed && "text-destructive before:content-['−']",
                      )}
                    />
                  )}
                  <span className="flex-1">
                    {line.length === 0
                      ? '​'
                      : (tokenLines?.[index]?.map((token, tokenIndex) =>
                          token.type === 'plain' ? (
                            token.content
                          ) : (
                            <span key={tokenIndex} data-token={token.type} className={TOKEN_CLASSES[token.type]}>
                              {token.content}
                            </span>
                          ),
                        ) ?? line)}
                  </span>
                </span>
              );
            })}
          </code>
        </pre>
        {collapsed && overflowing && (
          <div
            aria-hidden
            data-slot="code-block-fade"
            className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-linear-to-t from-card to-transparent"
          />
        )}
      </div>
      {collapsible && (overflowing || expanded) && (
        <div data-slot="code-block-expand" className="flex justify-center border-t border-border p-1">
          <Button
            type="button"
            variant="ghost"
            size="sm"
            aria-expanded={expanded}
            onClick={() => setExpanded((value) => !value)}
            className="h-7 gap-1 text-xs text-muted-foreground hover:text-foreground"
          >
            {expanded ? 'Show less' : 'Show more'}
            {expanded ? <ChevronUp aria-hidden /> : <ChevronDown aria-hidden />}
          </Button>
        </div>
      )}
    </div>
  );
};

export { CodeBlock, CodeBlockHeader, CodeBlockContent };

Dependencies

shadcn registry

badgebuttoncopy-button

npm

lucide-react