GODRICH

9 Code Block UI Patterns — No Highlighter Library

A code block UI is the small frame built around a snippet — header, copy button, tabs — that tells a reader what they are looking at before they touch a single

Auto-plays · click a tile to jump to its section · all nine in one zip

None of the nine below need an extra library; every color you see comes from plain classes in the markup, with nothing to import. The order follows how a snippet actually gets used on a page rather than how flashy each pattern looks. First it has to be copyable in one click (01), then show the same example in more than one syntax (02). Once a reader is inside the block, attention narrows to one exact line (03) or to what changed between two versions (04). A block that runs long needs a way to fold away (05), and a page with several files needs a header that says which one is open right now (06). Beyond a static snippet, a command can type and answer itself (07), a result can sit right next to the code that drew it (08), and a single unfamiliar word inside a paragraph can explain itself the moment someone points at it (09).

01Copy button that says it copied

Pressing the button in the top-right corner hands the snippet to navigator.clipboard and swaps the label in a single frame, with no fade in between. A hidden aria-live region repeats the new label out loud, so someone using a screen reader hears that the copy went through, too.

navigator.clipboardaria-livesteps(1, end)
btn.addEventListener('click', function () {
  root.classList.remove('is-demo');   // 사람이 누르면 자동 루프를 멈춘다
  if (navigator.clipboard && navigator.clipboard.writeText) {
    navigator.clipboard.writeText(codeText.textContent).catch(function () {});
  }
  btn.classList.add('is-copied');
  live.textContent = '복사됨';
  clearTimeout(timer);
  timer = setTimeout(function () {
    btn.classList.remove('is-copied');
    live.textContent = '';
  }, 1600);
});

02Tabs that swap the same example per language

Three tabs show one example in CSS, SCSS, and Tailwind, and a bar underneath slides to whichever tab is active. That bar is not a fixed width — it is measured from each tab's own content, so a short label and a long one get different underline widths.

translateXaria-selectedtablist
@keyframes lts-indicator {
  0%   { transform: translateX(0) scaleX(1); }
  30%  { transform: translateX(43.2px) scaleX(1.14); }
  60%  { transform: translateX(92.58px) scaleX(1.55); }
  88%  { transform: translateX(0) scaleX(1); }
  100% { transform: translateX(0) scaleX(1); }
}

03Line numbers with a called-out line

Line numbers here are not typed text; a CSS counter prints them onto a pseudo-element, so a reader who copies the snippet never drags a stray number along with it. Hovering or tabbing to the called-out line brings up a small note explaining why that one line matters.

counter-increment::beforeuser-select
.ln::before {
  counter-increment: ln;
  content: counter(ln);
  position: absolute;
  left: 0;
  top: 0;
  width: 16px;
  text-align: right;
  color: $syn-dim;
  user-select: none;
}

04Diff that splits added and removed lines

Added lines carry a green edge and removed lines a red one, and the plus or minus sign sits in its own span so only the real code ever gets copied. A stretch where nothing changed folds into one row until a reader chooses to open it.

aria-expandedborder-inline-startsteps(1, end)
var cd = document.getElementById('cd');
var fold = document.getElementById('cd-fold');
fold.addEventListener('click', function () {
  cd.classList.remove('is-demo');
  var open = fold.getAttribute('aria-expanded') === 'true';
  fold.setAttribute('aria-expanded', String(!open));
  fold.setAttribute('aria-label', open ? '가려진 줄 펼치기' : '가려진 줄 접기');
  cd.classList.toggle('is-open', !open);
});

05Long block that folds until you open it

A long file opens by moving grid-template-rows from 0fr to 1fr, so there is no fixed pixel height to measure and hard-code ahead of time. While it stays folded, a fading band sits over the bottom edge as a hint that more code is waiting below.

grid-template-rowsaria-expandedlinear-gradient
.ecb__more {
  display: grid;
  grid-template-rows: 0fr;
}
.ecb:not(.is-demo) .ecb__more {
  transition: grid-template-rows $duration $easing;
}
.ecb.is-open .ecb__more { grid-template-rows: 1fr; }

06Window header that carries the file name

Three dots, a file icon, and a file name sit in the header, so a reader knows where to paste before reading a single line of the panel below. With more than one file, that same header becomes the tab strip, and the file name switches to match whichever tab is open.

aria-selectedborder-radiustranslateY
function fwSelect(i) {
  root.classList.remove('is-demo');
  // 머리의 파일 이름이 켜진 탭을 따라간다 — 거짓 헤더 방지
  titleEl.textContent = tabs[i].textContent;
  tabs.forEach(function (t, idx) {
    var on = idx === i;
    t.setAttribute('aria-selected', on ? 'true' : 'false');
    t.tabIndex = on ? 0 : -1;
  });
  panels.forEach(function (p, idx) {
    var on = idx === i;
    p.classList.toggle('is-active', on);
    p.setAttribute('aria-hidden', on ? 'false' : 'true');
  });
}

07Terminal that types the command itself

One command grows a character at a time and a block cursor blinks at the end of it, then two lines of output rise underneath once the typing finishes. The whole window doubles as a button — pressing it, or reaching it with Tab and Enter, replays the same typing for real.

steps(24, end)animation-fill-modemonospace
@keyframes term-type {
  0%   { width: 0ch;  animation-timing-function: steps(24, end); }
  44%  { width: 24ch; animation-timing-function: steps(1, end); }
  86%  { width: 24ch; animation-timing-function: steps(1, end); }
  100% { width: 0ch; }
}

08Pane that flips between code and result

One pane swaps between the code and the thing that code draws, and the swap happens in a single frame so the two layers never show through each other. The active side also lives in aria-pressed, so it is clear which of the two buttons is on even without seeing the highlight.

aria-pressedsteps(1, end)visibility
function setView(showResult) {
  root.classList.remove('is-demo');
  codeBtn.setAttribute('aria-pressed', String(!showResult));
  eyeBtn.setAttribute('aria-pressed', String(showResult));
  codeBtn.classList.toggle('is-on', !showResult);
  eyeBtn.classList.toggle('is-on', showResult);
  codeFace.style.opacity = showResult ? '0' : '1';
  codeFace.style.visibility = showResult ? 'hidden' : 'visible';
  resultFace.style.opacity = showResult ? '1' : '0';
  resultFace.style.visibility = showResult ? 'visible' : 'hidden';
  codeFace.setAttribute('aria-hidden', String(showResult));
  resultFace.setAttribute('aria-hidden', String(!showResult));
}

09Inline code that explains a token on hover

A word sitting inside a sentence carries a dotted underline, and hovering or tabbing to it pops up a short note explaining what that word means. Because the note is positioned absolutely, it never pushes the paragraph below it downward, which means the space for it has to be reserved ahead of time instead.

aria-describedbytext-decorationfocus-visible
.tp__body {
  position: relative;
  box-sizing: border-box;
  padding: $sp-3;
  padding-bottom: $sp-10; // 함정 7 — 팝오버는 position:absolute 라 뿌리 높이에 안 잡힌다. 미리 확보
  font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
  font-size: 12px;
  line-height: 18px;
}

Where it breaks — the trap

The sliding bar under the language tabs in 02 is not eyeballed. A Playwright measurement of the real tab boxes puts CSS at 43.2px starting at 0, SCSS at 49.38px starting at 43.2px, and Tailwind at 67.05px starting at 92.58px — three different widths because "Tailwind" is simply a longer label than "CSS". Hardcode one bar width for all three tabs and it either overshoots the short label or stops short of the long one; only reading each tab's own getBoundingClientRect at every swap keeps the bar honest.

The result pane in 08 rounds its own corners with overflow: hidden, which is ordinary for a card — until a code line runs long. One line inside that panel is wider than the 288px card, and the rounded-corner clip quietly cuts its right edge off. A page-level scroll check never catches this, because the surrounding document itself never overflows; only opening the still frame and reading the last few characters on that line shows the cut.

The toggle in 05 used to flip its caret and its "Show more" label back partway through the closing animation, while the panel underneath was still visibly open on screen — for a few hundred milliseconds the label was announcing a state the panel had not reached yet. Moving both back at 84% — right as the fold actually finishes, rather than partway through it — closed that gap between what the eye sees and what the label claims.

The note in 09 is position: absolute, so it never counts toward its parent's height — on a narrow phone width, skipping the reserved padding below the sentence would let the note land on top of the code sample it is meant to explain. Every one of these nine sits together in one folder, and the archive holding all nine is opened with the same password, 8j8vmbbq, folded into this sentence rather than boxed off on its own line.

Accessibility (reduced-motion)

prefers-reduced-motion: reduce turns off every autoplay loop across all nine, and what is left standing afterward is a chosen resting state, never a frame frozen mid-motion. Keyboard reach and screen-reader labels run on their own track, independent of whether motion is on, since most of these are native buttons underneath the animation.

Item Reduced motion leaves Keyboard Screen reader
01 Copy button Idle label showing, cursor hidden Native button, Tab then Enter aria-live announces the new label
02 Language tabs CSS tab selected, bar at rest Arrow Left/Right moves focus and swaps aria-selected marks the active tab
03 Line highlight Tooltip and cursor hidden until touched Tab focuses the called-out line aria-describedby ties the line to its note
04 Diff fold Fold open, added/removed bars fully drawn Native button, Tab then Enter aria-expanded marks the fold state
05 Collapsed block Panel collapsed, cursor hidden Native button, Enter or Space aria-expanded plus a labeled toggle
06 Filename header Locks to the second tab and its file name Arrow Left/Right, roving tabindex aria-selected and the header text move together
07 Terminal Cursor stops blinking, command and output stay filled in role="button", Enter or Space replays it aria-label announces "다시 재생"
08 Result split Code face visible, result face hidden Two native buttons, Tab then Enter aria-pressed marks which face is on
09 Token popover Both notes hidden until touched Tab reaches each token, Escape closes aria-describedby ties token to note

Item 01's clipboard write only runs inside a secure context in the first place, which is one more reason the fallback there is a silent no-op rather than a broken button. For the click-triggered half of this family — buttons and toggles that change state on a tap rather than a hover — the click trigger index collects the rest, and the tabbed pattern in 02 has a longer write-up in the CSS tab menu transition post.

FAQ

Do I still need a highlighting library like Prism for this?

No — every color across these nine comes from a plain span with a class, hand-picked for one fixed snippet. A real highlighting library still earns its keep once the code being shown is arbitrary text a user pasted in, since hand-tagging every keyword does not scale beyond a small set of known examples.

Why does the copy button in 01 do nothing on some pages?

navigator.clipboard.writeText only runs inside a secure context, so a page served over plain HTTP gets a rejected promise and nothing else happens. See MDN's Clipboard API page for the exact requirement, which is also why the demo wraps the call in a catch that does nothing rather than showing an error.

Can the tab indicator in 02 just use one fixed width instead of measuring?

Only if every label happened to be the same length — and "CSS", "SCSS", and "Tailwind" are not. The moment labels differ, a fixed-width bar either overshoots the short one or falls short of the long one — exactly the gap the real getBoundingClientRect numbers in the trap section above close. Related copy-and-paste chrome, including the button pattern behind 01, lives in the content category and the button state feedback post.

Enter the archive password

The password is inside this article. You will find it as you read.