フォーム · お問い合わせフォーム

未保存の変更を見失わせないSaaS設定画面

保存した一覧を見る 0 →

このブラウザー内に保存します。別の端末との同期はなく、サイトデータを消すと保存も消えます。

印刷時は見本を静止画にし、プロンプトと解説を出力します。PDF保存はブラウザーの印刷画面から選べます。

Vibehaus · デザイン資料
https://vibe.itlibra.com/design/saas-settings-form/
完全無料・著作権フリー(CC0)· 料金¥0 · 商用利用・改変自由

プレビュー

画面に合わせる

指定幅で実際に組み替えて表示します。表示領域より広い場合は、この枠の中で横へスクロールできます。

vibe.itlibra.com/preview/saas-settings-form/

AIへの指示文

B2B SaaSのワークスペース設定画面を作ってください。ダークテーマです。

要件:
- 配色: 地 #0b0c0f、入力欄 #121318、浮いた面 #181a20、罫線 #24262d、文字 #ececf1、補助 #a0a3ad、アクセント #7c8cff、危険 #f0525a。
- 左に幅220pxの目次(一般/メンバー/通知/危険な操作)。スクロールに合わせて現在の節を aria-current で示す。危険な操作だけ赤い文字。
- 各節は2列。左に節の見出しと「この設定を変えると何が起きるか」の説明、右に入力欄。幅860px未満では1列にする。
- 一般: ワークスペース名、URL(「app.example.jp/」の固定部分を入力欄の左に付けた形)、タイムゾーンの選択。入力欄の下に形式の説明を小さく。
- メンバー: アイコン・名前・メールアドレス・役割の選択・「外す」ボタンの一覧。自分の行は外すボタンの代わりに「あなた」。役割の選択には、見えないラベルで「〇〇 の役割」を付ける。
- 通知: チェックボックスに、太字の見出しと補足の2段のラベル。
- 危険な操作: 薄い赤の面と赤い枠。消えるデータの件数を具体的に書き、ワークスペース名を正確に打ち込むまで削除ボタンを押せなくする。
- 未保存の変更バー: 画面下中央に浮かせ、変更した項目の数を出す。保存した値といまの値を比べて数えるので、元に戻したら件数が減り、0になったらバーが消える。「取り消す」と「変更を保存」を置く。
- 未保存のまま離れようとしたら beforeunload で止める。保存したら role="status" で「保存しました」と知らせる。
- フォント: Inter と日本語の system フォント。

バリエーション

ライト版

地を #ffffff、入力欄を #fafafa、罫線を #e4e4e7、文字を #18181b に。
危険な操作の面は #fef2f2、枠は #fecaca にする。

節ごとに保存する版

未保存の変更バーをやめ、各節の右下に「保存」ボタンを置く。
その節の値が変わったときだけ押せるようにし、保存すると節の見出しの横に「保存しました」を2秒出す。

このプレビューのコード

HTML・CSS・JavaScriptの1ファイルです。外部依存・広告・解析コードはありません。変更数・取り消し・ページ内の仮保存を試せます。再読み込みで初期値へ戻り、認証・永続保存・招待・削除は行いません。

CC0 · 改変・商用利用自由 · revision 447e19c1d36d

コードを表示
<!doctype html>
<!-- Vibehaus / CC0。HTML・CSS・JSの独立したデモ。保存はページ内だけ、外部通信なし。 -->
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="robots" content="noindex" />
    <title>未保存の変更を見失わせないSaaS設定画面 — Vibehaus プレビュー</title>
    <style>
      :root {
        --bg: #0b0c0f;
        --panel: #121318;
        --raised: #181a20;
        --line: #24262d;
        --line-2: #30333b;
        --ink: #ececf1;
        --muted: #a0a3ad;
        --quiet: #8a8e99;
        --accent: #7c8cff;
        --accent-ink: #0b0c0f;
        --danger: #f0525a;
        --danger-soft: rgba(240, 82, 90, 0.08);
        --font: "Inter", system-ui, -apple-system, "Hiragino Sans", "Hiragino Kaku Gothic ProN",
          "Yu Gothic Medium", "Yu Gothic", "Meiryo", sans-serif;
      }
      * { box-sizing: border-box; }
      body {
        margin: 0;
        font-family: var(--font);
        font-size: 14px;
        line-height: 1.75;
        letter-spacing: 0;
        color: var(--ink);
        background: var(--bg);
        -webkit-font-smoothing: antialiased;
      }
      button, input, select { font: inherit; color: inherit; }
      button { cursor: pointer; }

      .shell { display: grid; grid-template-columns: 220px minmax(0, 1fr); max-width: 1080px; margin: 0 auto; padding: 2rem 1.5rem 7rem; gap: 2.5rem; }
      .title { grid-column: 1 / -1; margin: 0; font-size: 22px; line-height: 1.4; }
      .title small { display: block; font-size: 13px; font-weight: 400; color: var(--muted); }

      /* ---------- 左の目次 ---------- */
      .snav { position: sticky; top: 1.5rem; align-self: start; }
      .snav ul { margin: 0; padding: 0; list-style: none; }
      .snav a {
        display: block; padding: 0.35rem 0.7rem;
        color: var(--muted); text-decoration: none; border-radius: 7px;
      }
      .snav a:hover { color: var(--ink); background: var(--raised); }
      .snav a[aria-current="true"] { color: var(--ink); font-weight: 600; background: var(--raised); }
      .snav a.danger { color: var(--danger); }

      /* ---------- 各節: 左に説明、右に入力 ---------- */
      .sec {
        display: grid;
        grid-template-columns: minmax(0, 1fr) minmax(0, 1.6fr);
        gap: 1.5rem 2.5rem;
        padding: 1.8rem 0;
        border-bottom: 1px solid var(--line);
      }
      .sec:first-child { padding-top: 0; }
      .sec h2 { margin: 0 0 0.3rem; font-size: 15px; line-height: 1.5; }
      .sec .desc { margin: 0; font-size: 13px; color: var(--muted); line-height: 1.8; }
      .fields { display: grid; gap: 1.1rem; }
      .field label { display: block; margin-bottom: 0.3rem; font-size: 13px; font-weight: 600; }
      .field .hint { margin: 0.3rem 0 0; font-size: 12px; color: var(--quiet); }
      .input, .select {
        width: 100%; padding: 0.5rem 0.7rem;
        background: var(--panel); border: 1px solid var(--line-2); border-radius: 8px; outline: 0;
      }
      .input:focus, .select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(124, 140, 255, 0.2); }
      .prefix { display: flex; }
      .prefix span {
        padding: 0.5rem 0.7rem; color: var(--quiet); white-space: nowrap;
        background: var(--raised); border: 1px solid var(--line-2); border-right: 0; border-radius: 8px 0 0 8px;
      }
      .prefix .input { border-radius: 0 8px 8px 0; }
      .check { display: flex; gap: 0.6rem; align-items: flex-start; }
      .check input { margin-top: 0.35rem; accent-color: var(--accent); }
      .check b { display: block; font-size: 13px; }
      .check span { font-size: 12px; color: var(--quiet); }

      /* ---------- メンバー ---------- */
      .members { width: 100%; border-collapse: collapse; }
      .members td { padding: 0.6rem 0; border-bottom: 1px solid var(--line); vertical-align: middle; }
      .members tr:last-child td { border-bottom: 0; }
      .person { display: flex; align-items: center; gap: 0.6rem; }
      .person i { display: grid; place-items: center; width: 30px; height: 30px; font-size: 12px; font-style: normal; font-weight: 700; color: var(--accent-ink); background: var(--accent); border-radius: 50%; }
      .person b { display: block; font-size: 13px; line-height: 1.5; }
      .person small { color: var(--quiet); }
      .members .select { width: auto; padding: 0.3rem 0.5rem; }
      .ghost { padding: 0.3rem 0.6rem; font-size: 12px; color: var(--muted); background: none; border: 1px solid var(--line-2); border-radius: 7px; }
      .ghost:hover { color: var(--ink); border-color: var(--quiet); }

      /* ---------- 危険な操作 ---------- */
      .danger-box { padding: 1rem 1.1rem; background: var(--danger-soft); border: 1px solid rgba(240, 82, 90, 0.4); border-radius: 10px; }
      .danger-box p { margin: 0 0 0.8rem; font-size: 13px; color: var(--muted); }
      .danger-box b { color: var(--ink); }
      .confirm { display: flex; gap: 0.5rem; flex-wrap: wrap; }
      .confirm .input { flex: 1; min-width: 180px; }
      .btn-danger { padding: 0.5rem 0.9rem; font-weight: 600; color: #fff; background: var(--danger); border: 0; border-radius: 8px; }
      .btn-danger:disabled { opacity: 0.35; cursor: not-allowed; }

      /* ---------- 未保存の変更バー ---------- */
      .savebar {
        position: fixed; left: 50%; bottom: 1.2rem; z-index: 10;
        display: flex; align-items: center; gap: 0.8rem;
        width: min(640px, calc(100% - 2rem));
        padding: 0.65rem 0.7rem 0.65rem 1.1rem;
        background: var(--raised);
        border: 1px solid var(--line-2);
        border-radius: 12px;
        box-shadow: 0 20px 40px -12px rgba(0, 0, 0, 0.6);
        translate: -50% 0;
        transition: translate 0.2s ease, opacity 0.2s ease;
      }
      .savebar[hidden] { display: flex; translate: -50% 160%; opacity: 0; visibility: hidden; }
      .savebar p { margin: 0; font-size: 13px; }
      .savebar .dot { width: 8px; height: 8px; flex: none; background: #f5b83d; border-radius: 50%; }
      .savebar .acts { display: flex; gap: 0.4rem; margin-left: auto; }
      .btn { padding: 0.45rem 0.9rem; font-weight: 600; color: var(--accent-ink); background: var(--accent); border: 0; border-radius: 8px; }
      .toast {
        width: max-content; max-width: calc(100% - 2rem);
        position: fixed; left: 50%; bottom: 1.2rem; z-index: 11; translate: -50% 0;
        padding: 0.5rem 1rem; font-size: 13px; color: var(--accent-ink); background: #6ee7a8; border-radius: 99px;
      }
      .toast:empty { display: none; }
      .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
      :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

      @media (max-width: 860px) {
        .shell { grid-template-columns: minmax(0, 1fr); gap: 1.2rem; }
        .snav { position: static; overflow-x: auto; }
        .snav ul { display: flex; gap: 0.3rem; }
        .snav a { white-space: nowrap; }
        .sec { grid-template-columns: minmax(0, 1fr); gap: 1rem; }
      }
      @media (prefers-reduced-motion: reduce) { .savebar { transition: none; } }
      .demo-note { position: relative; z-index: 6; max-width: 1100px; margin: 1rem auto; padding: 0.8rem 1rem; font-size: 13px; line-height: 1.8; letter-spacing: 0; border: 1px solid currentColor; }
      :focus-visible { outline: 3px solid #1d4ed8; outline-offset: 3px; box-shadow: 0 0 0 6px #fff; }
      @media (prefers-reduced-motion: reduce) {
        *, *::before, *::after { animation: none !important; transition: none !important; scroll-behavior: auto !important; }
        .plan:hover, .receipt:hover, .plan-cta:hover, .cta a:hover { transform: none; }
      }
    </style>
  </head>
  <body>
    <div class="shell">
      <h1 class="title">設定<small>Kakeru 請求 ・ ワークスペース</small></h1>

      <nav class="snav" aria-label="設定の目次">
        <ul>
          <li><a href="#general" aria-current="true">一般</a></li>
          <li><a href="#members">メンバー</a></li>
          <li><a href="#notify">通知</a></li>
          <li><a href="#danger" class="danger">危険な操作</a></li>
        </ul>
      </nav>

      <form id="form" novalidate onsubmit="return false;">
        <p class="desc">架空の設定画面です。保存はこのページ内だけで、再読み込みすると初期値に戻ります。招待・削除・外部への送信は行いません。</p>
        <section class="sec" id="general" aria-labelledby="h-general">
          <div>
            <h2 id="h-general">一般</h2>
            <p class="desc">ワークスペースの名前とURLです。URLを変えると、以前のURLのブックマークは使えなくなります。</p>
          </div>
          <div class="fields">
            <div class="field">
              <label for="ws-name">ワークスペース名</label>
              <input class="input" id="ws-name" name="name" value="Kakeru 請求" autocomplete="organization" />
            </div>
            <div class="field">
              <label for="ws-slug">URL</label>
              <div class="prefix"><span>app.example.jp/</span><input class="input" id="ws-slug" name="slug" value="kakeru" aria-describedby="slug-hint" spellcheck="false" /></div>
              <p class="hint" id="slug-hint">半角英数字とハイフンのみ。3〜32文字</p>
            </div>
            <div class="field">
              <label for="tz">タイムゾーン</label>
              <select class="select" id="tz" name="tz">
                <option selected>(UTC+09:00) 東京</option>
                <option>(UTC+00:00) 協定世界時</option>
                <option>(UTC-08:00) ロサンゼルス</option>
              </select>
            </div>
          </div>
        </section>

        <section class="sec" id="members" aria-labelledby="h-members">
          <div>
            <h2 id="h-members">メンバー</h2>
            <p class="desc">役割によってできることが変わります。管理者は請求と設定を変更でき、編集者は請求書の作成と送付までです。</p>
          </div>
          <div>
            <table class="members">
              <caption class="sr">メンバーと役割</caption>
              <tbody>
                <tr>
                  <td><span class="person"><i>佐</i><span><b>佐藤 美咲</b><small>sato@example.jp</small></span></span></td>
                  <td><label class="sr" for="r1">佐藤 美咲 の役割</label><select class="select" id="r1" name="role-1"><option selected>管理者</option><option>編集者</option><option>閲覧のみ</option></select></td>
                  <td style="text-align:right"><span class="ghost" style="border:0">あなた</span></td>
                </tr>
                <tr>
                  <td><span class="person"><i style="background:#6ee7a8">林</i><span><b>林 健太</b><small>hayashi@example.jp</small></span></span></td>
                  <td><label class="sr" for="r2">林 健太 の役割</label><select class="select" id="r2" name="role-2"><option>管理者</option><option selected>編集者</option><option>閲覧のみ</option></select></td>
                  <td style="text-align:right"><button class="ghost" type="button" aria-label="林 健太 を外す">外す</button></td>
                </tr>
                <tr>
                  <td><span class="person"><i style="background:#f5b83d">森</i><span><b>森 由衣</b><small>mori@example.jp</small></span></span></td>
                  <td><label class="sr" for="r3">森 由衣 の役割</label><select class="select" id="r3" name="role-3"><option>管理者</option><option>編集者</option><option selected>閲覧のみ</option></select></td>
                  <td style="text-align:right"><button class="ghost" type="button" aria-label="森 由衣 を外す">外す</button></td>
                </tr>
              </tbody>
            </table>
            <p style="margin:0.8rem 0 0"><button class="ghost" type="button">+ メンバーを招待</button></p>
          </div>
        </section>

        <section class="sec" id="notify" aria-labelledby="h-notify">
          <div>
            <h2 id="h-notify">通知</h2>
            <p class="desc">メールで知らせる出来事を選びます。個人ごとの設定は、各自のアカウント画面で変えられます。</p>
          </div>
          <div class="fields">
            <label class="check"><input type="checkbox" name="n-paid" checked /><span><b>入金を照合したとき</b><span>取引先ごとに1通。まとめて届くことはありません</span></span></label>
            <label class="check"><input type="checkbox" name="n-due" checked /><span><b>支払期限の3日前</b><span>未入金の請求書だけが対象です</span></span></label>
            <label class="check"><input type="checkbox" name="n-weekly" /><span><b>毎週月曜の集計</b><span>前の週の請求額・入金額・未回収</span></span></label>
          </div>
        </section>

        <section class="sec" id="danger" aria-labelledby="h-danger">
          <div>
            <h2 id="h-danger">危険な操作</h2>
            <p class="desc">取り消せない操作です。実行する前に、請求書のデータを書き出しておいてください。</p>
          </div>
          <div class="danger-box">
            <p>ワークスペースを削除すると、<b>請求書128件・取引先46件</b>を含むすべてのデータが消え、元に戻せません。</p>
            <label for="confirm-name" style="display:block;margin-bottom:0.35rem;font-size:13px;font-weight:600">確認のため <b>Kakeru 請求</b> と入力してください</label>
            <div class="confirm">
              <input class="input" id="confirm-name" autocomplete="off" spellcheck="false" />
              <button class="btn-danger" id="delete" type="button" disabled>ワークスペースを削除</button>
            </div>
          </div>
        </section>
      </form>
    </div>

    <div class="savebar" id="savebar" role="region" aria-label="未保存の変更" hidden>
      <span class="dot" aria-hidden="true"></span>
      <p><span id="dirtyCount">1</span>件の未保存の変更があります</p>
      <div class="acts">
        <button class="ghost" type="button" id="reset">取り消す</button>
        <button class="btn" type="button" id="save">変更を保存</button>
      </div>
    </div>
    <p class="toast" id="toast" role="status" aria-live="polite"></p>

    <script>
      const form = document.getElementById("form");
      const bar = document.getElementById("savebar");
      const dirtyCount = document.getElementById("dirtyCount");
      const toast = document.getElementById("toast");
      form.addEventListener("submit", event => event.preventDefault());

      // 保存済みの値を覚えておき、いまの値と比べて「変わった項目の数」を出す。
      // 一度触ったら未保存、とすると、元に戻しても保存バーが消えない
      const fields = [...form.querySelectorAll("input[name], select[name]")];
      const valueOf = (el) => (el.type === "checkbox" ? el.checked : el.value);
      let saved = new Map(fields.map((el) => [el, valueOf(el)]));

      function refresh() {
        const n = fields.filter((el) => valueOf(el) !== saved.get(el)).length;
        dirtyCount.textContent = n;
        bar.hidden = n === 0;
      }
      form.addEventListener("input", refresh);
      form.addEventListener("change", refresh);

      document.getElementById("reset").addEventListener("click", () => {
        for (const el of fields) {
          if (el.type === "checkbox") el.checked = saved.get(el);
          else el.value = saved.get(el);
        }
        refresh();
      });

      document.getElementById("save").addEventListener("click", () => {
        saved = new Map(fields.map((el) => [el, valueOf(el)]));
        refresh();
        toast.textContent = "デモ:このページ内に仮保存しました。再読み込みすると元に戻ります。";
        setTimeout(() => (toast.textContent = ""), 1800);
      });

      // 未保存のまま離れようとしたら止める
      addEventListener("beforeunload", (e) => {
        if (!bar.hidden) e.preventDefault();
      });

      // 取り消せない操作は、名前を打ち込むまで押せない
      const confirmInput = document.getElementById("confirm-name");
      const del = document.getElementById("delete");
      confirmInput.addEventListener("input", () => {
        del.disabled = confirmInput.value.trim() !== "Kakeru 請求";
      });
      del.addEventListener("click", () => {
        toast.textContent = "デモ:削除は行っていません。";
      });
      form.querySelectorAll('.members button, #members > div button').forEach(button => {
        button.addEventListener("click", () => {
          toast.textContent = "デモ:メンバーの招待・削除は行っていません。";
        });
      });

      // 見本として、開いた時点でワークスペース名を1か所書き換えた状態にしておく
      document.getElementById("ws-name").value = "Kakeru 請求 本社";
      refresh();

      // 目次の現在地をスクロールに合わせる
      const links = [...document.querySelectorAll(".snav a")];
      const io = new IntersectionObserver((entries) => {
        for (const en of entries) {
          if (!en.isIntersecting) continue;
          links.forEach((a) => a.setAttribute("aria-current", String(a.hash === "#" + en.target.id)));
        }
      }, { rootMargin: "-20% 0px -70% 0px" });
      document.querySelectorAll(".sec").forEach((s) => io.observe(s));
    </script>
  </body>
</html>

2026年10月14日、Chromiumで320・390・768・1280pxの描画とキーボードのフォーカス表示を確認しました。他のブラウザは未確認です。利用先のブラウザと実データでも確認してください。

概要

設定画面は、SaaSの中でいちばん慎重に触られる場所です。請求の宛先を変える、メンバーの権限を変える、場合によってはワークスペースそのものを消す。どれも間違えたときの損害が大きい操作で、利用者は常に「本当に大丈夫か」と構えています。この画面の設計は、その不安を1つずつ潰していく作業です。

何が変わったかを伝え続ける

設定を変えたのに保存を押し忘れて画面を離れる。これは設定画面で最も多い事故です。変更があることを画面の下に浮かせて常に見せ、離れようとしたら止めます。

崩れる形1か所でも触ったら未保存にする

入力を元に戻しても未保存のまま残るので、何が変わったのか分からなくなります。結局、全部を見直してから保存することになります。

効く形保存済みの値と比べて数える

変わった項目の数を出し、元に戻せば減り、0になれば消えます。いま保存すると何件変わるのかが常に分かる状態です。

説明は入力欄の外に出す

各節を2列に分け、左に「この設定を変えると何が起きるか」、右に入力欄を置きます。URLを変えると以前のブックマークが使えなくなる、役割によってできることが変わる。こうした結果は、入力欄の下に小さく書いても読まれません。

取り消せない操作は、手を止めさせる

削除のような取り消せない操作は、消えるものの件数を具体的に示し、名前を打ち込むまで押せなくする。「はい」を押すだけの確認は、読まれずに押されます。

この仕組みは強い抵抗を生むので、本当に取り消せない操作にだけ使います。メンバーを外す程度の操作にまで使うと、確認そのものが読み飛ばされるようになります。

1 : 1.6説明の列と入力の列の幅。説明が読める幅を残す
件数未保存バーに出すもの。「変更があります」だけでは足りない
名前の入力取り消せない操作にだけ課す確認

ダークテーマでは、入力欄と地の明度差が小さくなりがちです。入力欄は地より一段明るい面にし、フォーカスしたときは枠の色と薄い光の輪の2つで示すと、どこに入力しているかを見失いません。

フォームの構成要素

フォームの構成要素 ラベル ── 入力欄の上に置く label と for / id で結ぶ 補助説明 ── 入力する前に読ませる エラーは入力欄の直後に 送信は1つ。取り消しは目立たせない 必須は「※」ではなく言葉で書く ── 記号は読み上げで伝わらないことがある 項目数がそのまま完了率に効く。減らせない項目だけを残す
フォームはラベルの位置と、エラーの置き場所で使いやすさが決まります。プレースホルダをラベル代わりにすると、入力後に何の欄か分からなくなります。

使いどころ

  • SaaSのワークスペース・組織・アカウントの設定
  • 社内管理ツールの環境設定
  • 権限と招待を扱うメンバー管理
  • 削除や解約など取り消せない操作を含む画面

このデザインの分類

カテゴリ
フォーム
スタイル
SaaS / ダーク / ミニマル

背景と狙いどころ

よくある質問

1か所触っただけで未保存扱いにしてはだめ?
元に戻しても未保存のままになるので、利用者は何が変わったのか分からなくなります。保存済みの値といまの値を比べて、違う項目の数を数えてください。0に戻ればバーは消えます。
削除の確認はダイアログではだめ?
「はい」を押すだけのダイアログは、読まずに押されます。消える件数を示したうえで名前を打ち込ませると、手を止めて考える時間が生まれます。本当に取り消せない操作にだけ使ってください。
説明を左、入力を右にするのはなぜ?
設定を開く人の多くは「これを変えたら何が起きるか」を知りたいからです。説明を入力欄の下に小さく置くと読まれません。左の列に独立させると、入力欄を眺める前に目に入ります。

この画面を実装する

動くサンプルとコピペできるコードを、技術別にまとめています。