TECH · CSSだけで作るUI

JavaScriptなしで作るUIパーツ

サンプルとコード

01

アコーディオン(よくある質問)

単独で開く ↗

details と summary だけで開閉する。高さのアニメーションは grid-template-rows の 0fr から 1fr への補間で作るので、要素の高さを測る必要がありません。

HTML
<details class="faq" open>
  <summary>送料はいくらかかりますか</summary>
  <div class="faq-body"><div>
    全国一律 500 円です。5,000 円以上のご注文で送料は無料になります。離島の一部地域のみ、別途中継料をいただく場合があります。
  </div></div>
</details>

<details class="faq">
  <summary>注文後に住所を変更できますか</summary>
  <div class="faq-body"><div>
    発送準備に入る前であれば変更できます。注文履歴から「配送先の変更」を選んでください。発送済みの場合は運送会社の再配達手続きをご案内します。
  </div></div>
</details>

<details class="faq">
  <summary>返品の条件を教えてください</summary>
  <div class="faq-body"><div>
    到着から 14 日以内、未使用かつ外装が保たれている場合に限りお受けします。お客様都合の返品は返送料をご負担ください。不良品は当社が全額負担します。
  </div></div>
</details>
CSS
.faq {
  border: 1px solid var(--line);
  border-radius: 12px;
  background: #fff;
  overflow: hidden;
}
.faq + .faq { margin-top: 0.75rem; }

/* summary の既定マーカーを消して、自前の記号に差し替える */
.faq > summary {
  list-style: none;
  cursor: pointer;
  display: flex;
  align-items: center;
  gap: 0.75rem;
  padding: 1rem 1.15rem;
  font-weight: 600;
  line-height: 1.6;
}
.faq > summary::-webkit-details-marker { display: none; }
.faq > summary:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}

.faq > summary::after {
  content: "";
  margin-left: auto;
  width: 10px;
  height: 10px;
  border-right: 2px solid var(--muted);
  border-bottom: 2px solid var(--muted);
  transform: rotate(45deg) translate(-2px, -2px);
  transition: transform 0.25s ease;
}
.faq[open] > summary::after {
  transform: rotate(-135deg) translate(-2px, -2px);
}

/* 開閉のアニメーション。height:auto は補間できないので
   grid-template-rows の 0fr → 1fr を使う */
.faq-body {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 0.28s ease;
}
.faq[open] .faq-body { grid-template-rows: 1fr; }
.faq-body > div {
  overflow: hidden;
  color: var(--muted);
  line-height: 1.85;
}
.faq[open] .faq-body > div { padding: 0 1.15rem 1.15rem; }

@media (prefers-reduced-motion: reduce) {
  .faq-body, .faq > summary::after { transition: none; }
}
AIへの指示文
details と summary だけで動くアコーディオンを作ってください。
要件:
- JavaScript は使わない。開閉は details の標準機能に任せる。
- summary の既定マーカーは list-style: none と ::-webkit-details-marker で消し、
  自前の山形記号を ::after で描いて、open のとき 180 度回す。
- 開閉のアニメーションは高さを直接動かさず、内側を display: grid にして
  grid-template-rows を 0fr から 1fr へ遷移させる。
- 中身の要素には overflow: hidden を当てる。
- prefers-reduced-motion: reduce のときは transition を無効にする。
- 日本語の本文なので line-height は 1.85、letter-spacing は 0 にする。
02

タブ切り替え

単独で開く ↗

同じ name のラジオボタンを隠して置き、:has() で「どれが選ばれているか」を親から判定します。ラジオなので左右キーでの移動も標準で効きます。

HTML
<div class="tabs">
  <input type="radio" name="plan" id="tab1" checked />
  <input type="radio" name="plan" id="tab2" />
  <input type="radio" name="plan" id="tab3" />

  <div class="tab-list">
    <label for="tab1">個人</label>
    <label for="tab2">チーム</label>
    <label for="tab3">法人</label>
  </div>

  <div class="panel" id="panel1">
    <h3>個人プラン</h3>
    <p>ひとりで使う前提の構成です。保存できるプロジェクトは 3 件まで、書き出しは月 50 回まで。学習と個人制作にはこれで足ります。</p>
  </div>
  <div class="panel" id="panel2">
    <h3>チームプラン</h3>
    <p>5 人までの共同編集と、変更履歴の 30 日保持が付きます。権限は編集者と閲覧者の 2 段階で、招待はリンク発行だけで完了します。</p>
  </div>
  <div class="panel" id="panel3">
    <h3>法人プラン</h3>
    <p>人数の上限がなくなり、シングルサインオンと監査ログが使えます。請求書払い、および利用状況の月次レポートに対応します。</p>
  </div>
</div>
CSS
.tabs {
  border: 1px solid var(--line);
  border-radius: 14px;
  background: #fff;
  overflow: hidden;
}
/* ラジオは同じ name を共有させるだけ。画面からは隠すが
   フォーカスは残すので、キーボードの左右キーで移動できる */
.tabs input[type="radio"] {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}
.tab-list {
  display: flex;
  gap: 0.25rem;
  padding: 0.4rem;
  background: #f2f4f9;
  border-bottom: 1px solid var(--line);
}
.tab-list label {
  flex: 1;
  text-align: center;
  padding: 0.6rem 0.5rem;
  border-radius: 9px;
  font-size: 0.92rem;
  font-weight: 600;
  line-height: 1.7;
  color: var(--muted);
  cursor: pointer;
}
.tab-list label:hover { color: var(--ink); }

/* 選択中のラジオから、その id を参照しているラベルへは辿れないので
   :has() で「チェック済みの入力を含むタブ群」の n 番目を塗り分ける */
.tabs:has(#tab1:checked) label[for="tab1"],
.tabs:has(#tab2:checked) label[for="tab2"],
.tabs:has(#tab3:checked) label[for="tab3"] {
  background: #fff;
  color: var(--accent);
  box-shadow: 0 1px 3px rgba(27, 32, 48, 0.12);
}

.panel { display: none; padding: 1.4rem 1.35rem; }
.tabs:has(#tab1:checked) #panel1,
.tabs:has(#tab2:checked) #panel2,
.tabs:has(#tab3:checked) #panel3 { display: block; }

.panel h3 { margin: 0 0 0.5rem; font-size: 1.05rem; line-height: 1.5; }
.panel p { margin: 0; color: var(--muted); line-height: 1.85; }
.tabs input:focus-visible + label {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
AIへの指示文
CSS だけで動くタブ切り替えを作ってください。
要件:
- 同じ name を共有する input[type=radio] を3つ、パネルの外に置いて視覚的に隠す。
  display: none は使わずフォーカスは残す(position: absolute と opacity: 0)。
- タブの見出しは label[for] にして、押すとラジオが切り替わるようにする。
- 選択中の見出しとパネルは、親要素の :has(#tabN:checked) で塗り分ける。
- パネルは既定で display: none、選択中だけ display: block。
- input:focus-visible + label にフォーカスリングを出す。
- 日本語の本文なので line-height は 1.85、letter-spacing は 0 にする。
03

モーダルダイアログ

単独で開く ↗

:target を使い、開いている状態を URL のハッシュに持たせます。JavaScript を使わずに状態を共有できる、ほぼ唯一の仕組みです。

HTML
<a class="btn" href="#confirm">アカウントを削除する</a>

<div class="modal" id="confirm" role="dialog" aria-modal="true" aria-labelledby="confirm-title">
  <a class="modal-backdrop" href="#" aria-label="閉じる"></a>
  <div class="modal-card">
    <h2 id="confirm-title">本当に削除しますか</h2>
    <p>アカウントを削除すると、保存済みのプロジェクトと共有リンクがすべて失われます。この操作は取り消せません。</p>
    <div class="modal-actions">
      <a class="modal-cancel" href="#">やめる</a>
      <a class="modal-ok" href="#">削除する</a>
    </div>
  </div>
</div>
CSS
.btn {
  display: inline-block;
  padding: 0.75rem 1.4rem;
  border-radius: 10px;
  background: var(--accent);
  color: #fff;
  font-weight: 700;
  line-height: 1.7;
  text-decoration: none;
}

/* :target は「URL のハッシュが自分の id と一致したとき」に当たる。
   JS を使わずに開閉状態を URL に持たせられる唯一の仕組み */
.modal {
  position: fixed;
  inset: 0;
  display: grid;
  place-items: center;
  padding: 1.25rem;
  background: rgba(20, 24, 36, 0.55);
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.2s ease, visibility 0.2s ease;
}
.modal:target { opacity: 1; visibility: visible; }

.modal-card {
  width: min(440px, 100%);
  background: #fff;
  border-radius: 16px;
  padding: 1.6rem 1.5rem 1.35rem;
  box-shadow: 0 30px 60px -25px rgba(20, 24, 36, 0.5);
  transform: translateY(10px) scale(0.98);
  transition: transform 0.24s ease;
}
.modal:target .modal-card { transform: none; }

.modal-card h2 { margin: 0 0 0.6rem; font-size: 1.2rem; line-height: 1.5; }
.modal-card p { margin: 0 0 1.3rem; color: var(--muted); line-height: 1.85; }

.modal-actions { display: flex; gap: 0.6rem; justify-content: flex-end; }
.modal-actions a {
  padding: 0.6rem 1.1rem;
  border-radius: 9px;
  font-size: 0.92rem;
  font-weight: 600;
  line-height: 1.7;
  text-decoration: none;
}
.modal-cancel { border: 1px solid var(--line); color: var(--muted); }
.modal-ok { background: var(--accent); color: #fff; }

/* 背景を押しても閉じられるように、カードの下に全面リンクを敷く */
.modal-backdrop { position: absolute; inset: 0; }

@media (prefers-reduced-motion: reduce) {
  .modal, .modal-card { transition: none; }
}
AIへの指示文
:target を使った、JavaScript なしのモーダルを作ってください。
要件:
- 開くリンクは href="#confirm"、閉じるリンクは href="#"。
- .modal は position: fixed で全面を覆い、既定は opacity: 0 と visibility: hidden。
  .modal:target のときだけ表示する。
- カードは translateY(10px) scale(0.98) から等倍へ遷移させる。
- カードの下に position: absolute inset: 0 の全面リンクを敷いて、
  背景を押しても閉じられるようにする。
- role="dialog" aria-modal="true" aria-labelledby を付ける。
- prefers-reduced-motion: reduce のときは transition を無効にする。
04

ローディング表示 4種

単独で開く ↗

スピナー、スケルトン、不定長の進捗バー、三点リーダー。いずれも要素1つと keyframes だけで、画像も JavaScript も使いません。

HTML
<div class="demo">
  <h3>スピナー</h3>
  <div class="spinner" role="status" aria-label="読み込み中"></div>
</div>

<div class="demo">
  <h3>スケルトン</h3>
  <div role="status" aria-label="読み込み中">
    <div class="skeleton"></div>
    <div class="skeleton"></div>
    <div class="skeleton short"></div>
  </div>
</div>

<div class="demo">
  <h3>不定長の進捗バー</h3>
  <div class="bar" role="status" aria-label="読み込み中"></div>
</div>

<div class="demo">
  <h3>三点リーダー</h3>
  <span class="dots" role="status" aria-label="読み込み中"><i></i><i></i><i></i></span>
</div>
CSS
/* 1) スピナー — border の一辺だけ色を変えて回す */
.spinner {
  width: 34px;
  height: 34px;
  border: 3px solid var(--line);
  border-top-color: var(--accent);
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }

/* 2) スケルトン — 背景を横に流して「読み込み中」を示す */
.skeleton {
  height: 14px;
  border-radius: 7px;
  background: linear-gradient(90deg, #eceff5 25%, #f6f8fc 37%, #eceff5 63%);
  background-size: 400% 100%;
  animation: shimmer 1.3s ease infinite;
}
.skeleton + .skeleton { margin-top: 0.7rem; }
.skeleton.short { width: 55%; }
@keyframes shimmer {
  from { background-position: 100% 0; }
  to { background-position: 0 0; }
}

/* 3) 進捗バー — 不定長。幅を変えながら左右に往復させる */
.bar {
  height: 5px;
  border-radius: 999px;
  background: var(--line);
  overflow: hidden;
}
.bar::after {
  content: "";
  display: block;
  height: 100%;
  width: 40%;
  border-radius: inherit;
  background: var(--accent);
  animation: slide 1.4s ease-in-out infinite;
}
@keyframes slide {
  0% { transform: translateX(-100%); }
  100% { transform: translateX(250%); }
}

/* 4) 三点リーダー — 時間差で沈ませる */
.dots { display: inline-flex; gap: 6px; }
.dots i {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--accent);
  animation: bounce 1s ease-in-out infinite;
}
.dots i:nth-child(2) { animation-delay: 0.15s; }
.dots i:nth-child(3) { animation-delay: 0.3s; }
@keyframes bounce {
  0%, 60%, 100% { transform: none; opacity: 0.45; }
  30% { transform: translateY(-6px); opacity: 1; }
}

/* 動きを減らす設定の利用者には、回さずに存在だけ示す */
@media (prefers-reduced-motion: reduce) {
  .spinner, .skeleton, .bar::after, .dots i { animation: none; }
  .bar::after { width: 100%; }
}
AIへの指示文
CSS だけのローディング表示を4種類作ってください。
要件:
- スピナー: border を4辺に引き、border-top-color だけアクセント色にして回転させる。
- スケルトン: linear-gradient を background-size: 400% で置き、background-position を動かす。
- 進捗バー: 親に overflow: hidden、::after を幅40%で translateX させて往復させる。
- 三点リーダー: 3つの円に animation-delay を 0.15s ずつずらして上下させる。
- すべて role="status" と aria-label="読み込み中" を付ける。
- prefers-reduced-motion: reduce のときは animation を止め、
  進捗バーは幅100%の静止状態にする。

この技術について

「動くUIにはJavaScriptが要る」という前提は、いまのCSSでは半分しか正しくありません。アコーディオン、タブ、モーダル、ローディング表示。この4つはWebサイトで最もよく使う「状態を持つ部品」ですが、どれもCSSの標準機能だけで組めます。しかも、JavaScriptで書くより短く、壊れにくい形で。

状態をどこに置くか、という一点

CSSだけでUIを組むときにやっていることは、突き詰めると「状態の置き場所を変える」ことです。JavaScriptで書く場合、開いているかどうかは変数やクラス名として持ちます。CSSだけで組む場合は、それをブラウザが元から持っている状態に預けます。details要素のopen属性、ラジオボタンのchecked、URLのハッシュ。どれもブラウザが管理してくれるので、こちらが同期を取る必要がありません。

この違いは、コード量より「壊れ方」に効きます。JavaScriptで状態を持つと、読み込みが終わるまで部品は動きません。回線が細いとき、スクリプトが1つ失敗したとき、広告タグが例外を投げたとき。そういう場面でアコーディオンが開かなくなるのは、コンテンツが読めなくなるということです。CSSに預けておけば、HTMLとCSSが届いた時点で動きます。

CSSだけのUIは、状態を要素・フォーム部品・URLのどこに置くかで選ぶ details / summary 状態は要素自身が持つ 開閉・読み上げ・キー操作 まで最初から付いてくる input:checked 状態はフォーム部品が持つ ラベルと結べば押せる 意味は自分で補う :target 状態はURLが持つ 共有・戻るが効く 履歴が増える 選ぶ基準は「その状態を誰が覚えているべきか」 見た目の書きやすさではなく、状態の置き場所で決める。ここを外すと後から効いてくる
3つとも「JavaScriptなし」で同じことができますが、状態の持ち主が違います。共有したいなら URL、その場限りならフォーム部品、意味まで込みなら details です。

高さのアニメーションという長年の難所

CSSだけでは長くできなかったことが一つあります。高さが可変の要素を、滑らかに開閉することです。height: auto は補間できないので、JavaScriptで実際の高さを測ってpxで指定する、というのが定番の回避策でした。

いまはgridで解けます。開閉する部分を display: grid にして、grid-template-rows を 0fr から 1fr へ遷移させる。中身には overflow: hidden を当てる。これだけで、高さを一切測らずに開閉がアニメーションします。この記事のアコーディオンとドロワーはどちらもこの方法で、JavaScriptは高さの計算に一切関与していません。

height: auto は補間できないが、grid-template-rows の 0fr→1fr なら滑らかに開く height: auto へ transition 補間されない パッと開く。長年これで妥協していた grid-template-rows 0fr → 1fr 数値どうしなので滑らかに開く .panel { display: grid; grid-template-rows: 0fr; transition: grid-template-rows .3s } [open] .panel { grid-template-rows: 1fr } 中身には min-height: 0 と overflow: hidden が要る。無いと閉じきらない
auto は数値ではないので、ブラウザは途中の値を作れません。0fr と 1fr なら数値どうしなので補間できる、というのがこの手法の核心です。

向かない場面もはっきりしている

一方で、CSSに寄せないほうがいい場面もあります。開いているタブを分析ツールに送りたい、選択内容を他の部品に連動させたい、開閉の履歴をサーバーに保存したい。こうした「状態を他から読む」要求が出た時点で、CSSの状態は扱いにくくなります。CSSの状態はDOMの属性として残らないため、外から観測できないからです。

判断の目安は単純です。その状態が、その部品の中で完結するかどうか。完結するならCSSで十分。外に出す必要があるなら、最初からJavaScriptで持たせたほうが後戻りがありません。

CSSだけで足りる場面と、JavaScriptが要る場面の境目 CSSだけで足りる ・開閉する/切り替える ・見た目が変わるだけ ・状態が1つで完結する JavaScript が要る ・順序や個数が変わる ・入力値によって分岐する ・外部と通信する 境目は「状態が増えるかどうか」。無理に CSS で押し切ると、 チェックボックスが画面外に大量に並ぶ、読めない実装になる
できる/できないの線引きではなく、読めるかどうかの線引きです。CSSで書けても、半年後に自分が追えない実装は選ばないでください。

見落とされがちな仕上げ

最後に2つだけ。ラジオボタンを隠すときに display: none を使うと、キーボードで操作している人がタブを切り替えられなくなります。position: absolute と opacity: 0 で視覚的にだけ隠してください。そして prefers-reduced-motion への対応です。画面内の予期しない動きは、前庭障害のある人にとって実際に体調不良の原因になります。transition と animation を切る数行を、最初から書いておいてください。

CSSだけのUIで見落とされやすい3つの仕上げ :focus-visible キーボードのときだけ枠を出す。マウス操作では出ない outline: none だけ書いて代わりを用意しないのが最も多い失敗 prefers-reduced-motion 動きを減らす設定の人には、動きだけ消して機能は残す transition ごと消すと、開いたか閉じたかが分からなくなることがある aria-expanded / aria-controls 開閉の状態を読み上げに伝える details 以外では自分で書く。見た目が変わっても意味は伝わらない
どれも動いているように見えるので、抜けても気づきません。CSSだけで組んだUIほど、意味と操作の手当てが自分の仕事として残ります。

使いどころとつまずきどころ

向いている場面

  • JavaScriptの読み込み前でも動いてほしい部品
  • ページ表示速度を削りたいランディングページ
  • 静的サイトジェネレータで組むドキュメント
  • メール配信やCMSなどJSを差し込めない環境

つまずきやすい点

  • 状態をCSSで持つと、その状態を他の場所から読めない
  • チェックボックスハックは支援技術に意味が伝わらないことがある
  • :has() は対応ブラウザを確認してから使う
  • アニメーションは prefers-reduced-motion を必ず尊重する

よくある質問

:has() は実務で使って大丈夫ですか。

2023年末までに Chrome・Safari・Firefox の安定版すべてに入り、いまは主要ブラウザで使えます。ただし社内システムなど古い環境が残る案件では、:has() が効かないときにタブが全部開いて見える、といった「壊れ方」を先に決めておくと安全です。

ラジオボタンを隠すのに display:none を使ってはいけませんか。

使わないでください。display:none の要素はフォーカスを受け取れないので、キーボードだけで操作している人がタブを切り替えられなくなります。position:absolute と opacity:0 で視覚的にだけ隠せば、フォーカスもキー操作も残ります。

状態をCSSで持つと困る場面はありますか。

「いまどのタブが開いているか」を別の場所から読みたいときです。CSSの状態はDOMの属性として残らないので、分析ツールに送ったり、他の要素の表示に連動させたりはできません。そこまで必要になった時点でJavaScriptに移すのが素直です。

アニメーションを止める設定に対応する必要はありますか。

あります。前庭障害のある人にとって、画面内の予期しない動きは実際に体調不良を引き起こします。prefers-reduced-motion: reduce を見て transition と animation を切るのは数行で済むので、最初から書いておいてください。

同じ技術を使った完成例を、画面の型ごとにまとめています。作りたい画面が決まっているなら、こちらから探すほうが早いはずです。

ほかの技術