shadimage

Code Block

An AI code block with a file header, copy, and line collapse.

components/spectrumui/blocks/ai-assistants/code-block.tsx

'use client';

import { useEffect, useRef, useState } from 'react';
import { Check, ChevronDown, Copy, FileCode2 } from 'lucide-react';
import { cn } from '@/lib/utils';

export type CodeBlockVariant = 'Default' | 'Numbered';

export interface CodeBlockProps {
  code: string;
  filename?: string;
  language?: string;
  collapsedLines?: number;
  variant?: CodeBlockVariant;
  className?: string;
}

export function CodeBlock({
  code,
  filename,
  language = 'tsx',
  collapsedLines = 8,
  variant = 'Default',
  className,
}: CodeBlockProps) {
  const [copied, setCopied] = useState(false);
  const [expanded, setExpanded] = useState(false);
  const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  useEffect(() => () => clearTimeout(timer.current), []);

  const lines = code.replace(/\n$/, '').split('\n');
  const collapsible = lines.length > collapsedLines;
  const visible = expanded || !collapsible ? lines : lines.slice(0, collapsedLines);

  async function copy() {
    try {
      await navigator.clipboard.writeText(code);
    } catch {
      return;
    }
    setCopied(true);
    clearTimeout(timer.current);
    timer.current = setTimeout(() => setCopied(false), 1600);
  }

  return (
    <figure
      className={cn(
        'w-full max-w-[500px] overflow-hidden rounded-2xl border border-black/[0.07] bg-white shadow-xs dark:border-white/[0.08] dark:bg-[#0B0B0D]',
        className,
      )}
    >
      <div className="flex items-center justify-between gap-3 border-b border-black/[0.06] px-3 py-1.5 dark:border-white/[0.07]">
        <span className="flex min-w-0 items-center gap-1.5">
          <FileCode2 className="size-3.5 shrink-0 text-neutral-400 dark:text-neutral-500" />
          <span className="truncate font-mono text-[11px] text-neutral-500 dark:text-neutral-400">
            {filename ?? language}
          </span>
        </span>
        <button
          type="button"
          onClick={copy}
          aria-label={copied ? 'Code copied' : 'Copy code'}
          className="grid size-7 shrink-0 place-items-center rounded-md text-neutral-400 transition-[color,transform] duration-150 hover:text-neutral-700 active:scale-[0.94] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-neutral-400 dark:text-neutral-500 dark:hover:text-neutral-200"
        >
          <span className="relative grid size-3.5 place-items-center">
            <Copy
              className={cn(
                'absolute size-3.5 transition-[opacity,filter] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]',
                copied ? 'opacity-0 blur-[2px]' : 'opacity-100 blur-0',
              )}
            />
            <Check
              className={cn(
                'absolute size-3.5 text-emerald-600 transition-[opacity,filter] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] dark:text-emerald-400',
                copied ? 'opacity-100 blur-0' : 'opacity-0 blur-[2px]',
              )}
            />
          </span>
        </button>
      </div>

      <div className="overflow-x-auto py-2" tabIndex={0} aria-label={filename ?? 'Code'}>
        {visible.map((line, index) => (
          <div key={index} className="flex whitespace-nowrap px-3 font-mono text-[11.5px] leading-[1.8]">
            {variant === 'Numbered' && (
              <span
                aria-hidden
                className="mr-3 w-5 select-none text-right tabular-nums text-neutral-300 dark:text-neutral-700"
              >
                {index + 1}
              </span>
            )}
            <span className="text-neutral-700 dark:text-neutral-300">{line || ' '}</span>
          </div>
        ))}
      </div>

      {collapsible && (
        <button
          type="button"
          aria-expanded={expanded}
          onClick={() => setExpanded((value) => !value)}
          className="flex w-full items-center justify-center gap-1 border-t border-black/[0.06] py-1.5 font-mono text-[10.5px] text-neutral-400 transition-colors duration-150 hover:bg-black/[0.02] hover:text-neutral-700 dark:hover:bg-white/[0.03] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-neutral-400 dark:border-white/[0.07] dark:text-neutral-500 dark:hover:text-neutral-300"
        >
          {expanded ? 'collapse' : `${lines.length - collapsedLines} more lines`}
          <ChevronDown
            className={cn(
              'size-3 transition-transform duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]',
              expanded && 'rotate-180',
            )}
          />
        </button>
      )}
    </figure>
  );
}

export default CodeBlock;
Where it goes
navbar
Visual style
terminal-mono
Interaction
accordion

DependenciesBundle weight · light

icons
lucide-react