コードブロック UI 9選 — ハイライターなしで作るコード窓
コードブロックとは、記事やドキュメントに載せるプログラムのコードを読みやすく整える、角の丸い窓のことです。窓がただ飾るだけでなく、コピーできて、大事な行を指せて、長いときは畳める形になっていると、読む人は貼り付けまで一息に進めます。ここではその部品を9つ、色を付けるライブラリーなしで作ります。
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 押すとコピー済みと伝えるボタン
- 02 同じ例を言語ごとに切り替えるタブ
- 03 行番号と指し示す行
- 04 追加行と削除行を分けて見る diff
- 05 長ければ畳んで開くコード窓
- 06 ファイル名が付いた窓の頭
- 07 コマンドが自動で打たれるターミナル
- 08 コードと結果を交互に見る窓
- 09 語を指すと意味が出るインラインコード
並び順は難しさではなく、読む人がコードを受け取る順です。まずコピーして手元に置き(01)、自分の書き方に合わせて文法を選び(02)、大事な行を指し示してもらい(03)、何が変わったかを色で読み取ります(04)。長いファイルは畳んで収め(05・06)、コマンドは打ち込まれる様子ごと受け取り(07)、仕上げにその結果を自分の目で確かめ(08)、本文中の言葉にはその場で意味を尋ねます(09)。9つともHTMLとCSS、少しのJavaScriptで組み立ててあり、状態を伝えるボタンの作り方は状態フィードバックのボタンで扱ったものと同じ系統です。
01押すとコピー済みと伝えるボタン
窓の右上のボタンを押すと navigator.clipboard が中身を受け取り、ラベルが1フレームで「コピーしました」に切り替わります。切り替わった文字は aria-live の領域にも同じように流れるので、画面を見ていない人にもコピーが伝わります。貼り付けてすぐ試してほしいインストールコマンドの文書に向いています。
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 の3つのタブが同じ結果を別の文法で見せ、下の棒が translateX で選んだタブまで滑ります。棒の位置と幅はそのつど getBoundingClientRect で測って最初のタブとの比に換算するので、ラベルの長さが違っても支障はありません。同じ機能を複数の文法で案内するライブラリーの文書に使います。
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 + ')';
}
function select(i) {
tabs.forEach(function (t, idx) {
var on = idx === i;
t.setAttribute('aria-selected', on ? 'true' : 'false');
t.tabIndex = on ? 0 : -1;
panels[idx].hidden = !on;
});
moveIndicator(tabs[i]);
}
03行番号と指し示す行
行番号は文字として打たず counter-increment で数えて ::before に出すため、コピーについてきません。指し示す行は背景色が変わり、その行にポインターを乗せると、なぜ大事なのかを書いた付箋が浮かびます。長いコードの一行だけを説明するチュートリアル向きです。
.lines { counter-reset: ln; display: block; }
.ln {
position: relative;
display: block;
box-sizing: border-box;
padding-left: 24px;
color: $syn-txt;
white-space: pre;
border-radius: $r-xs;
}
.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;
}
04追加行と削除行を分けて見る diff
追加の行は緑、削除の行は赤の帯を左端に持ち、記号の部分は別の span なので本文のコードだけがコピーされます。変更のない区間は点3つの一行に畳まれ、押すと aria-expanded も切り替わって開きます。設定ファイルの一行だけを変えてもらう移行の文書に合います。
.cd__line--add {
border-inline-start: 2px solid $color;
background: rgba($color, .16);
}
.cd__line--add .cd__sign { color: $color; font-weight: 700; }
.cd__line--del {
border-inline-start: 2px solid $error;
background: rgba($error, .18);
}
.cd__line--del .cd__sign { color: $error; font-weight: 700; }
.cd__line--top .cd__src { color: $syn-dim; }
05長ければ畳んで開くコード窓
長いファイルは grid-template-rows を 0fr から 1fr へ動かして開くので、高さを先に測って書いておく必要がありません。畳まれている間は下端が薄れる帯が敷かれ、続きがあることが一目で分かります。設定ファイル全体を載せつつ画面は短く保つ文書に使います。
.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; }
.ecb__moreInner {
position: relative;
overflow: hidden;
min-height: 0;
}
.ecb__line { display: block; }
06ファイル名が付いた窓の頭
窓の頭には3つの点とファイルのアイコン、ファイル名が並び、どのファイルに貼るべきかを一行目より先に伝えます。ファイルが2つ以上あると頭の下がタブになり、選んだタブだけが aria-selected で選択状態になります。複数のファイルを同時に直すインストール案内に向いています。
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コマンドが自動で打たれるターミナル
コマンド1行が steps で1文字ずつ伸び、その先でブロックカーソルが点滅します。打ち終わると出力が2行続けて上がり、貼り付ける前に何が返ってくるかを示します。インストールコマンドとその結果を並べる入門文書に使います。
function play() {
root.classList.remove('is-demo');
clear();
cmd.textContent = '';
cmd.style.width = '0ch';
show(out1, false);
show(out2, false);
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コードと結果を交互に見る窓
同じ窓がコードと、そのコードが描く結果を交互に見せ、切り替えは1フレームのカットなので2つの層が半透明に重なることはありません。選んだ側は aria-pressed にも反映されるので、どちらのボタンが入っているかが伝わります。結果を見ないとコードが分からないCSS効果の紹介に合います。
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));
}
09語を指すと意味が出るインラインコード
本文中のインラインコードに点線の下線が引かれ、ポインターを乗せるかタブで移動すると、その語が何かを書いた付箋が下に浮かびます。付箋は絶対配置なので親の高さには含まれず、その分の余白をあらかじめ下に空けてあります。見慣れない属性名が何度も出る技術説明の段落向きです。
.tok {
display: inline;
padding: 0 $sp-1;
border: 0;
border-radius: $r-xs;
background: transparent;
color: $syn-key;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
font-weight: 700;
text-decoration: underline dotted;
text-underline-offset: 3px;
cursor: pointer;
}
.tok.is-on { background: rgba(47, 109, 246, .14); color: $subject-blue; text-decoration: none; }
どこで壊れるか — 落とし穴
最初に壊れたのは02の下の棒でした。この棒の位置と幅を決めるキーフレームの数値は、ブラウザーで各タブの getBoundingClientRect を実際に測って得たものです。測った結果は CSS が 43.2px、SCSS が 49.38px、Tailwind が 67.05px で、左端はそれぞれ 0px・43.2px・92.58px、棒の幅は最初のタブを 1 とした比の scaleX で 1.14 と 1.55 に掛け直してあります。棒の実体は幅1pxの要素で、scaleX で伸ばしているため、実測で端数が出ても滲みやずれが出ません。この実測を飛ばしてタブを均等割りで伸ばすと、棒は SCSS と Tailwind の境目でラベルから外れた場所に止まりました。
次に08で、パネルの中のコード行の右端が静かに短くなっていました。窓が overflow: hidden を持つため、はみ出した部分は警告なく切り取られます。この状態ではページ全体の横スクロールは起こらないので、自動検査のはみ出し測定には引っ掛からず、poster の静止画を目で確かめて初めて気づきました。折り返されるのではなく行ごとに切れるので、一見すると短い例示にしか見えないのが厄介なところです。切り詰めた後の映像は24フレームのうち16フレームで動き、7つのアニメーション規則だけで構成されています。
05では、窓がまだ開いているのにボタンのラベルが先に「もっと見る」へ戻る瞬間がありました。展開が終わる前の63パーセント地点で戻すと画面が嘘をつき、開いたままのコードの上に「もっと見る」と書かれたボタンが載ります。ラベルと矢印の復帰は折りたたみがほぼ終わる84パーセントまで遅らせて、画面の言葉と実際の状態が一致するように直しました。ラベルは2枚を重ねて透過だけで差し替えるので、文字の幅が違っても行が震えることはありません。
09の付箋は position: absolute なので、開いても親の高さに数えられません。下に余白を取っておかないと、狭い画面で開いた付箋が次の要素に重なります。この分は本文の箱の下余白として最初から確保してあり、幅340px以下の規則に切り替わってもそのまま残ります。9つの部品は一つの圧縮ファイルにまとめてあり、記事の中で動いている素の版と同じ動きの React 版も一緒に入っています。開くときの合言葉は 8j8vmbbq です。
アクセシビリティ
9つとも prefers-reduced-motion: reduce のときは自動再生を止めて、それぞれが伝えたい状態を静止画で残します。動きを消しても「コピーが済んだこと」「どのタブが選ばれていること」まで消えてしまえば、減らしたのは動きではなく情報になるからです。表の内容は各デモのCSSに書いた reduce 時の規則をそのまま書き起こしたものです。
| 項目 | 動きを減らしたとき | キーボード | 読み上げ |
|---|---|---|---|
| 01 コピーのボタン | 「コピーしました」の表示を残す | タブで入り Enter・Space | aria-live が一度だけ読む |
| 02 言語タブ | 最初のCSSの面だけを残す | 矢印キーでタブ移動 | aria-selected が選択を示す |
| 03 行番号 | 薄い行の強調を残し付箋は出さない | タブで行に乗ると開く | aria-describedby が付箋を結ぶ |
| 04 diff | 色帯を全部点けた静止画 | Enter・Space で開閉 | aria-expanded が開閉を示す |
| 05 畳み込み | 畳んだ最初の状態を残す | Enter・Space で開閉 | aria-expanded とラベルの交代 |
| 06 ファイルの頭 | 2番目のタブが選ばれた静止画 | 矢印キーでタブ移動 | aria-selected とファイル名の書き換え |
| 07 ターミナル | 打ち終わった命令と出力を残し点滅を止める | 窓ごとボタンで Enter・Space | role="button" と aria-label |
| 08 結果の切替 | コードの面だけを残す | 2つのボタンを切替 | aria-pressed が入っている側を示す |
| 09 語の付箋 | 付箋は閉じたまま下線を残す | タブで乗り Escape で閉じる | aria-describedby が付箋を結ぶ |
開閉のほとんどは button 要素で作ってあるので、Tabキーと Enter・Space だけで全項目を操作できます。矢印キーが必要なのは02と06のタブ列だけで、左右で末尾と先頭を行き来できます。設定方法の解説はMDN の prefers-reduced-motion のページにまとまっています。記事に載せる見せ方の部品はコンテンツのカテゴリーに、クリックで応える部品はクリックのカテゴリーに、タブの動きの近親はタブメニューの遷移にまとめてあります。
FAQ
コードブロックに色を付けるにはどうすればいいですか?
この9つは <span class="k"> のような目印を行に振り、3色のCSSで染める方式です。単語の種類が増えるようなら軽い着色のライブラリーを <pre> の中に足しても、窓の枠組みには影響しません。コピーは textContent を渡す仕組みなので、足した着色の目印がコピーに混ざることもありません。
狭い画面でもそのまま動きますか?
9つとも480×300の枠で動作を確かめ、幅340px以下では文字と行間を一段小さくする規則に切り替わります。01では跳ね返りの強い easing のカーソルが320pxの端を一瞬だけ超えたため、移動には跳ねない easing に替えてあります。全項目、横のはみ出しは測定に出ていません。
コピーが効かない環境はありますか?
navigator.clipboard は https など安全なコンテキストでのみ存在するため、手元のファイルを直接開くと消えています。01はそのときに何もせず静かに進み、見た目の表示だけは切り替わります。確かめるときは簡易サーバーを経由して開くと、コピーまで試せます。