GODRICH

코드 블록 UI 9종 — 하이라이터 없이 붙여 쓰는 코드 창

코드 블록은 문서 안에 프로그래밍 언어를 그대로 옮겨 담은 상자이고, 아홉 가지 모두 복사·전환·펼침·타이핑 같은 동작이 실제로 붙어 있는 화면입니다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

복사 버튼(01)은 코드 창에 딱 하나만 붙는 가장 작은 부품이라 먼저 옵니다. 탭(02)과 파일 이름 창 머리(06)는 같은 창 안에서 여러 벌을 오가는 부품이라 그다음이고, 줄 번호(03)와 diff(04)는 코드 자체를 읽는 방식을 바꾸는 부품이라 가운데 놓았습니다. 접고 펴기(05)는 화면 밖으로 나가는 분량을 다루고, 터미널(07)과 코드·결과 전환(08)은 코드가 실행된 뒤를 보여 주는 부품이라 뒤쪽에 두었습니다. 마지막 인라인 팝오버(09)는 코드 창 밖, 본문 문장 속으로 들어간 것이라 가장 나중입니다.

01누르면 복사됐다고 알려 주는 버튼

오른쪽 위 버튼을 누르면 Clipboard 문서navigator.clipboard.writeText 가 코드 문자열을 통째로 넘겨받고, 라벨은 트랜지션 없이 한 프레임 컷으로 "복사"에서 "복사됨"으로 바뀝니다. 같은 문구가 aria-live="polite" 칸에도 함께 적혀 화면을 보지 않는 사람도 복사가 끝난 순간을 알 수 있습니다.

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);
});

02같은 예제를 언어별로 바꾸는 탭

CSS·SCSS·Tailwind 세 탭 아래 깔린 막대는 클릭할 때마다 getBoundingClientRect 로 눌린 탭의 폭과 왼쪽 좌표를 다시 재서 translateX·scaleX 값으로 바꿔 넣습니다. 탭 폭을 숫자로 미리 정해 두지 않고 매번 재기 때문에 라벨 글자 수가 달라져도 막대가 옆 탭을 침범하지 않습니다.

translateXaria-selectedtablist
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 로 연결된 쪽지가 뜹니다.

counter-increment::beforeuser-select
.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 로 접혀 있다가 버튼을 눌러야 펴집니다.

aria-expandedborder-inline-startsteps(1, end)
.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 로 박아 둘 필요가 없습니다. 접힌 동안에는 바닥에 흐려지는 띠가 깔려 아래에 더 있다는 사실이 눈에 보입니다.

grid-template-rowsaria-expandedlinear-gradient
.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 로 다시 쓰여 항상 켜진 탭과 같은 값을 보여 주므로, 머리와 탭이 서로 다른 파일을 가리키는 일이 없습니다.

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');
  });
}

07명령이 스스로 쳐지는 터미널

한 글자씩 36ms 간격의 setTimeout 으로 명령 문자열을 잘라 붙이고, 다 쳐지면 240ms와 520ms 뒤에 출력 두 줄이 차례로 떠오릅니다. 커서는 명령 글자와 별도 요소라 폭이 늘어나는 동안 저절로 줄 끝을 따라갑니다.

steps(24, end)animation-fill-modemonospace
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 에도 남아 두 버튼 중 무엇이 눌려 있는지 헷갈리지 않습니다.

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));
  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 같은 낱말에는 점선 밑줄만 그어 두고, 올리거나 탭으로 초점을 주면 그 뜻을 적은 쪽지가 문단 아래에서 떠오릅니다. 쪽지 두 장은 같은 자리에 겹쳐 있다가 짚은 낱말이 바뀌면 글자만 컷으로 갈아 끼웁니다.

aria-describedbytext-decorationfocus-visible
.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 같은 접근성 속성은 이름과 값이 그대로 유지되므로 스크린리더 쪽 동작은 옮긴 뒤에도 같습니다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.