CodeBlock

PreviousNext

Syntax-highlighted codeblock component built on top of react-syntax-highlighter.

Docs
scrollxuicomponent

Preview

Loading preview…
components/ui/codeblock.tsx
"use client";
import React from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { atomDark } from "react-syntax-highlighter/dist/cjs/styles/prism";
import {
  Check,
  Copy,
  ChevronRight,
  File,
  Folder,
  Terminal,
  Code2,
  Download,
  Maximize2,
  Settings,
} from "lucide-react";

type CodeBlockProps = {
  language: string;
  filename: string;
  highlightLines?: number[];
  breadcrumb?: string[];
  showStats?: boolean;
  theme?: "dark" | "light";
} & (
  | {
      code: string;
      tabs?: never;
    }
  | {
      code?: never;
      tabs: Array<{
        name: string;
        code: string;
        language?: string;
        highlightLines?: number[];
      }>;
    }
);

export const CodeBlock = ({
  language,
  filename,
  code,
  highlightLines = [],
  tabs = [],
  breadcrumb = [],
  showStats = true,
  theme = "dark",
}: CodeBlockProps) => {
  const [copied, setCopied] = React.useState(false);
  const [activeTab, setActiveTab] = React.useState(0);
  const [isExpanded, setIsExpanded] = React.useState(false);

  const tabsExist = tabs.length > 0;

  const copyToClipboard = async () => {
    const textToCopy = tabsExist ? tabs[activeTab].code : code;
    if (textToCopy) {
      await navigator.clipboard.writeText(textToCopy);
      setCopied(true);
      setTimeout(() => setCopied(false), 2000);
    }
  };

  const downloadCode = () => {
    const textToDownload = tabsExist ? tabs[activeTab].code : code;
    const activeFilename = tabsExist ? tabs[activeTab].name : filename;
    if (textToDownload) {
      const blob = new Blob([textToDownload], { type: "text/plain" });
      const url = URL.createObjectURL(blob);
      const a = document.createElement("a");
      a.href = url;
      a.download = activeFilename;
      document.body.appendChild(a);
      a.click();
      document.body.removeChild(a);
      URL.revokeObjectURL(url);
    }
  };

  const activeCode = tabsExist ? tabs[activeTab].code : code;
  const activeLanguage = tabsExist
    ? tabs[activeTab].language || language
    : language;
  const activeHighlightLines = tabsExist
    ? tabs[activeTab].highlightLines || []
    : highlightLines;

  const getLanguageIcon = (lang: string) => {
    switch (lang.toLowerCase()) {
      case "javascript":
      case "jsx":
      case "typescript":
      case "tsx":
        return <Code2 size="1em" className="text-yellow-400" />;
      case "bash":
      case "shell":
        return <Terminal size="1em" className="text-green-400" />;
      default:
        return <File size="1em" className="text-blue-400" />;
    }
  };

  const getCodeStats = (code: string) => {
    const lines = code.split("\n").length;
    const chars = code.length;
    const words = code.split(/\s+/).filter((word) => word.length > 0).length;
    return { lines, chars, words };
  };

  const stats = showStats ? getCodeStats(activeCode || "") : null;

  return (
    <div
      className={`relative w-full rounded-xl overflow-hidden shadow-2xl ${
        theme === "dark" ? "bg-slate-900" : "bg-white"
      } border ${theme === "dark" ? "border-slate-700" : "border-gray-200"}`}
    >
      <div
        className={`flex items-stretch min-h-[3rem] ${
          theme === "dark"
            ? "bg-slate-800 border-b border-slate-700"
            : "bg-gray-50 border-b border-gray-200"
        }`}
      >
        <div className="flex-1 flex items-center min-w-0 px-3">
          <div className="flex gap-2 mr-3 shrink-0">
            <div className="w-3 h-3 rounded-full bg-red-500"></div>
            <div className="w-3 h-3 rounded-full bg-yellow-500"></div>
            <div className="w-3 h-3 rounded-full bg-green-500"></div>
          </div>

          {breadcrumb.length > 0 && (
            <div className="flex items-center min-w-0">
              <Folder
                size="1em"
                className={`shrink-0 ${
                  theme === "dark" ? "text-slate-400" : "text-gray-500"
                }`}
              />
              <div className="flex items-center min-w-0 ml-2">
                {breadcrumb.map((crumb, index) => (
                  <React.Fragment key={index}>
                    <span
                      className={`text-xs truncate ${
                        theme === "dark" ? "text-slate-400" : "text-gray-500"
                      }`}
                    >
                      {crumb}
                    </span>
                    {index < breadcrumb.length - 1 && (
                      <ChevronRight
                        size="0.75em"
                        className={`shrink-0 mx-1 ${
                          theme === "dark" ? "text-slate-500" : "text-gray-400"
                        }`}
                      />
                    )}
                  </React.Fragment>
                ))}
              </div>
            </div>
          )}
        </div>

        <div className="flex items-center justify-end shrink-0 px-2">
          {stats && (
            <div
              className={`text-xs mx-2 ${
                theme === "dark" ? "text-slate-400" : "text-gray-500"
              } truncate hidden md:block`}
            >
              {stats.lines}L • {stats.words}W
            </div>
          )}

          <div className="flex">
            <button
              onClick={() => setIsExpanded(!isExpanded)}
              className={`p-2 hover:${
                theme === "dark" ? "bg-slate-700" : "bg-gray-200"
              } transition-colors`}
              title="Toggle fullscreen"
            >
              <Maximize2
                size="1em"
                className={
                  theme === "dark" ? "text-slate-400" : "text-gray-500"
                }
              />
            </button>
            <button
              onClick={downloadCode}
              className={`p-2 hover:${
                theme === "dark" ? "bg-slate-700" : "bg-gray-200"
              } transition-colors`}
              title="Download code"
            >
              <Download
                size="1em"
                className={
                  theme === "dark" ? "text-slate-400" : "text-gray-500"
                }
              />
            </button>
            <button
              onClick={copyToClipboard}
              className={`p-2 hover:${
                theme === "dark" ? "bg-slate-700" : "bg-gray-200"
              } transition-colors`}
              title="Copy code"
            >
              {copied ? (
                <Check size="1em" className="text-green-400" />
              ) : (
                <Copy
                  size="1em"
                  className={
                    theme === "dark" ? "text-slate-400" : "text-gray-500"
                  }
                />
              )}
            </button>
          </div>
        </div>
      </div>

      {tabsExist && (
        <div
          className={`flex border-b ${
            theme === "dark"
              ? "border-slate-700 bg-slate-800"
              : "border-gray-200 bg-gray-50"
          } overflow-x-auto`}
        >
          {tabs.map((tab, index) => (
            <button
              key={index}
              onClick={() => setActiveTab(index)}
              className={`flex items-center gap-2 px-4 py-2 text-sm transition-all duration-200 border-b-2 shrink-0 ${
                activeTab === index
                  ? theme === "dark"
                    ? "text-white border-blue-400 bg-slate-900"
                    : "text-gray-900 border-blue-500 bg-white"
                  : theme === "dark"
                  ? "text-slate-400 border-transparent hover:text-slate-200 hover:bg-slate-700"
                  : "text-gray-600 border-transparent hover:text-gray-800 hover:bg-gray-100"
              }`}
            >
              {getLanguageIcon(tab.language || language)}
              <span className="truncate max-w-[10rem]">{tab.name}</span>
            </button>
          ))}
        </div>
      )}

      {!tabsExist && filename && (
        <div
          className={`flex items-center px-3 py-2 border-b ${
            theme === "dark"
              ? "border-slate-700 bg-slate-800"
              : "border-gray-200 bg-gray-50"
          }`}
        >
          <div className="flex items-center gap-2 min-w-0">
            {getLanguageIcon(language)}
            <span
              className={`text-sm font-medium truncate ${
                theme === "dark" ? "text-slate-200" : "text-gray-700"
              }`}
            >
              {filename}
            </span>
          </div>
        </div>
      )}

      <div
        className={`relative ${
          isExpanded ? "max-h-screen overflow-auto" : "max-h-96 overflow-auto"
        }`}
      >
        <SyntaxHighlighter
          language={activeLanguage}
          style={theme === "dark" ? atomDark : undefined}
          customStyle={{
            margin: 0,
            padding: "1rem",
            background: "transparent",
            fontSize: "0.875rem",
            lineHeight: "1.5",
          }}
          wrapLines={true}
          showLineNumbers={true}
          lineNumberStyle={{
            minWidth: "3em",
            paddingRight: "1em",
            color: theme === "dark" ? "#64748b" : "#9ca3af",
            borderRight: `1px solid ${
              theme === "dark" ? "#334155" : "#e5e7eb"
            }`,
            marginRight: "1em",
          }}
          lineProps={(lineNumber: number) => ({
            style: {
              backgroundColor: activeHighlightLines.includes(lineNumber)
                ? theme === "dark"
                  ? "rgba(59, 130, 246, 0.1)"
                  : "rgba(59, 130, 246, 0.05)"
                : "transparent",
              display: "block",
              width: "100%",
              borderLeft: activeHighlightLines.includes(lineNumber)
                ? "3px solid #3b82f6"
                : "3px solid transparent",
              paddingLeft: "0.5rem",
            },
          })}
          PreTag="div"
        >
          {String(activeCode)}
        </SyntaxHighlighter>
      </div>

      {showStats && stats && (
        <div
          className={`px-3 py-2 border-t text-xs ${
            theme === "dark"
              ? "border-slate-700 bg-slate-800 text-slate-400"
              : "border-gray-200 bg-gray-50 text-gray-500"
          } flex items-center justify-between min-h-[2.5rem]`}
        >
          <div className="flex items-center gap-3 min-w-0">
            <span className="truncate">{activeLanguage.toUpperCase()}</span>
            <span className="truncate hidden sm:inline">
              {stats.lines} lines
            </span>
            <span className="truncate hidden md:inline">
              {stats.chars} chars
            </span>
          </div>
          <div className="flex items-center gap-1 shrink-0">
            <Settings size="0.75em" />
            <span>UTF-8</span>
          </div>
        </div>
      )}
    </div>
  );
};

Installation

npx shadcn@latest add @scrollxui/codeblock

Usage

import { Codeblock } from "@/components/codeblock"
<Codeblock />