Code Block

A component for displaying code blocks.

ShikiSymbolsMotion
Components
07/31/2025
Preview

JavaScript

const x = "Hello world";

Introduction

What is Shiki?

Shiki is a beautiful and powerful syntax highlighter based on TextMate grammar and themes, the same engine as VS Code's syntax highlighting. It supports a wide range of languages and themes, making it a great choice for displaying code snippets in your documentation.

What will you learn?

In this guide, you will learn how to use the Code Block component to display code snippets in client side and MDX files. You will also learn how to customize the syntax highlighting using Shiki themes and how to use the motion library for simple animations.

Installation

Client Component:

npx shadcn@latest add https://docs-kit.pheralb.dev/r/codeblock-client

MDX Component:

npx shadcn@latest add https://docs-kit.pheralb.dev/r/codeblock-mdx

Create the highlighter

Client and MDX components uses the shikiHighlighter to display code with syntax highlighting. To create the highlighter, you need to follow these steps:

1

Install the following dependencies:

npm i shiki @shikijs/themes @shikijs/langs
2

Create a `shiki.ts` file in the `src/utils` directory with the following content:

TypeScript

import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
import {
  type HighlighterCore,
  type RegexEngine,
  createHighlighterCore,
} from "shiki/core";

// Themes:
import githubLight from "@shikijs/themes/github-light";
import githubDark from "@shikijs/themes/github-dark";

// Languages:
import js from "@shikijs/langs/js";
import ts from "@shikijs/langs/ts";
import css from "@shikijs/langs/css";
import tsx from "@shikijs/langs/tsx";
import bash from "@shikijs/langs/bash";
import markdown from "@shikijs/langs/markdown";

let jsEngine: RegexEngine | null = null;
let highlighter: Promise<HighlighterCore> | null = null;

const getJsEngine = (): RegexEngine => {
  jsEngine ??= createJavaScriptRegexEngine();
  return jsEngine;
};

const shikiHighlighter = async (): Promise<HighlighterCore> => {
  highlighter ??= createHighlighterCore({
    themes: [githubLight, githubDark],
    langs: [bash, js, ts, tsx, css, markdown],
    engine: getJsEngine(),
  });
  return highlighter;
};

export { shikiHighlighter };

You can import the themes and languages you need from @shikijs/themes and @shikijs/langs.

3

Create a `shiki.css` file in the `src/styles` directory with the following content:

CSS

/* Shiki light/dark mode */
html.dark .shiki,
html.dark .shiki span {
  color: var(--shiki-dark) !important;
  background-color: transparent !important;
}

/* Shiki Line Numbers */
code {
  counter-reset: step;
  counter-increment: step 0;
}

code:has(.line:nth-child(2)) .line::before {
  content: counter(step);
  counter-increment: step;
  width: 1rem;
  margin-right: 1.5rem;
  display: inline-block;
  text-align: right;
}

html.light code:has(.line:nth-child(2)) .line::before {
  color: #a0a0a0;
}

html.dark code:has(.line:nth-child(2)) .line::before {
  color: #525252;
}

This file contains the light/dark mode and line number styles for the Shiki highlighter.

Language Icons

The Code Block component uses language icons from the react-symbols library. To use the icons, you need to follow these steps:

1

Install the following dependencies:

npm i @react-symbols/icons
2

Create a `languages.tsx` file in the `src/utils` directory with the following content:

React

import type { JSX } from "react";

import {
  Js,
  BracketsGreen,
  Document,
  TypeScript,
  BracketsBlue,
  BracketsYellow,
  Reactjs,
  Markdown,
} from "@react-symbols/icons";

const iconSize = 20;

export interface IconsData {
  lang?: string;
  name: string;
  icon: JSX.Element;
}

export const icons: IconsData[] = [
  {
    name: "Document",
    icon: <Document width={iconSize} height={iconSize} />,
  },
  {
    lang: "tsx",
    name: "React",
    icon: <Reactjs width={iconSize} height={iconSize} />,
  },
  {
    lang: "ts",
    name: "TypeScript",
    icon: <TypeScript width={iconSize} height={iconSize} />,
  },
  {
    lang: "js",
    name: "JavaScript",
    icon: <Js width={iconSize} height={iconSize} />,
  },
  {
    lang: "bash",
    name: "Terminal",
    icon: <BracketsGreen width={iconSize} height={iconSize} />,
  },
  {
    lang: "css",
    name: "CSS",
    icon: <BracketsBlue width={iconSize} height={iconSize} />,
  },
  {
    lang: "json",
    name: "JSON",
    icon: <BracketsYellow width={iconSize} height={iconSize} />,
  },
  {
    lang: "md",
    name: "Markdown",
    icon: <Markdown width={iconSize} height={iconSize} />,
  },
];

export const getIcon = (lang: string): IconsData => {
  const icon = icons.find((icon) => icon.lang === lang);
  return icon ?? icons[0];
};

Copy to clipboard component

All the code block components have a copy to clipboard button. To create this component, run the following command or create the file manually:

1

Install the following dependencies:

npm i motion
2

Create a `copyToClipboard.tsx` file in the `src/components/ui` directory with the following content:

React

import type { FC, SVGProps } from "react";
import type { Variants } from "motion/react";

import { cn } from "@/utils/cn";
import { useCallback, useRef, useState } from "react";
import { AnimatePresence, motion } from "motion/react";

interface CopyButtonIconProps {
  isAnimating: boolean;
}

interface CopyButtonProps {
  text: string;
  className?: string;
  label?: string;
}

const CopyButtonStyles = cn(
  "cursor-pointer",
  "text-neutral-600 dark:text-neutral-400",
  "hover:text-black dark:hover:text-white",
  "transition-colors",
);

// Settings:
const COPY_ANIMATION_DURATION = 2000;
const ICON_SIZE = 14;

const CopyIcon: FC<SVGProps<SVGElement>> = () => (
  <svg
    xmlns="http://www.w3.org/2000/svg"
    width={ICON_SIZE}
    height={ICON_SIZE}
    fill="none"
    stroke="currentColor"
    strokeLinecap="round"
    strokeLinejoin="round"
    strokeWidth="2"
    viewBox="0 0 24 24"
  >
    <rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect>
    <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path>
  </svg>
);

const CheckIcon: FC<SVGProps<SVGElement>> = () => (
  <svg
    xmlns="http://www.w3.org/2000/svg"
    width={ICON_SIZE}
    height={ICON_SIZE}
    fill="none"
    stroke="currentColor"
    strokeLinecap="round"
    strokeLinejoin="round"
    strokeWidth="2"
    viewBox="0 0 24 24"
  >
    <path d="M20 6 9 17l-5-5"></path>
  </svg>
);

const variants: Variants = {
  visible: {
    opacity: 1,
    scale: 1,
    transition: { duration: 0.2 },
  },
  hidden: {
    opacity: 0,
    scale: 0.8,
    transition: { duration: 0.1 },
  },
};

const CopyButtonIcon = ({ isAnimating }: CopyButtonIconProps) => {
  return (
    <AnimatePresence mode="wait">
      {isAnimating ? (
        <motion.div
          animate="visible"
          exit="hidden"
          initial="hidden"
          key="copied"
          variants={variants}
        >
          <CheckIcon />
        </motion.div>
      ) : (
        <motion.div
          animate="visible"
          exit="hidden"
          initial="hidden"
          key="copy"
          variants={variants}
        >
          <CopyIcon />
        </motion.div>
      )}
    </AnimatePresence>
  );
};

const CopyButton = ({ text, label, className }: CopyButtonProps) => {
  const timeout = useRef<number>(0);
  const [isAnimating, setIsAnimating] = useState<boolean>(false);

  const copyToClipboard = useCallback(async (text: string) => {
    window.clearTimeout(timeout.current);
    await copyToClipboard(text);
  }, []);

  const handleCopy = useCallback(() => {
    void copyToClipboard(text);
    setIsAnimating(true);
    setTimeout(() => {
      setIsAnimating(false);
    }, COPY_ANIMATION_DURATION);
  }, [copyToClipboard, text]);

  return (
    <button
      title={label}
      aria-label={label}
      className={cn(CopyButtonStyles, className)}
      onClick={handleCopy}
    >
      <CopyButtonIcon isAnimating={isAnimating} />
    </button>
  );
};

export { CopyButton };

Code Block Container

A simple container to wrap the code block. It's used to disable prose styles (prose class from tailwindcss/typography) and add some margin and border styles.

React

import type { ReactNode } from "react";
import { cn } from "@/utils/cn";

interface CodeblockContainerProps {
  children: ReactNode;
}

const CodeblockContainer = ({ children }: CodeblockContainerProps) => {
  return (
    <div
      className={cn(
        "not-prose my-2",
        "group relative",
        "overflow-hidden rounded-md border border-neutral-200 dark:border-neutral-800",
      )}
    >
      {children}
    </div>
  );
};

export { CodeblockContainer };

Client Component

Create a codeblock-client.tsx file in the src/components/ui/codeblock directory with the following content:

React

import { useEffect, useState, type HTMLProps, type ReactNode } from "react";

import { CopyButton } from "@/registry/components/copyButton";
import { CodeblockContainer } from "@/registry/components/codeblock/codeblock-container";

import { cn } from "@/utils/cn";
import { getIcon } from "@/utils/languages";
import { shikiHighlighter } from "@/utils/shiki";

export type CodeBlockCodeProps = {
  code: string;
  children?: ReactNode;
  icon?: ReactNode;
  language?: string;
  className?: string;
  title?: string;
} & HTMLProps<HTMLDivElement>;

const CodeblockClient = ({
  code,
  language = "tsx",
  title,
  children,
  icon,
  ...props
}: CodeBlockCodeProps) => {
  const [highlightedHtml, setHighlightedHtml] = useState<string | null>(null);

  useEffect(() => {
    async function highlight() {
      if (!code) {
        setHighlightedHtml("<pre><code></code></pre>");
        return;
      }
      const html = (await shikiHighlighter()).codeToHtml(code, {
        lang: language,
        themes: {
          light: "github-light",
          dark: "github-dark",
        },
        transformers: [
          {
            name: "DeleteDefaultStyles",
            pre(node) {
              // Remove default styles from the <pre> tag
              node.properties.style = "";
            },
          },
        ],
      });
      setHighlightedHtml(html);
    }
    void highlight();
  }, [code, language]);

  return (
    <CodeblockContainer>
      <div
        className={cn(
          "flex items-center justify-between text-sm",
          "px-2.5 py-1.5",
          "bg-neutral-200/30 dark:bg-neutral-800/30",
          "border-b border-neutral-200 dark:border-neutral-800",
        )}
      >
        <div className="flex items-center space-x-2">
          {icon ?? getIcon(language).icon}
          {title && <p>{title}</p>}
          {children}
        </div>
        <CopyButton
          className="opacity-0 transition-opacity group-hover:opacity-100"
          label="Copy to clipboard"
          text={code}
        />
      </div>
      <div className="not-prose max-h-80 overflow-y-auto px-3.5 py-3 text-sm">
        {highlightedHtml ? (
          <div
            dangerouslySetInnerHTML={{ __html: highlightedHtml }}
            {...props}
          />
        ) : (
          <pre>
            <code>{code}</code>
          </pre>
        )}
      </div>
    </CodeblockContainer>
  );
};

export { CodeblockClient };

Usage

React

import { CodeblockClient } from "@/components/ui/codeblock/codeblock-client";

const Example = () => {
  return (
    <CodeblockClient code={`const x = "Hello world";`} language="javascript" />
  );
};

Props

PropTypeRequired
codestringYes
iconReactNodeNo
languagestringNo
classNamestringNo
titlestringNo
Included: Attributes<HTMLDivElement>

MDX Component

1

Install the following dependencies:

npm i @shikijs/transformers @shikijs/rehype -D
2

Create a `rehype-shiki.ts` file in the `src/utils` directory with the following content:

TypeScript

import type { RehypeShikiOptions } from "@shikijs/rehype";

// Transformers:
import { transformerNotationHighlight } from "@shikijs/transformers";

export const rehypeShikiOptions = {
  themes: {
    light: "github-light",
    dark: "github-dark",
  },
  transformers: [
    transformerNotationHighlight(),
    {
      name: "AddPreProperties",
      pre(node) {
        node.properties["data-language"] = this.options.lang || "plaintext";
      },
    },
    {
      name: "WordWrap",
      pre(node) {
        node.properties.style = "white-space: pre-wrap;";
      },
    },
  ],
} satisfies RehypeShikiOptions;

data-language is used to set the language of the code block, and white-space: pre-wrap; is used to enable word wrapping in the code block.

3

Create a `codeblock-mdx.tsx` file in the `src/components/ui/codeblock` directory with the following content:

React

import type { ComponentProps, ReactElement, ReactNode } from "react";

import { cn } from "@/utils/cn";
import { getIcon } from "@/utils/languages";

import { CopyButton } from "@/registry/components/copyButton";
import { CodeblockContainer } from "@/registry/components/codeblock/codeblock-container";

export type CodeblockMDXProps = {
  ["data-language"]?: string;
  title?: string;
} & ComponentProps<"code">;

const CodeblockMDX = ({
  children,
  ["data-language"]: dataLanguage = "",
  className,
}: CodeblockMDXProps) => {
  // Get the text content from children, handling strings, arrays, and React elements:
  const getTextContent = (children: ReactNode): string => {
    if (typeof children === "string") {
      return children;
    } else if (Array.isArray(children)) {
      return children.map(getTextContent).join("");
    } else if (
      children &&
      typeof children === "object" &&
      "props" in children
    ) {
      const childElement = children as ReactElement<{ children?: ReactNode }>;
      return getTextContent(childElement.props.children);
    }
    return "";
  };

  const content = getTextContent(children);

  return (
    <CodeblockContainer>
      <div
        className={cn(
          "flex items-center justify-between text-sm",
          "px-2.5 py-1.5",
          "bg-neutral-200/30 dark:bg-neutral-800/30",
          "border-b border-neutral-200 dark:border-neutral-800",
        )}
      >
        <div className="flex items-center space-x-2">
          {getIcon(dataLanguage).icon}
          <p className="text-neutral-600 dark:text-neutral-400">
            {getIcon(dataLanguage).name}
          </p>
        </div>
        <CopyButton
          className="opacity-0 transition-opacity group-hover:opacity-100"
          label="Copy to clipboard"
          text={content}
        />
      </div>
      <div className="not-prose max-h-80 overflow-y-auto px-2.5 py-3">
        <pre className={cn(className)}>{children}</pre>
      </div>
    </CodeblockContainer>
  );
};

export { CodeblockMDX };

Props

PropTypeRequired
data-languagestringNo
titlestringNo
Included: DOMAttributes<HTMLDivElement>