Sticky Header Code Blocks with Copy Button in Dark Theme: A Developer’s Guide
When building developer blogs or documentation sites, one of the little things that make a huge difference is well-styled code blocks. A common requirement is:
- Code blocks with scrollable content (both horizontal and vertical)
- A copy-to-clipboard button
- A language label
- Dark theme support
- Sticky header so the label and button remain visible while scrolling
Here’s how you can build this feature from scratch using plain HTML, CSS, and JavaScript.
The Problem
Many blog authors run into these issues:
- Long code blocks scroll horizontally, but the copy button disappears or scrolls out of view.
- Vertical scrolling hides the language label, making it unclear what language the code is in.
- Copy buttons or labels that use
position: stickyindividually often stack incorrectly, showing on multiple lines. - Scrollbars can conflict with animations or button overlays in a dark theme.
The solution is to use a sticky header container that houses both the language label and copy button.
HTML Structure
Each code block should be wrapped like this:
<div class="pre-wrapper">
<div class="pre-header">
<div class="language-label">JS</div>
<button class="copy-btn">📋 Copy</button>
</div>
<pre>
<code class="language-javascript">
// Your long code here
console.log("Hello World");
</code>
</pre>
</div>.pre-wrapperis the scrollable container..pre-headeris sticky and holds both the label and button.<pre>contains your scrollable code.
CSS Styling
Here’s the CSS that handles sticky headers, dark theme, and smooth copy button animations:
.pre-wrapper {
position: relative;
margin: 1.5rem 0;
background: #161718;
border-radius: 8px;
max-height: 400px; /* scrollable height */
overflow: auto; /* vertical + horizontal scroll */
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.pre-header {
position: sticky;
top: 0;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5em 1em;
background: #161718;
z-index: 10;
}
.language-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: #fff;
background: #343a40;
padding: 3px 10px;
border-radius: 4px;
letter-spacing: 0.5px;
}
.copy-btn {
padding: 6px 12px;
font-size: 0.85rem;
border-radius: 6px;
background: rgba(33,34,34,0.95);
color: #fff;
cursor: pointer;
border: none;
opacity: 0;
transform: translateX(20px);
transition: all 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.pre-header:hover .copy-btn {
opacity: 1;
transform: translateX(0);
}
.pre-wrapper pre {
margin: 0;
padding: 1em;
background: #0e0e0e;
border-radius: 6px;
white-space: pre;
font-family: 'Fira Code', monospace;
font-size: 14px;
overflow: auto;
}
/* Dark-themed scrollbars */
.pre-wrapper pre::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.pre-wrapper pre::-webkit-scrollbar-thumb {
background-color: rgba(255,255,255,0.2);
border-radius: 4px;
}
.pre-wrapper pre::-webkit-scrollbar-thumb:hover {
background-color: rgba(255,255,255,0.4);
}
.pre-wrapper pre::-webkit-scrollbar-track {
background: transparent;
}
.pre-wrapper pre {
scrollbar-width: thin;
scrollbar-color: rgba(255,255,255,0.2) transparent;
}
JavaScript for Copy Button
We also want the copy button to work dynamically on all <pre> blocks, including those added after page load:
function wrapCodeBlocksWithStickyHeaderSlide() {
document.querySelectorAll('pre:not(.has-wrapper)').forEach(pre => {
pre.classList.add('has-wrapper');
let language = 'Code';
const code = pre.querySelector('code');
if (code) {
const langClass = Array.from(code.classList).find(cls =>
cls.startsWith('language-')
);
if (langClass) language = langClass.replace('language-', '').toUpperCase();
}
const wrapper = document.createElement('div');
wrapper.className = 'pre-wrapper';
const header = document.createElement('div');
header.className = 'pre-header';
const langLabel = document.createElement('div');
langLabel.className = 'language-label';
langLabel.textContent = language;
const copyBtn = document.createElement('button');
copyBtn.className = 'copy-btn';
copyBtn.textContent = '📋 Copy';
copyBtn.title = 'Copy to clipboard';
copyBtn.addEventListener('click', async () => {
try {
const codeText = code.innerText;
await navigator.clipboard.writeText(codeText);
const original = copyBtn.textContent;
copyBtn.textContent = '✅ Copied!';
setTimeout(() => copyBtn.textContent = original, 2000);
} catch {
copyBtn.textContent = '❌ Failed';
setTimeout(() => copyBtn.textContent = '📋 Copy', 1500);
}
});
header.appendChild(langLabel);
header.appendChild(copyBtn);
pre.parentNode.insertBefore(wrapper, pre);
wrapper.appendChild(header);
wrapper.appendChild(pre);
});
}
document.addEventListener('DOMContentLoaded', wrapCodeBlocksWithStickyHeaderSlide);
const observer = new MutationObserver(wrapCodeBlocksWithStickyHeaderSlide);
observer.observe(document.body, { childList: true, subtree: true });
Features
- Sticky header: Both the language label and copy button remain visible on long scrollable code.
- Slide-in copy button on hover for a smooth effect.
- Dark theme support with subtle, thin scrollbars.
- Works with long horizontal and vertical code blocks.
- Automatically wraps all
<pre>elements, including dynamically added ones.
Final Thoughts
Using a sticky header for code blocks:
- Improves usability: users always know the language and can copy code easily.
- Makes your developer blog feel modern, like GitHub or Dev.to.
- Keeps your dark theme clean with thin scrollbars and subtle animations.
This small addition adds professional polish to any technical site or documentation page.