GODRICH

コードブロック UI 9選 — ハイライターなしで作るコード窓

コードブロックとは、記事やドキュメントに載せるプログラムのコードを読みやすく整える、角の丸い窓のことです。窓がただ飾るだけでなく、コピーできて、大事な行を指せて、長いときは畳める形になっていると、読む人は貼り付けまで一息に進めます。ここではその部品を9つ、色を付けるライブラリーなしで作ります。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

並び順は難しさではなく、読む人がコードを受け取る順です。まずコピーして手元に置き(01)、自分の書き方に合わせて文法を選び(02)、大事な行を指し示してもらい(03)、何が変わったかを色で読み取ります(04)。長いファイルは畳んで収め(05・06)、コマンドは打ち込まれる様子ごと受け取り(07)、仕上げにその結果を自分の目で確かめ(08)、本文中の言葉にはその場で意味を尋ねます(09)。9つともHTMLとCSS、少しのJavaScriptで組み立ててあり、状態を伝えるボタンの作り方は状態フィードバックのボタンで扱ったものと同じ系統です。

01押すとコピー済みと伝えるボタン

窓の右上のボタンを押すと navigator.clipboard が中身を受け取り、ラベルが1フレームで「コピーしました」に切り替わります。切り替わった文字は aria-live の領域にも同じように流れるので、画面を見ていない人にもコピーが伝わります。貼り付けてすぐ試してほしいインストールコマンドの文書に向いています。

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 の3つのタブが同じ結果を別の文法で見せ、下の棒が translateX で選んだタブまで滑ります。棒の位置と幅はそのつど getBoundingClientRect で測って最初のタブとの比に換算するので、ラベルの長さが違っても支障はありません。同じ機能を複数の文法で案内するライブラリーの文書に使います。

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 + ')';
}
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 に出すため、コピーについてきません。指し示す行は背景色が変わり、その行にポインターを乗せると、なぜ大事なのかを書いた付箋が浮かびます。長いコードの一行だけを説明するチュートリアル向きです。

counter-increment::beforeuser-select
.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 も切り替わって開きます。設定ファイルの一行だけを変えてもらう移行の文書に合います。

aria-expandedborder-inline-startsteps(1, end)
.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-rows0fr から 1fr へ動かして開くので、高さを先に測って書いておく必要がありません。畳まれている間は下端が薄れる帯が敷かれ、続きがあることが一目で分かります。設定ファイル全体を載せつつ画面は短く保つ文書に使います。

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; }
.ecb__moreInner {
  position: relative;
  overflow: hidden;
  min-height: 0;
}
.ecb__line { display: block; }

06ファイル名が付いた窓の頭

窓の頭には3つの点とファイルのアイコン、ファイル名が並び、どのファイルに貼るべきかを一行目より先に伝えます。ファイルが2つ以上あると頭の下がタブになり、選んだタブだけが aria-selected で選択状態になります。複数のファイルを同時に直すインストール案内に向いています。

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コマンドが自動で打たれるターミナル

コマンド1行が steps で1文字ずつ伸び、その先でブロックカーソルが点滅します。打ち終わると出力が2行続けて上がり、貼り付ける前に何が返ってくるかを示します。インストールコマンドとその結果を並べる入門文書に使います。

steps(24, end)animation-fill-modemonospace
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効果の紹介に合います。

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

09語を指すと意味が出るインラインコード

本文中のインラインコードに点線の下線が引かれ、ポインターを乗せるかタブで移動すると、その語が何かを書いた付箋が下に浮かびます。付箋は絶対配置なので親の高さには含まれず、その分の余白をあらかじめ下に空けてあります。見慣れない属性名が何度も出る技術説明の段落向きです。

aria-describedbytext-decorationfocus-visible
.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はそのときに何もせず静かに進み、見た目の表示だけは切り替わります。確かめるときは簡易サーバーを経由して開くと、コピーまで試せます。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。