設定画面(変更検知+保存バー)

応用例 中級

この画面パターンについて

SaaSや社内ツールの「設定」ページで定番の、変更検知型の設定画面パターンです。 項目を変更すると画面下部に固定保存バーがスライドインし、「保存」か「破棄」を選ぶまで表示され続けます。 保存ボタンを常設せず「変更があるときだけ現れる」ことで、保存忘れに気づける画面を実装します。

こんな場面で使えます

  • ユーザー設定・プロフィール — 表示名・言語・通知の好みを変更する
  • 管理者向けのシステム設定 — 動作オプションをまとめて切り替える
  • 通知設定 — ON/OFFをいくつか試してから一括保存する

この画面で使っているUIコンポーネント

#パーツこの画面での役割
1セクションナビアカウント/通知/表示の切り替え
2テキストボックス表示名の入力(必須・30文字以内)
3セレクトボックス言語・タイムゾーンの選択
4トグルスイッチ群通知設定3項目のON/OFF
5ラジオボタンテーマの3択
6変更検知(dirty判定)現在値と保存済み値の比較
7固定保存バー変更時に画面下部へスライドイン
8保存完了トースト保存のフィードバック

実装のポイント・注意点

核は dirty 判定です。入力イベントで旗を立てる方式だと「変更して手で元に戻した」ケースでバーが消えません。 各入力に data-key 属性を付け、collectCurrent() で現在値オブジェクトを集めて保存済み値と比較する方式なら、 判定は Object.keys(saved).some(k => current[k] !== saved[k]) の1行で済み、項目を増やしてもJSの修正がほぼ不要になります。 同じ data-key 走査は破棄時の値復元 applySettings() にもそのまま使い回せます。 保存バーは transform: translateY のスライドで出し入れし、表示中はコンテンツ末尾に padding-bottom を確保して、バーが最下部の設定項目を隠さないようにします。

なお、トグル1つで完結する設定は変更と同時に保存する「即時保存」方式もありますが、複数項目をまとめて変える設定画面では「変更 → 確認 → 保存」のバー方式のほうが事故が少なく済みます。 保存ボタン常設+確認ダイアログ型の編集フォーム画面と比べて、自分の案件に合うほうを選んでください。 デモのテーマ設定は値の保持のみで実際の配色は変えません。配色まで切り替えたい場合はダークモード切替を参照してください。

8個のUIコンポーネントをHTML・CSS・バニラJavaScriptのみで組み合わせており、 フレームワーク不要で画面ごとコピペして使えます。

動作サンプル

設定画面(変更検知+保存バー)のデモ画面 動作サンプルを別ウィンドウで確認 ↗

試してみる:

  • どれかのトグルを変更して、保存バーがスライドインすることを確認
  • 変更した項目を手で元の値に戻すと、バーが消えること(値比較の変更検知)を確認
  • 変更したまま別セクションへ移動しても、変更とバーが保持されることを確認

そのほかの操作も自由に試してみてください。

サンプルソース

3つのファイルを同じフォルダに保存し、ブラウザで index.html を開くと動作確認できます。
ファイル名:index.html / style.css / script.js
保存時の文字コードは UTF-8 を指定してください。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>設定画面 サンプル</title>
  <link rel="stylesheet" href="./style.css">
</head>
<body>

<div class="settings-screen">
  <h1 class="screen-title">設定</h1>

  <div class="settings-layout">
    <!-- ===== セクションナビ ===== -->
    <nav class="settings-nav" aria-label="設定セクション">
      <button type="button" class="nav-item is-active" data-section="account">アカウント</button>
      <button type="button" class="nav-item" data-section="notification">通知</button>
      <button type="button" class="nav-item" data-section="display">表示</button>
    </nav>

    <!-- ===== セクション本体 ===== -->
    <div class="settings-body" id="settingsBody">

      <!-- アカウント -->
      <section class="settings-section" data-section="account">
        <h2 class="section-title">アカウント</h2>
        <div class="setting-item">
          <div class="setting-label">
            <label for="displayName">表示名</label>
            <p class="setting-desc">他のユーザーに表示される名前です(30文字以内)</p>
          </div>
          <div class="setting-control">
            <input type="text" id="displayName" data-key="displayName" maxlength="30">
          </div>
        </div>
        <div class="setting-item">
          <div class="setting-label">
            <label for="language">言語</label>
            <p class="setting-desc">画面の表示言語を選択します</p>
          </div>
          <div class="setting-control">
            <select id="language" data-key="language">
              <option value="ja">日本語</option>
              <option value="en">English</option>
            </select>
          </div>
        </div>
        <div class="setting-item">
          <div class="setting-label">
            <label for="timezone">タイムゾーン</label>
            <p class="setting-desc">日時の表示に使用します</p>
          </div>
          <div class="setting-control">
            <select id="timezone" data-key="timezone">
              <option value="Asia/Tokyo">Asia/Tokyo</option>
              <option value="UTC">UTC</option>
              <option value="America/New_York">America/New_York</option>
            </select>
          </div>
        </div>
      </section>

      <!-- 通知 -->
      <section class="settings-section" data-section="notification" hidden>
        <h2 class="section-title">通知</h2>
        <div class="setting-item">
          <div class="setting-label">
            <span>メール通知</span>
            <p class="setting-desc">重要なお知らせをメールで受け取る</p>
          </div>
          <div class="setting-control">
            <label class="toggle">
              <input type="checkbox" data-key="notifyEmail">
              <span class="toggle-track"></span>
            </label>
          </div>
        </div>
        <div class="setting-item">
          <div class="setting-label">
            <span>プッシュ通知</span>
            <p class="setting-desc">ブラウザのプッシュ通知を受け取る</p>
          </div>
          <div class="setting-control">
            <label class="toggle">
              <input type="checkbox" data-key="notifyPush">
              <span class="toggle-track"></span>
            </label>
          </div>
        </div>
        <div class="setting-item">
          <div class="setting-label">
            <span>週次レポート</span>
            <p class="setting-desc">利用状況のサマリーを毎週受け取る</p>
          </div>
          <div class="setting-control">
            <label class="toggle">
              <input type="checkbox" data-key="notifyWeekly">
              <span class="toggle-track"></span>
            </label>
          </div>
        </div>
      </section>

      <!-- 表示 -->
      <section class="settings-section" data-section="display" hidden>
        <h2 class="section-title">表示</h2>
        <div class="setting-item">
          <div class="setting-label">
            <span>テーマ</span>
            <p class="setting-desc">画面の配色を選択します</p>
          </div>
          <div class="setting-control">
            <div class="radio-group" role="radiogroup" aria-label="テーマ">
              <label><input type="radio" name="theme" value="light" data-key="theme"> ライト</label>
              <label><input type="radio" name="theme" value="dark" data-key="theme"> ダーク</label>
              <label><input type="radio" name="theme" value="system" data-key="theme"> システムに合わせる</label>
            </div>
          </div>
        </div>
        <div class="setting-item">
          <div class="setting-label">
            <span>コンパクト表示</span>
            <p class="setting-desc">行間を詰めて一覧の表示件数を増やす</p>
          </div>
          <div class="setting-control">
            <label class="toggle">
              <input type="checkbox" data-key="compactMode">
              <span class="toggle-track"></span>
            </label>
          </div>
        </div>
      </section>

    </div>
  </div>

  <!-- ===== 固定保存バー ===== -->
  <div class="save-bar" id="saveBar" aria-live="polite">
    <div class="save-bar-texts">
      <p class="save-bar-text">保存されていない変更があります</p>
      <p class="save-bar-error" id="saveBarError" hidden></p>
    </div>
    <div class="save-bar-actions">
      <button type="button" class="btn-secondary" id="discardBtn">破棄</button>
      <button type="button" class="btn-primary" id="saveBtn">保存する</button>
    </div>
  </div>

  <!-- ===== トースト ===== -->
  <div class="toast" id="toast" role="status" hidden></div>
</div>

<script src="./script.js"></script>
</body>
</html>
/* ===== 設定画面(変更検知+保存バー) — style.css ===== */
*, *::before, *::after { box-sizing: border-box; }

:root {
  --color-primary: #2B7FE8;
  --color-danger:  #D64545;
  --color-text:    #1E293B;
  --color-muted:   #64748B;
  --color-border:  #D0D7E0;
  --color-bg:      #F4F6F9;
  --color-card:    #FFFFFF;
  --savebar-height: 72px; /* 保存バー表示中に本文へ確保する余白 */
}

body {
  margin: 0;
  font-family: "Hiragino Kaku Gothic ProN", "Hiragino Sans", Meiryo, sans-serif;
  background: var(--color-bg);
  color: var(--color-text);
}

/* ===== 画面レイアウト ===== */
.settings-screen {
  max-width: 920px;
  margin: 0 auto;
  padding: 24px 20px 48px;
}

.screen-title {
  font-size: 22px;
  margin: 0 0 16px;
}

.settings-layout {
  display: grid;
  grid-template-columns: 200px 1fr;
  gap: 24px;
  align-items: start;
}

/* ===== セクションナビ ===== */
.settings-nav {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.nav-item {
  padding: 10px 14px;
  font-size: 14px;
  font-family: inherit;
  text-align: left;
  color: var(--color-muted);
  background: transparent;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  transition: background 0.15s, color 0.15s;
}

.nav-item:hover { background: #E9EEF4; }

.nav-item.is-active {
  color: var(--color-primary);
  background: #E3EEFC;
  font-weight: 700;
}

/* ===== セクション本体 ===== */
.settings-section {
  background: var(--color-card);
  border: 1px solid var(--color-border);
  border-radius: 8px;
  padding: 8px 24px;
}

.section-title {
  font-size: 16px;
  margin: 16px 0 8px;
}

/* 保存バー表示中はバーが最下部の項目を隠さないよう余白を足す */
.settings-body.has-savebar { padding-bottom: var(--savebar-height); }

/* ===== 設定項目の行 ===== */
.setting-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 16px;
  padding: 16px 0;
  border-bottom: 1px solid #E6EBF1;
}

.setting-item:last-child { border-bottom: none; }

.setting-label label,
.setting-label span {
  font-size: 14px;
  font-weight: 700;
}

.setting-desc {
  margin: 4px 0 0;
  font-size: 12px;
  color: var(--color-muted);
}

/* ===== 入力部品(テキスト・セレクト) ===== */
.setting-control input[type="text"],
.setting-control select {
  width: 220px;
  max-width: 100%;
  padding: 8px 10px;
  font-size: 14px;
  font-family: inherit;
  color: var(--color-text);
  background: var(--color-card);
  border: 1px solid var(--color-border);
  border-radius: 6px;
}

.setting-control input:focus-visible,
.setting-control select:focus-visible {
  outline: 2px solid var(--color-primary);
  outline-offset: -1px;
}

/* ===== ラジオボタン ===== */
.radio-group {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.radio-group label {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-size: 14px;
  font-weight: 400;
  cursor: pointer;
}

.radio-group input[type="radio"] { accent-color: var(--color-primary); }

/* ===== トグルスイッチ ===== */
.toggle {
  display: inline-flex;
  align-items: center;
  cursor: pointer;
}

/* チェックボックス本体は視覚的に隠す(キーボード操作は残す) */
.toggle input {
  position: absolute;
  opacity: 0;
  width: 1px;
  height: 1px;
}

.toggle-track {
  position: relative;
  flex-shrink: 0;
  width: 44px;
  height: 24px;
  border-radius: 9999px;
  background: #C3CCD6;
  transition: background 0.2s;
}

.toggle-track::after {
  content: "";
  position: absolute;
  top: 2px;
  left: 2px;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
  transition: left 0.2s;
}

.toggle input:checked + .toggle-track { background: var(--color-primary); }
.toggle input:checked + .toggle-track::after { left: 22px; }

.toggle input:focus-visible + .toggle-track {
  outline: 2px solid var(--color-primary);
  outline-offset: 2px;
}

/* ===== 固定保存バー ===== */
.save-bar {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 10;
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 16px;
  padding: 14px 24px;
  background: var(--color-card);
  box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.12);
  /* 基本位置は画面外。is-visible でスライドイン */
  transform: translateY(100%);
  transition: transform 0.25s ease;
}

.save-bar.is-visible { transform: translateY(0); }

.save-bar-text {
  margin: 0;
  font-size: 14px;
  font-weight: 700;
}

.save-bar-error {
  margin: 4px 0 0;
  font-size: 12px;
  color: var(--color-danger);
}

.save-bar-actions {
  display: flex;
  flex-shrink: 0;
  gap: 12px;
}

/* ===== ボタン ===== */
.btn-primary,
.btn-secondary {
  padding: 10px 22px;
  font-size: 14px;
  font-family: inherit;
  border-radius: 6px;
  cursor: pointer;
  transition: background 0.15s, border-color 0.15s;
}

.btn-primary {
  color: #fff;
  background: var(--color-primary);
  border: 1.5px solid var(--color-primary);
}

.btn-primary:hover { background: #1D6AD0; }

.btn-secondary {
  color: var(--color-muted);
  background: var(--color-card);
  border: 1.5px solid var(--color-border);
}

.btn-secondary:hover {
  background: var(--color-bg);
  border-color: #9AA5B4;
}

/* ===== トースト ===== */
.toast {
  position: fixed;
  bottom: 88px; /* 保存バーの退場アニメと重ならない高さに出す */
  right: 24px;
  z-index: 20;
  padding: 12px 20px;
  font-size: 14px;
  color: #fff;
  background: #1E293B;
  border-radius: 8px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
  animation: toast-in 0.2s ease;
}

@keyframes toast-in {
  from { opacity: 0; transform: translateY(8px); }
  to   { opacity: 1; transform: none; }
}

/* hidden 属性を確実に効かせる(display 指定との競合対策) */
.settings-screen [hidden] { display: none !important; }

/* ===== レスポンシブ(768px以下) ===== */
@media (max-width: 768px) {
  /* ナビを上部の横並びタブに変形する */
  .settings-layout { grid-template-columns: 1fr; gap: 12px; }

  .settings-nav {
    flex-direction: row;
    gap: 8px;
    border-bottom: 1px solid var(--color-border);
    padding-bottom: 8px;
  }

  .nav-item { text-align: center; }

  /* 入力欄をラベルの下に回り込ませる */
  .setting-item {
    flex-direction: column;
    align-items: stretch;
    gap: 10px;
  }

  .setting-control input[type="text"],
  .setting-control select { width: 100%; }

  .save-bar {
    flex-direction: column;
    align-items: stretch;
    gap: 10px;
  }

  .save-bar-actions { justify-content: flex-end; }
}
/* =====================================================
   設定画面(変更検知+保存バー)のスクリプト

   仕組み:各コントロールに data-key を付け、collectCurrent() が
   data-key を走査して現在値オブジェクトを集める。input / change の
   たびに保存済み値(savedSettings)と比較し、1項目でも違えば
   保存バーをスライドイン、すべて一致すればスライドアウトする。
   イベントフラグではなく値の比較なので「変更して手で元に戻した」
   ケースでもバーが正しく消える。破棄は同じ data-key 走査で
   保存済み値を書き戻すだけ(applySettings)。
   ===================================================== */

// ===== 設定値 =====
var TOAST_DURATION_MS = 3000; // トーストの表示時間
// 表示名の30文字制限は HTML 側の maxlength="30" で制御している

// 保存済みの設定値(初期値)。サーバーから取得した値を想定
var savedSettings = {
  displayName: 'サンプル 太郎',
  language: 'ja',
  timezone: 'Asia/Tokyo',
  notifyEmail: true,
  notifyPush: false,
  notifyWeekly: true,
  theme: 'light',
  compactMode: false
};

// ===== DOM要素 =====
var controls     = document.querySelectorAll('[data-key]');
var navItems     = document.querySelectorAll('.nav-item');
var sections     = document.querySelectorAll('.settings-section');
var settingsBody = document.getElementById('settingsBody');
var saveBar      = document.getElementById('saveBar');
var saveBarError = document.getElementById('saveBarError');
var saveBtn      = document.getElementById('saveBtn');
var discardBtn   = document.getElementById('discardBtn');
var toastEl      = document.getElementById('toast');

// ===== 現在値の収集・書き戻し(data-key 走査で共通化) =====
// 全コントロールを走査して { displayName: '...', notifyEmail: true, ... } を作る。
// 項目を増やすときは HTML に data-key 付きで足すだけでよい
function collectCurrent() {
  var current = {};
  controls.forEach(function (el) {
    if (el.type === 'checkbox') {
      current[el.dataset.key] = el.checked;
    } else if (el.type === 'radio') {
      if (el.checked) { current[el.dataset.key] = el.value; }
    } else {
      current[el.dataset.key] = el.value;
    }
  });
  return current;
}

// 設定値オブジェクトを全コントロールに書き戻す(初期表示と「破棄」で共用)
function applySettings(settings) {
  controls.forEach(function (el) {
    var value = settings[el.dataset.key];
    if (el.type === 'checkbox') {
      el.checked = value;
    } else if (el.type === 'radio') {
      el.checked = (el.value === value);
    } else {
      el.value = value;
    }
  });
}

// ===== 変更検知(dirty判定) =====
// 値の比較で判定する。1項目でも保存済み値と違えばバーを出す
function updateDirtyState() {
  var current = collectCurrent();
  var isDirty = Object.keys(savedSettings).some(function (key) {
    return current[key] !== savedSettings[key];
  });
  saveBar.classList.toggle('is-visible', isDirty);
  // バーが最下部の設定項目を隠さないよう本文に余白を確保する
  settingsBody.classList.toggle('has-savebar', isDirty);
  hideError();
}

// どれかの項目を操作 → そのたびに dirty を再評価する
controls.forEach(function (el) {
  var eventName = (el.type === 'text') ? 'input' : 'change';
  el.addEventListener(eventName, updateDirtyState);
});

// ===== セクションナビ =====
// ナビ項目クリック → 対応セクションだけ表示・ナビのアクティブを切り替え。
// 入力値はDOMに残るため、別セクションへ移動しても変更とバーは保持される
navItems.forEach(function (item) {
  item.addEventListener('click', function () {
    switchSection(item.dataset.section);
  });
});

function switchSection(name) {
  navItems.forEach(function (item) {
    item.classList.toggle('is-active', item.dataset.section === name);
  });
  sections.forEach(function (section) {
    section.hidden = (section.dataset.section !== name);
  });
}

// ===== 保存 =====
// 「保存する」クリック → 必須チェック → OKなら現在値を保存済み値にしてバー退場
saveBtn.addEventListener('click', function () {
  var current = collectCurrent();

  // 表示名の必須チェック。NGなら該当セクションへ切り替えてフォーカスを移す
  if (current.displayName.trim() === '') {
    showError('表示名を入力してください');
    switchSection('account');
    document.getElementById('displayName').focus();
    return;
  }

  savedSettings = Object.assign({}, current);
  updateDirtyState(); // 保存済み値と一致したのでバーが消える
  showToast('設定を保存しました');
});

// ===== 破棄 =====
// 「破棄」クリック → 全セクションの項目を保存済み値に戻してバー退場
discardBtn.addEventListener('click', function () {
  applySettings(savedSettings);
  updateDirtyState();
});

// ===== バー内のエラー表示 =====
function showError(message) {
  saveBarError.textContent = message;
  saveBarError.hidden = false;
}

function hideError() {
  saveBarError.hidden = true;
  saveBarError.textContent = '';
}

// ===== トースト =====
var toastTimerId = null;

// 3秒で自動的に消える。連続表示のときはタイマーを張り直す
function showToast(message) {
  toastEl.textContent = message;
  toastEl.hidden = false;
  if (toastTimerId) { clearTimeout(toastTimerId); }
  toastTimerId = setTimeout(function () {
    toastEl.hidden = true;
    toastTimerId = null;
  }, TOAST_DURATION_MS);
}

// ===== 初期化 =====
applySettings(savedSettings);

AI用プロンプト

以下のプロンプトをコピーしてAIに渡すと、同様の画面パターンを生成できます。

ChatGPTやClaudeにこのプロンプトを渡すと、同様の画面をゼロから生成・カスタマイズできます。設定項目の追加やセクション構成の変更など、要件を追記して使うのがおすすめです。

※ このプロンプトを使ってもデモとまったく同じ動作にならない場合があります。AIの解釈や生成タイミングによって差が出ることをご了承ください。

💡 jQuery・Vue・React など特定のライブラリで実装したい場合は、プロンプトの末尾に「〇〇を使って実装してください」と追記してください。

# 設定画面(変更検知+保存バー) 作成依頼

## 概要
左のセクションナビと設定項目群からなる設定画面を作成してください。
設定を変更すると画面下部に「保存されていない変更があります」の固定バーが
スライドインし、保存または破棄を選べるようにします。

## 要件
- 左ナビで「アカウント」「通知」「表示」の3セクションを切り替える2カラムレイアウト
- アカウント:表示名(テキスト・必須・30文字以内)/言語(セレクト)/タイムゾーン(セレクト)
- 通知:メール通知・プッシュ通知・週次レポートの3項目(トグルスイッチ+説明文)
- 表示:テーマ(ラジオ:ライト/ダーク/システムに合わせる)/コンパクト表示(トグル)
- 各設定項目は「ラベル+小さなグレーの説明文」を左、コントロールを右に置く行レイアウト
- いずれかの項目が保存済みの値と異なる状態になったら、画面下部に固定の保存バー
  (「保存されていない変更があります」+破棄ボタン+保存ボタン)をスライドインさせる
- 変更した項目を手で元の値に戻したら保存バーは消える(値の比較で判定する)
- 別セクションへ移動しても変更状態と保存バーは維持される
- 「保存する」で現在値を保存済み値として確定し、バーを消してトースト
  「設定を保存しました」を表示する。表示名が空の場合はバー内にエラーを表示して保存しない
- 「破棄」で全セクションの項目を保存済みの値に戻し、バーを消す

## 技術仕様
- HTML / CSS / バニラJavaScript で実装
- 外部ライブラリ:なし(トグルスイッチもCSSで自作する)
- 設定の保存先はJavaScript内の変数とする(サーバー送信・localStorageは使わない)
- レスポンシブ対応:必要(768px以下で左ナビを上部の横並びタブに変形)

## 動作詳細
- 各コントロールに data-key 属性を付け、現在値の収集と保存済み値の書き戻しを
  data-key の走査で共通化する(項目を増やしてもJSを変えずに済む構造にする)
- 変更検知は「現在値オブジェクトと保存済み値オブジェクトの比較」で行い、
  input / change イベントのたびに再評価する
- 保存バーは transform: translateY によるスライドイン/アウトで表示を切り替える

## 出力形式
HTML・CSS・JavaScriptを分けて出力してください。
各ファイルは単独でコピー&ペーストして使えるよう記述してください。