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:
- Look beautiful and match my site's theme in both light and dark modes
- Render quickly with server-side support for optimal performance
- Support interactive features like copy buttons and line highlighting
- Adapt to theme changes in real-time
- Maintain excellent user experience and accessibility
- Work well on all devices, including mobile
Architecture Overview
The system uses a hybrid architecture with these main components:
- MDX Processing: Using
rehype-pretty-code
for syntax highlighting - Client Component: A client-side
CodeBlock
component that handles interactivity - 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:
-
Single Source of Truth: Having duplicate configurations for the same functionality leads to bugs. Always maintain a single source of truth.
-
Understanding the Full Stack: From MDX processing to CSS variables to client-side interactivity, each layer needs to work together seamlessly.
-
CSS Variables are Powerful: The rehype-pretty-code inline variables approach is elegant but requires proper CSS to actually apply the colors.
-
Progressive Enhancement Works: Our code blocks work even without JavaScript, but provide additional features when it's available.
-
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:
- Look beautiful in both light and dark modes
- Work seamlessly on desktop and mobile devices
- Provide helpful features like copy buttons and scroll indicators
- Maintain excellent performance and accessibility
- 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.