↔️

Reactにおける UI = f(state) をCSSのみで翻訳してみる

に公開

0. はじめに

「React の useStateContext も、最近の CSS だけで再現できるんじゃないか」と思いついて、ちょっとした自由研究としてやってみた記録です。

下のマルチステップウィザード、JavaScript を 1 行も書かずに 動いています。
「次へ」を押すとステップが進み、Progress bar の幅が連続的に伸び、現在のステップに応じて表示パネルとボタンが入れ替わる。すべて CSS の中で完結しています。

1. CSS で状態管理ってできる?

CSS には伝統的に「状態」という概念が薄い領域でした。:hover / :focus / :checked のような擬似クラスはあっても、複数の状態を保管する変数 を CSS 内部で持つ手段はなく、JS が握っていた領分です。
ところが最近の CSS には、ちょうど状態管理に使える道具が揃ってきています。本記事はそれを組み合わせて、React の鉄則「UI = f(state)」を CSS の世界だけでどれだけ成立させられるか試してみたいと思います。

鍵になる道具は3つ:

  • :has(): 子要素の状態を親が読み取る
  • if(): 値の中で条件分岐する
  • @property: カスタムプロパティに型を付け、数値計算に流す

順に組み立てていきます。

2. 仕方ないので state の置き場を借りる ─ hidden な radio + label

CSS には変数に代入する構文がありません。
なので state の保管庫だけは、どうしてもブラウザがネイティブに状態を持つ DOM 要素 ── <input type="radio"> を借ります。

Wizard 全体の HTML はおおむね以下の形になります。コメントの ①〜③ が、このあと CSS で扱う構造の見取り図です:

<form class="wizard">
  <!-- ① state の保管庫: radio (視覚的には hidden) -->
  <input type="radio" name="step" id="step-1" value="1" checked />
  <input type="radio" name="step" id="step-2" value="2" />
  <input type="radio" name="step" id="step-3" value="3" />

  <!-- ② setter: <label for> クリックで対応する radio.checked がトグルされる -->
  <label for="step-2" class="btn-next">次へ →</label>
  <label for="step-3" class="btn-next">次へ →</label>
  <span class="btn-finish">完了</span>

  <!-- ③ 派生 UI の対象: これらは state を見て自分の見た目を決める -->
  <div class="progress-bar"></div>

  <ol>
    <li class="step-item" data-step="1"><span class="badge">1</span></li>
    <li class="step-item" data-step="2"><span class="badge">2</span></li>
    <li class="step-item" data-step="3"><span class="badge">3</span></li>
  </ol>

  <section class="panel" data-panel="1">アカウント設定</section>
  <section class="panel" data-panel="2">プロフィール</section>
  <section class="panel" data-panel="3">確認</section>
</form>

① の radio は視覚的に隠しておきます:

input[type="radio"] {
  display: none;
}

② の <label for="step-2"> をクリックするとブラウザが step-2.checkedtrue にする ── これが setter。
ここまでが「仕方なく HTML に頼る部分」です。あとは ③ の派生 UI を CSS でどう state に追従させるか、という本題に進みます。

3. CSS の中で状態を「持つ」 ─ :has() でカスタムプロパティに変換する

ここからが状態管理の本題です。radio が状態を持っているとはいえ、CSS 側からは :checked 擬似クラスでしか参照できません。
これをカスタムプロパティに変換し、CSS内での状態として定義します。

/* --step を整数型のカスタムプロパティとして宣言(初期値は 1) */
@property --step {
  syntax: "<integer>";
  inherits: true;
  initial-value: 1;
}

.wizard:has(input[value="2"]:checked) {
  --step: 2;
}
.wizard:has(input[value="3"]:checked) {
  --step: 3;
}

「子のチェック状態が変わったら、親に --step を立てる」という宣言です。
@property<integer> 型として --step を宣言したうえで、:has() が子(radioの値)の :checked を見て、親側の値が自動で追従する。
つまり、React で言うところの state lifting をやっています。

ただし、よく見ると データフローの向きが逆 です。

  • React の state lifting: 親が useState を持ち、setter を props で子に渡す。子は callback で親に値を伝える。
  • CSS の :has() による hoist: state は子(radio)が持ったまま。親は :has(input:checked)直接子を read する

つまり CSS では 「親が子を見る関係そのものを宣言で書く」 という発想になります
:has():checkedでカスタムプロパティを上書きする上述のコードはCSSの世界でいうイベントハンドラーのように見えてきますね)

ここまで来れば、.wizard を root とする子孫は、CSS の継承を辿って --step を読めるようになります。

4. 状態に追従して UI を派生する ─ if()@property

--step という変数が立ったので、いよいよ 「UI を --step の関数として書く」 フェーズです。
if() は文字通り「状態を引数に取って UI 値を返す式」として使えるので、コードがそのまま UI = f(state) の形になります。

たとえば step インジケータの 背景色 は「現在の step が何か」で完全に決まる関数です:

/* 1 つ目の step バッジの背景色 = f(--step) */
.step-item[data-step="1"] .badge {
  background: if(
    style(--step: 1): rgb(37 99 235) ; /* 自分が現在 */
    style(--step: 2): rgb(34 197 94) ; /* 自分は完了済 */
    style(--step: 3): rgb(34 197 94) ; /* 自分は完了済 */
    else: rgb(209 213 219)
  );
}

/* 2 つ目の step バッジの背景色 = f(--step) */
.step-item[data-step="2"] .badge {
  background: if(
    style(--step: 1): rgb(209 213 219) ; /* 自分は未済 */
    style(--step: 2): rgb(37 99 235) ; /* 自分が現在 */
    style(--step: 3): rgb(34 197 94) ; /* 自分は完了済 */
    else: rgb(209 213 219)
  );
}

この要素の背景色は state によって決まる」という関係が、1 つの宣言の中に全分岐込みで畳まれています。
これはつまり、state を入力し、UI 値を出力とする pure function そのものです。
state は親で 3 行宣言したきり、派生はそれを読んだ関数として独立に書ける ── この分離は React で useContext() から state を読み取って各コンポーネントが個別に派生値を返すのに近い書き味です。

if() の手が届かない 連続値 (たとえば Progress bar の幅など)は、calc() で書きます。--step<integer> 型として宣言しているので、そのまま calc() に流せる ── 「state を整数で受け取って、0〜100% に線形マップする関数」です:

/* progress bar の width = f(--step) */
.progress-bar {
  width: calc(var(--step) / 3 * 100%);
}

if()離散的な分岐関数calc()連続的な計算関数 ── どちらも 同じ --step 1 本 を入力にして UI 値を返す関数として並列に読めます。

5. 並べてみる ── 形は似ているが、性質は別物

ここまで組み上げた状態管理を、React の語彙に当てはめると、こんな対応関係に並びます:

React CSS-only
useState(initial) <input type="radio">(DOM 上の SSoT)+ @property --step { initial-value: 1; ... }(CSS 側の状態宣言)
setState(val) <label for> クリック → .wizard:has(input[value=N]:checked) { --step: N; }--step を上書き
状態管理の参照 var(--step) / style(--step: N)
Context / Provider カスタムプロパティの継承
state から派生値を計算 if() / calc()
再レンダリング CSS style recompute

6. おわりに ── 限界と落としどころ

  • UI の表示・非表示は、本来は条件付きレンダリングの仕事: Wizard では display: none で step ごとのパネルを切り替えていますが、これは「フィールドの入力値を残しておきたい」というフォーム特有の事情によるものです。
    一般的な UI 切替は、表示しないものはそもそも DOM に存在させない(コンポーネントを呼び出さない)ほうが、パフォーマンスも保守性も健全になります。
  • CSSはあくまでHTMLを装飾するもの: CSSからHTML内のフィールドの選択値やJavaScriptのメモリ上の値を更新することは決してできないですよね。
    Reactは状態に応じてreturnされるUIが決定されるように、script上の値を真のSSoTとすることができます。しかし、CSSで状態管理をしようと思うとどうしてもSSoTは外部依存になってしまう。

実務で状態が CSS に置かれる場面は、今後もほぼないでしょう。今回見えたのは「CSS の機能だけで React 風の状態管理の "形" を作るとこうなる」というところまで。
それでもなお、JS なしでここまで「形」を書けるようになったこと自体は、CSS の進化の方向性を眺める材料として面白いと思いました。
「React の代わりに CSS を使え」という話ではなく、ブラウザのレンダリングエンジン側の表現力がどこまで来ているかを確かめる、という距離感の自由研究でした。

7. 参考リンク

本記事で使った CSS 機能(2026/5 時点):

  • :has() - MDN ── 主要ブラウザ対応済み(Baseline 2023)
  • if() - MDN ── Chromium 系のみ(Chrome / Edge 137+)。Firefox / Safari は実装中
  • @property - MDN ── 主要ブラウザ対応済み(Baseline 2024)
株式会社ログラス テックブログ

Discussion