Building Great Code Blocks for Your Personal Site

As a developer writing about code on my personal site, having beautiful, functional code blocks was a must-have. This guide explores how I built a modern code block system using React Server Components and client-side interactivity.

The Goal

I wanted code blocks that:

  1. Look beautiful and match my site's theme in both light and dark modes
  2. Render quickly with server-side support for optimal performance
  3. Support interactive features like copy buttons and line highlighting
  4. Adapt to theme changes in real-time
  5. Maintain excellent user experience and accessibility
  6. Work well on all devices, including mobile

Architecture Overview

The system uses a hybrid architecture with these main components:

  1. MDX Processing: Using rehype-pretty-code for syntax highlighting
  2. Client Component: A client-side CodeBlock component that handles interactivity
  3. CSS Variables: Leveraging CSS variables for applying syntax highlighting styles

This approach offers good performance while enabling interactive features like copy buttons and scroll indicators.

Implementation Details

Client Component (CodeBlock.tsx)

The client component handles rendering and interactivity:

"use client";
 
import React, { useEffect, useRef, useState } from "react";
import Icon from "../../../../components/ui/Icon";
import { Button } from "../../../../components/ui/Button";
import { cn } from "../../../../lib/client-utils";
 
interface CodeBlockProps {
  children: React.ReactNode;
  language?: string;
  showLineNumbers?: boolean;
  showCopy?: boolean;
  meta?: string;
}
 
export default function CodeBlock(props: CodeBlockProps) {
  const {
    children,
    language,
    showLineNumbers = true,
    showCopy = true,
    meta,
  } = props;
  const containerRef = useRef<HTMLDivElement>(null);
  const preRef = useRef<HTMLPreElement | null>(null);
  const [copied, setCopied] = useState(false);
  const [lang, setLang] = useState("");
  const [showLeftFade, setShowLeftFade] = useState(false);
  const [showRightFade, setShowRightFade] = useState(false);
 
  // Check meta for line numbers
  const hasLineNumbers =
    showLineNumbers || (meta && meta.includes("showLineNumbers"));
 
  // Handle scroll to update fade indicators
  const handleScroll = () => {
    if (preRef.current) {
      const { scrollLeft, scrollWidth, clientWidth } = preRef.current;
      setShowLeftFade(scrollLeft > 5);
      setShowRightFade(scrollLeft < scrollWidth - clientWidth - 5);
    }
  };
 
  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;
 
    const figure =
      container.querySelector("[data-rehype-pretty-code-figure]") ||
      container.querySelector("figure");
    if (!figure) return;
 
    const pre = figure.querySelector("pre");
    if (!pre) return;
 
    // Store pre element for scroll handling
    preRef.current = pre;
 
    // Get language
    const detectedLang = pre.getAttribute("data-language") || language || "";
    setLang(detectedLang);
 
    // Add line numbers if needed
    if (hasLineNumbers && pre) {
      pre.setAttribute("data-line-numbers", "");
 
      // Apply line numbers to all line elements
      const lines = pre.querySelectorAll("[data-line]");
      lines.forEach((line) => {
        line.classList.add("numbered-line");
      });
    }
 
    // Check initial scroll state
    handleScroll();
 
    // Check if horizontal scroll is needed
    setShowRightFade(pre.scrollWidth > pre.clientWidth);
 
    // Add scroll event listener
    pre.addEventListener("scroll", handleScroll);
 
    // Handle resize events to update indicator visibility
    window.addEventListener("resize", handleScroll);
 
    return () => {
      pre.removeEventListener("scroll", handleScroll);
      window.removeEventListener("resize", handleScroll);
    };
  }, [language, hasLineNumbers, meta]);
 
  // Handle copy functionality
  const handleCopy = () => {
    const pre = containerRef.current?.querySelector("pre");
    if (!pre) return;
 
    const code = Array.from(pre.querySelectorAll("[data-line]"))
      .map((line) => line.textContent)
      .join("\n");
 
    navigator.clipboard.writeText(code);
    setCopied(true);
 
    // Reset after 2 seconds
    setTimeout(() => {
      setCopied(false);
    }, 2000);
  };
 
  return (
    <div className="code-block" ref={containerRef}>
      <div className="code-block-header">
        {lang && <span className="code-block-language">{lang}</span>}
        {showCopy && (
          <Button
            variant="ghost"
            size="sm"
            className="code-block-copy p-1 h-auto min-h-0"
            aria-label="Copy to clipboard"
            onClick={handleCopy}
          >
            <Icon
              name={copied ? "check" : "copy"}
              size={16}
              className={copied ? "text-green-500" : ""}
            />
          </Button>
        )}
      </div>
 
      {/* Left fade indicator */}
      <div className={cn("fade-left", showLeftFade ? "fade-visible" : "")} />
 
      {/* Right fade indicator */}
      <div className={cn("fade-right", showRightFade ? "fade-visible" : "")} />
 
      {children}
    </div>
  );
}

MDX Integration

The code blocks are connected to MDX files through the MDXComponents.tsx file:

// Inside MDXComponents.tsx
// Handling the figure element from rehype-pretty-code
figure: (props) => {
  // Check if this is a code block figure
  const isPrettyCodeFigure =
    props["data-rehype-pretty-code-figure"] !== undefined;
 
  if (isPrettyCodeFigure) {
    // Extract pre element to get language and other attributes
    const preElement = React.Children.toArray(props.children).find(
      (child) => React.isValidElement(child) && child.type === "pre",
    ) as React.ReactElement | undefined;
 
    const showLineNumbers =
      preElement?.props["data-line-numbers"] !== undefined ||
      props["data-meta"]?.includes("showLineNumbers");
    const language = preElement?.props["data-language"] || "";
    const meta = preElement?.props["data-meta"] || props["data-meta"] || "";
 
    // Simply wrap the figure with our CodeBlock component
    return (
      <CodeBlock
        language={language}
        showLineNumbers={showLineNumbers}
        showCopy={true}
        meta={meta}
      >
        <figure {...props} />
      </CodeBlock>
    );
  }
 
  return <figure {...props} />;
}

The Evolution of Our Code Block Implementation

Our journey to create perfect code blocks had several interesting challenges. Here's what we learned along the way:

The Challenge of Configuration Duplication

One significant issue we encountered was having duplicate configuration for MDX processing:

// next.config.js - REMOVED THIS CONFIGURATION
const withMDX = require("@next/mdx")({
  extension: /\.mdx?$/,
  options: {
    rehypePlugins: [
      [
        require("rehype-pretty-code"),
        {
          theme: { ... },
          keepBackground: true,
          // Other options...
        }
      ]
    ]
  }
});

While simultaneously having similar configuration in our page component:

// In page.tsx
const { content: mdxContent } = await compileMDX({
  source: post.content,
  components: MDXComponents,
  options: {
    mdxOptions: {
      rehypePlugins: [
        [
          rehypePrettyCode,
          {
            theme: { ... },
            keepBackground: false, // Conflicting setting!
            // Other options...
          }
        ]
      ]
    }
  }
});

This duplication led to inconsistent syntax highlighting because the configurations were conflicting. The solution was to remove the duplicate configuration from next.config.js and maintain a single source of truth in our page component.

The CSS Variable Challenge

A key challenge was getting the syntax highlighting colors to apply correctly. Rehype-pretty-code generates inline style attributes with CSS variables:

<span style="--shiki-light:#D73A49;--shiki-dark:#F97583">import</span>

But we needed to add CSS rules to actually apply these variables to the text color:

/* Apply inline syntax highlighting variables */
.code-block [data-line] span {
  color: var(--shiki-light);
}
 
:root.dark .code-block [data-line] span {
  color: var(--shiki-dark);
}

This approach solved the problem elegantly by using the theme-specific variables in each mode.

CSS Organization

Our CSS for code blocks is well-organized in a dedicated file:

/* Code Blocks */
.code-block {
  position: relative;
  margin: 1.5rem 0;
  border-radius: 0.5rem;
  overflow: hidden;
  background: var(--mono2);
}
 
.code-block-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0.5rem 1rem;
  background: var(--mono3);
  border-bottom: 1px solid var(--mono4);
}
 
/* rehype-pretty-code specific styling */
.code-block [data-rehype-pretty-code-fragment] {
  overflow: hidden;
}
 
.code-block pre {
  overflow-x: auto;
  font-size: 0.875rem;
  line-height: 1.5;
}
 
/* Apply inline syntax highlighting variables */
.code-block [data-line] span {
  color: var(--shiki-light);
}
 
:root.dark .code-block [data-line] span {
  color: var(--shiki-dark);
}
 
/* Theme visibility based on document theme */
:root:not(.dark) .code-block [data-theme="dark"] {
  display: none;
}
 
:root.dark .code-block [data-theme="light"] {
  display: none;
}

This organization makes our code much easier to maintain and understand.

Horizontal Scrolling with Visual Indicators

A nice UX touch we added was scroll fade indicators that visually show when there's more content to the left or right:

const handleScroll = () => {
  if (preRef.current) {
    const { scrollLeft, scrollWidth, clientWidth } = preRef.current;
    setShowLeftFade(scrollLeft > 5);
    setShowRightFade(scrollLeft < scrollWidth - clientWidth - 5);
  }
};

The CSS for these indicators creates a subtle gradient effect:

.fade-left,
.fade-right {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 2rem;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.2s ease-in-out;
}
 
.fade-left {
  left: 0;
  background: linear-gradient(to right, var(--mono2) 0%, transparent 100%);
}
 
.fade-right {
  right: 0;
  background: linear-gradient(to left, var(--mono2) 0%, transparent 100%);
}
 
.fade-visible {
  opacity: 1;
}

Accessibility and Performance

Throughout our implementation, we maintained a focus on accessibility and performance:

Accessibility Features

<Button
  variant="ghost"
  size="sm"
  className="code-block-copy p-1 h-auto min-h-0"
  aria-label="Copy to clipboard" // Proper accessibility label
  onClick={handleCopy}
>
  <Icon
    name={copied ? "check" : "copy"}
    size={16}
    className={copied ? "text-green-500" : ""}
  />
</Button>

Performance Considerations

By keeping our implementation lean and focused, we maintained good performance:

  • Single source of truth for rehype-pretty-code configuration
  • Clean CSS with minimal specificity conflicts
  • Efficient DOM manipulation
  • Only essential JavaScript for interactivity

Usage Guide

Basic Usage

To create a basic code block in an MDX file:

function example() {
  console.log('Hello world!');
}

With Line Numbers

Add the showLineNumbers modifier:

function example() {
  console.log('Hello world!');
}

With Line Highlighting

Use the curly braces syntax to highlight specific lines:

function example() {
  console.log('This line is highlighted!');
  return true;
}

Lessons Learned

Our code block implementation journey taught us several important lessons:

  1. Single Source of Truth: Having duplicate configurations for the same functionality leads to bugs. Always maintain a single source of truth.

  2. Understanding the Full Stack: From MDX processing to CSS variables to client-side interactivity, each layer needs to work together seamlessly.

  3. CSS Variables are Powerful: The rehype-pretty-code inline variables approach is elegant but requires proper CSS to actually apply the colors.

  4. Progressive Enhancement Works: Our code blocks work even without JavaScript, but provide additional features when it's available.

  5. Small Details Matter: Features like scroll indicators and proper line numbers greatly improve the user experience.

Technical Implementation Tips

Here are some specific technical insights from our implementation:

1. Handling Line Numbers

// Add line numbers if needed
if (hasLineNumbers && pre) {
  pre.setAttribute("data-line-numbers", "");
 
  // Apply line numbers to all line elements
  const lines = pre.querySelectorAll("[data-line]");
  lines.forEach((line) => {
    line.classList.add("numbered-line");
  });
}

2. CSS for Line Numbers

.code-block [data-line].numbered-line::before {
  counter-increment: line;
  content: counter(line);
  display: inline-block;
  width: 1.5rem;
  margin-right: 1.25rem;
  text-align: right;
  color: var(--mono9);
}

3. Copying Code Text

const handleCopy = () => {
  const pre = containerRef.current?.querySelector("pre");
  if (!pre) return;
 
  const code = Array.from(pre.querySelectorAll("[data-line]"))
    .map((line) => line.textContent)
    .join("\n");
 
  navigator.clipboard.writeText(code);
  setCopied(true);
 
  // Reset after 2 seconds
  setTimeout(() => {
    setCopied(false);
  }, 2000);
};

Conclusion

Building great code blocks for a developer-focused site requires attention to detail, but the results are worth it. By focusing on user experience, accessibility, and performance, we've created code blocks that:

  1. Look beautiful in both light and dark modes
  2. Work seamlessly on desktop and mobile devices
  3. Provide helpful features like copy buttons and scroll indicators
  4. Maintain excellent performance and accessibility
  5. Are easy to maintain and extend

The journey wasn't always straightforward, but each challenge we solved improved the overall experience for our readers. The key was understanding how each piece of the stack (MDX processing, CSS variables, and client-side interactivity) works together to create a cohesive experience.

Correspondence

Get notified when I write. Feel free to reply directly.