코드 블록 UI 9종 — 하이라이터 없이 붙여 쓰는 코드 창
코드 블록은 문서 안에 프로그래밍 언어를 그대로 옮겨 담은 상자이고, 아홉 가지 모두 복사·전환·펼침·타이핑 같은 동작이 실제로 붙어 있는 화면입니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 누르면 복사됐다고 알려 주는 버튼
- 02 같은 예제를 언어별로 바꾸는 탭
- 03 줄 번호와 짚어 주는 줄
- 04 더한 줄과 뺀 줄을 나눠 보는 diff
- 05 길면 접었다가 펴는 코드 창
- 06 파일 이름이 붙은 창 머리
- 07 명령이 스스로 쳐지는 터미널
- 08 코드와 결과를 번갈아 보는 창
- 09 낱말을 짚으면 뜻이 뜨는 인라인 코드
복사 버튼(01)은 코드 창에 딱 하나만 붙는 가장 작은 부품이라 먼저 옵니다. 탭(02)과 파일 이름 창 머리(06)는 같은 창 안에서 여러 벌을 오가는 부품이라 그다음이고, 줄 번호(03)와 diff(04)는 코드 자체를 읽는 방식을 바꾸는 부품이라 가운데 놓았습니다. 접고 펴기(05)는 화면 밖으로 나가는 분량을 다루고, 터미널(07)과 코드·결과 전환(08)은 코드가 실행된 뒤를 보여 주는 부품이라 뒤쪽에 두었습니다. 마지막 인라인 팝오버(09)는 코드 창 밖, 본문 문장 속으로 들어간 것이라 가장 나중입니다.
01누르면 복사됐다고 알려 주는 버튼
오른쪽 위 버튼을 누르면 Clipboard 문서의 navigator.clipboard.writeText 가 코드 문자열을 통째로 넘겨받고, 라벨은 트랜지션 없이 한 프레임 컷으로 "복사"에서 "복사됨"으로 바뀝니다. 같은 문구가 aria-live="polite" 칸에도 함께 적혀 화면을 보지 않는 사람도 복사가 끝난 순간을 알 수 있습니다.
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);
});
02같은 예제를 언어별로 바꾸는 탭
CSS·SCSS·Tailwind 세 탭 아래 깔린 막대는 클릭할 때마다 getBoundingClientRect 로 눌린 탭의 폭과 왼쪽 좌표를 다시 재서 translateX·scaleX 값으로 바꿔 넣습니다. 탭 폭을 숫자로 미리 정해 두지 않고 매번 재기 때문에 라벨 글자 수가 달라져도 막대가 옆 탭을 침범하지 않습니다.
function moveIndicator(tab) {
var tabsRect = tablist.getBoundingClientRect();
var r = tab.getBoundingClientRect();
var base = tabs[0].getBoundingClientRect().width;
var left = r.left - tabsRect.left;
var scale = r.width / base;
indicator.style.transform =
'translateX(' + left + 'px) scaleX(' + scale + ')';
}
03줄 번호와 짚어 주는 줄
줄 번호는 <span>에 타이핑된 글자가 아니라 counter-increment 로 세어 ::before 에 찍히므로, 코드를 전체 선택해 복사해도 번호는 따라오지 않습니다. 짚어 둔 줄에 포인터를 올리거나 탭으로 초점을 주면 aria-describedby 로 연결된 쪽지가 뜹니다.
.lines { counter-reset: ln; display: block; }
.ln {
position: relative;
padding-left: 24px;
white-space: pre;
}
.ln::before {
counter-increment: ln;
content: counter(ln);
position: absolute;
left: 0;
width: 16px;
text-align: right;
user-select: none;
}
04더한 줄과 뺀 줄을 나눠 보는 diff
더한 줄은 왼쪽에 초록 띠, 뺀 줄은 붉은 띠를 두르고 그 색은 +·- 기호 칸에도 같이 입혀 색과 기호가 같은 정보를 두 번 말합니다. 기호는 코드 본문과 다른 <span> 안에 있어 실제로 선택해 복사하면 부호 없이 코드만 붙습니다. 가운데 안 바뀐 두 줄은 grid-template-rows 로 접혀 있다가 버튼을 눌러야 펴집니다.
.cd__line--add {
border-inline-start: 2px solid $color;
background: rgba($color, .16);
}
.cd__line--del {
border-inline-start: 2px solid $error;
background: rgba($error, .18);
}
.cd__fold-body {
display: grid;
grid-template-rows: 0fr;
overflow: hidden;
transition: grid-template-rows $duration $easing;
}
.cd.is-open .cd__fold-body { grid-template-rows: 1fr; }
05길면 접었다가 펴는 코드 창
긴 파일은 grid-template-rows 를 0fr에서 1fr로 옮겨 여닫으므로, 자바스크립트로 콘텐츠 높이를 재서 px 로 박아 둘 필요가 없습니다. 접힌 동안에는 바닥에 흐려지는 띠가 깔려 아래에 더 있다는 사실이 눈에 보입니다.
.ecb__more { display: grid; grid-template-rows: 0fr; }
.ecb.is-open .ecb__more { grid-template-rows: 1fr; }
.ecb__fade {
position: absolute;
left: 0; right: 0; bottom: 0;
height: 32px;
background: linear-gradient(
to bottom, rgba(255, 247, 230, 0), $subject-cream
);
pointer-events: none;
}
.ecb.is-open .ecb__fade { opacity: 0; }
06파일 이름이 붙은 창 머리
창 머리 왼쪽엔 신호등 점 세 개와 파일 아이콘, 그리고 지금 보고 있는 파일 이름이 나란히 앉습니다. 탭을 눌러 파일을 바꾸면 그 이름표가 textContent 로 다시 쓰여 항상 켜진 탭과 같은 값을 보여 주므로, 머리와 탭이 서로 다른 파일을 가리키는 일이 없습니다.
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');
});
}
07명령이 스스로 쳐지는 터미널
한 글자씩 36ms 간격의 setTimeout 으로 명령 문자열을 잘라 붙이고, 다 쳐지면 240ms와 520ms 뒤에 출력 두 줄이 차례로 떠오릅니다. 커서는 명령 글자와 별도 요소라 폭이 늘어나는 동안 저절로 줄 끝을 따라갑니다.
function play() {
cmd.textContent = '';
cmd.style.width = '0ch';
var i = 0;
(function tick() {
i += 1;
cmd.textContent = CMD.slice(0, i);
cmd.style.width = i + 'ch';
if (i < CMD.length) {
timers.push(setTimeout(tick, 36));
} else {
timers.push(setTimeout(function () { show(out1, true); }, 240));
timers.push(setTimeout(function () { show(out2, true); }, 520));
}
})();
}
08코드와 결과를 번갈아 보는 창
같은 창 안 두 겹 중 코드 쪽이 꺼지는 순간 결과 쪽이 opacity·visibility 로 한 프레임에 켜져, 두 장이 겹쳐 반투명하게 비치는 순간이 없습니다. 지금 켜진 쪽은 버튼의 aria-pressed 에도 남아 두 버튼 중 무엇이 눌려 있는지 헷갈리지 않습니다.
function setView(showResult) {
root.classList.remove('is-demo');
codeBtn.setAttribute('aria-pressed', String(!showResult));
eyeBtn.setAttribute('aria-pressed', String(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));
}
09낱말을 짚으면 뜻이 뜨는 인라인 코드
문장 속 border-radius, box-shadow 같은 낱말에는 점선 밑줄만 그어 두고, 올리거나 탭으로 초점을 주면 그 뜻을 적은 쪽지가 문단 아래에서 떠오릅니다. 쪽지 두 장은 같은 자리에 겹쳐 있다가 짚은 낱말이 바뀌면 글자만 컷으로 갈아 끼웁니다.
.tp__body {
position: relative;
padding: $sp-3;
padding-bottom: $sp-10;
}
.tok {
text-decoration: underline dotted;
text-underline-offset: 3px;
}
.tp__pop {
position: absolute;
left: $sp-3; right: $sp-3; bottom: $sp-3;
opacity: 0;
visibility: hidden;
}
.tp__pop.is-open { opacity: 1; visibility: visible; }
어디서 깨지는가 — 함정
02의 탭 인디케이터 폭은 짐작이 아니라 Playwright 로 getBoundingClientRect 를 세 탭 각각에 대고 잰 값입니다. CSS 탭이 기준(43.2px)이고, 그 오른쪽에 SCSS 탭이 43.2px 지점에서 49.38px 폭으로, Tailwind 탭이 92.58px 지점에서 67.05px 폭으로 이어 붙습니다. 이 세 숫자를 그대로 translateX·scaleX 키프레임에 넣지 않으면 막대가 글자보다 짧거나 길게 걸칩니다.
08의 코드·결과 전환 창은 카드 전체에 overflow: hidden 이 걸려 있어 모서리가 둥글게 잘립니다. 문제는 이 규칙이 안쪽 코드 줄에도 그대로 적용된다는 점인데, 무대 높이(132px)를 넘는 줄은 스크롤바 없이 조용히 잘려 나가고 문서 전체 폭·높이는 그대로 안 넘치므로 자동 검사로는 안 걸립니다. poster.png 를 직접 눈으로 열어 넷째 줄까지 다 보이는지 확인해야 잡히는 함정입니다.
05는 원래 창이 아직 펼쳐진 63% 지점에서 버튼 라벨을 「더 보기」로 되돌렸는데, 그러면 화면에는 코드가 다 보이는데 버튼만 접혀 있다고 화면이 거짓말을 하는 순간이 생겼습니다. 라벨과 화살표를 접힘이 거의 끝나는 84%로 옮겨 grid-template-rows 가 0fr에 가까워지는 시점과 맞춘 뒤에야 없어졌습니다.
09의 설명 쪽지는 position: absolute 로 띄워 두어 부모 높이 계산에 안 잡힙니다. 그래서 본문 아래에 쪽지 한 장 높이만큼 padding-bottom 을 미리 비워 두지 않으면, 좁은 화면에서 쪽지가 카드 바깥으로 넘칩니다. 압축 파일 비밀번호는 8j8vmbbq 이며, 아홉 벌 모두 같은 이유로 좁은 화면 규칙을 한 번씩 다시 확인했습니다.
접근성
아홉 개 모두 prefers-reduced-motion: reduce 에서 자동 루프만 꺼지고, 그 설정에서 화면에 남는 정지 상태는 항목마다 다릅니다. 어떤 값이 정확히 무엇을 끄는지는 MDN 문서에 정리되어 있습니다. 같은 갈래의 다른 화면은 버튼 상태 피드백과 CSS 탭 메뉴 전환에도 있고, 코드처럼 글을 담는 화면은 본문 카테고리에 모여 있습니다.
| 항목 | reduced-motion 정지 화면 | 키보드 | 낭독기 |
|---|---|---|---|
| 01 복사 버튼 | "복사됨" 라벨로 고정, 커서 숨김 | Tab · Enter | aria-live로 복사됨 통지 |
| 02 언어 탭 | CSS 탭 그대로 고정, 순환 없음 | 방향키로 탭 전환 | aria-selected · role=tablist |
| 03 줄 번호 | 짚은 줄 배경만 남고 쪽지·커서 숨김 | Tab으로 짚은 줄에 초점 | aria-describedby로 쪽지 연결 |
| 04 diff | 접힌 줄 그대로, 추가·삭제 띠는 이미 다 그려짐 | 접기 버튼 Tab · Enter | aria-expanded로 펼침 여부 |
| 05 접고 펴기 | 접힌 「더 보기」 상태로 고정 | 버튼 Tab · Enter | aria-expanded · 라벨 텍스트 |
| 06 창 머리 | 두 번째 탭(style.css)이 선택된 채로 고정 | 방향키로 탭 전환 | aria-selected + 머리 파일명 동기화 |
| 07 터미널 | 명령·출력 두 줄 전부 표시, 커서 깜빡임만 정지 | Enter · Space로 재생 | role="button" aria-label="다시 재생" |
| 08 코드·결과 전환 | 코드 면 고정, 결과 면 숨김 | 버튼 Tab · Enter | aria-pressed로 켜진 면 표시 |
| 09 인라인 팝오버 | 쪽지 둘 다 숨김, 밑줄만 남음 | Tab으로 낱말 초점, Esc로 닫기 | aria-describedby로 쪽지 연결 |
FAQ
코드 하이라이팅 라이브러리 없이 색을 칠하는 게 가능한가요?
여기 아홉 개는 전부 <span class="k">, <span class="s"> 처럼 손으로 나눠 놓은 조각에 색을 입힌 것이라 라이브러리가 없어도 됩니다. 다만 이 방식은 예제가 고정돼 있을 때만 통하고, 사용자가 아무 코드나 붙여 넣어 실시간으로 칠해야 하는 화면이라면 Prism이나 Shiki 같은 전용 도구가 낫습니다.
복사 버튼의 navigator.clipboard 가 모든 브라우저에서 되나요?
writeText 는 HTTPS 같은 보안 컨텍스트에서만 열리고, 지원 범위는 caniuse.com 에서 브라우저별로 확인할 수 있습니다. 01의 코드는 navigator.clipboard 자체가 없는 환경이면 if 문에서 조용히 넘어가도록 짜여 있어, 지원하지 않는 브라우저에서도 버튼이 에러로 멈추지는 않습니다.
이 아홉 개를 React로 옮기면 뭐가 달라지나요?
zip 안에는 지금 본 바닐라 판과 같은 동작의 React 판이 함께 들어 있어, classList.toggle 자리가 useState 로, textContent 를 바꾸던 자리가 상태값을 그대로 렌더링하는 자리로 바뀝니다. aria-selected·aria-expanded 같은 접근성 속성은 이름과 값이 그대로 유지되므로 스크린리더 쪽 동작은 옮긴 뒤에도 같습니다.