フォーム ステップ ui 9選 — 分岐と再開まで
フォーム ステップ uiとは、長い入力フォームを複数の画面に分けて、順番に答えを集める作りのことです。9つとも実際の値を読み、実際に検査し、入力のたびに保存します。空欄があるステップは先へ進まず、タブを閉じて開き直しても書きかけの位置から続けられます。
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 条件が揃うまで開かない次ステップのロック
- 02 ステップごとに高さを合わせるフォームの伸縮
- 03 選んだ答えで分かれるステップの経路
- 04 書きかけの位置を覚えて続きから戻す帯
- 05 打つそばから積み上がる横の要約
- 06 終わったステップが一行に畳まれる縦型ウィザード
- 07 送信前にすべて広げて見る確認画面
- 08 空欄が残ったステップへ送り返す送信
- 09 入力中の値を守るインラインの離脱確認
順番はフォームを作る人の都合ではなく、埋める人がつまずく順です。まず必須の同意が揃わず「次へ」ボタンがロックされたまま(01)、進むとステップごとに項目数が違って箱が跳ねます(02)。個人と事業者で道が分かれる分岐が現れ(03)、途中で離脱した人のために続きから書く帯が待っています(04)。ここまでが道案内です。次の2つは打ちながら確かめる仕事です。入力した値が横に積み上がり(05)、スマホでは終わったステップが一行に畳まれます(06)。最後の3つはフォームが人に筋を通す側です。送信前にすべて広げて見せ(07)、空欄があればそのステップへ送り返し(08)、戻るボタンで値が消える直前に一度だけ尋ねます(09)。
01条件が揃うまで開かない次ステップのロック
「次へ」ボタンを最初から押せると、反応がない理由が伝わりません。この部品は必須条件の3行がすべてオンになるまでボタンを aria-disabled でロックし、理由の文をボタンのすぐ横に置きます。最後の条件が埋まった瞬間、ロックが1フレームで外れ、理由の文が緑の「次のステップへ進めます」に変わります。
function lgSync() {
var done = lgBoxes.filter(function (b) {
return b.getAttribute('aria-checked') === 'true';
}).length;
var ready = done === lgBoxes.length;
lg.classList.toggle('is-ready', ready);
var next = document.getElementById('lg-next');
next.setAttribute('aria-disabled', ready ? 'false' : 'true');
}
02ステップごとに高さを合わせるフォームの伸縮
ステップ1は項目が2つ、ステップ2は3つ、ステップ3は1つしかない、となると箱の高さが境目ごとに跳ねます。この部品は次のパネルの実際の高さを getBoundingClientRect() で測って、長さ型で登録したカスタムプロパティ --h にまとめ、箱がその値まで流れます。--h が @property で登録されているおかげで中間値が生まれる点が核心です。
@property --h {
syntax: "<length>";
inherits: true;
initial-value: 63px;
}
.ah__box {
--h: 63px;
display: grid;
align-items: start;
height: var(--h);
overflow: hidden;
transition: --h 300ms cubic-bezier(.2, .8, .2, 1);
}
03選んだ答えで分かれるステップの経路
個人アカウントと事業者アカウントでは、必要なステップ数からして違います。ラジオを選んだ瞬間、ステップ配列をもう一度しぼって一覧を作り直します。事業者を選ぶと「事業者情報」の枠が割って入り、3枠が4枠になり、均等に割る枠数も配列の長さから書き直されます。今いる枠にだけ aria-current="step" が付きます。
function bpDraw() {
var picked = bp.querySelector('.bp__r:checked');
var biz = !!picked && picked.value === 'business';
var list = bpSrc.filter(function (n) {
return n.getAttribute('data-k') === 'all' || biz;
});
bpSteps.style.gridTemplateColumns =
'repeat(' + list.length + ', 1fr)';
list.forEach(function (n, i) {
if (i + 1 === bpAt) li.setAttribute('aria-current', 'step');
});
}
04書きかけの位置を覚えて続きから戻す帯
長い申込書はたいてい途中で離脱されます。この部品は入力のたびにステップ番号と値を localStorage に書き込み、戻ってきた人に「ステップ3から続ける」と書かれた帯を上から降ろします。続けるを押すと、保存してあったステップと値が本当にフォームへ蘇ります。iframe の中では保存が拒否され得るため、アクセスはすべて try/catch で包みました。
function rbRead() {
try {
return JSON.parse(localStorage.getItem(RB_KEY) || 'null');
} catch (e) { return null; }
}
function rbWrite(o) {
try { localStorage.setItem(RB_KEY, JSON.stringify(o)); } catch (e) {}
}
rbGo.textContent = rbGo.getAttribute('data-t')
.replace('{n}', rbDraft.step);
05打つそばから積み上がる横の要約
決済画面で一番不安なのは、自分が選んだものが見えない瞬間です。左の欄に文字を入れたその瞬間、右の要約行が同じように変わり、要約の横の修正ボタンを押すとその値を持つステップへ直接戻って該当欄にフォーカスを移します。要約パネルは aria-live="polite" で囲み、値が変わればスクリーンリーダーが読み直します。
ins.forEach(function (inp, i) {
inp.addEventListener('input', function () {
sp.classList.remove('is-demo');
rows[i].querySelector('.sp__v1').textContent =
inp.value.trim() || '—';
});
});
rows.forEach(function (row, i) {
row.querySelector('.sp__fix').addEventListener('click', function () {
sp.classList.remove('is-demo');
setStep(i);
ins[i].focus({ preventScroll: true });
});
});
06終わったステップが一行に畳まれる縦型ウィザード
スマホの画面にはステップを横に並べる幅がありません。この部品は縦に積み、値が埋まったステップを grid-template-rows が 1fr から 0fr へ畳んで、見出しの一行だけを残します。0fr でも縦パディングは残るので、本体に min-height: 0 と overflow: hidden の内側ラッパーを置くのがこのパターンの急所です。
.vz__body {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 300ms cubic-bezier(.2, .8, .2, 1),
visibility 300ms;
}
.vz__item.is-open .vz__body { grid-template-rows: 1fr; }
.vz__inner {
min-height: 0;
overflow: hidden;
}
07送信前にすべて広げて見る確認画面
最後の画面は、前のステップで受け取った値をグループごとに書き直して並べます。各グループの鉛筆ボタンは自分のステップ番号を覚えていて、押されたグループだけ一瞬浮き上がり、値が本物の入力欄に変わります。もう一度押すと、直した値がテキストへ戻って書き込まれます。
g.querySelectorAll('.rv__dd').forEach(function (dd) {
var old = dd.querySelector('input');
if (editing && !old) {
var inp = document.createElement('input');
inp.value = dd.textContent.trim();
dd.textContent = '';
dd.appendChild(inp);
} else if (!editing && old) {
dd.textContent = old.value.trim() || '—';
}
});
08空欄が残ったステップへ送り返す送信
大切なのは空欄のあるステップの名前ではなく、その場所へ返すことです。送信を押すとステップを先頭から走査し、value が空の最初のステップへ戻ります。そのステップ表示には感嘆符の警告アイコンが付き、欄には aria-invalid が付き、ステップ見出しにはフォーカスが移ります。送信は最後の画面で押すため、空欄が最初の画面に残っているとき戻り道が最も長くなるのが、このパターンの性質です。
function irSubmit() {
var first = -1;
for (var i = 0; i < irPanels.length; i++) {
var blank = irPanels[i].querySelector('.ir__in')
.value.trim() === '';
irPanels[i].querySelector('.ir__in')
.setAttribute('aria-invalid', blank ? 'true' : 'false');
if (blank && first < 0) first = i;
}
irGo(first, true);
irPanels[first].querySelector('.ir__title')
.focus({ preventScroll: true });
}
09入力中の値を守るインラインの離脱確認
入力途中に戻るを押すと画面を覆うモーダルが出がちですが、それでは今どんな瞬間だったのかが隠れます。この部品は代わりに、ボタンのすぐ上で確認行が開きます。値が空なら何も聞かずにすぐ戻り、値があるときだけ role="alertdialog" で尋ねます。開いている間、Tab は行の中の2つのボタンの間だけを巡ります。
lgdSheet.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { lgdOpen(false); return; }
if (e.key !== 'Tab') return;
e.preventDefault();
var i = lgdBtns.indexOf(document.activeElement);
lgdBtns[(i + 1) % 2].focus({ preventScroll: true });
});
lgdBack.addEventListener('click', function () {
if (lgdIn.value.trim() === '') {
lgd.classList.add('is-gone');
return;
}
lgdOpen(true);
});
どこで壊れるか — 落とし穴
一番よく壊れるのは、高さを決め打ちで固定値に埋め込む箇所です。02 の初版ではステップの高さを3つ書き込んでいましたが、説明文が1行増えただけで6番目の欄が箱の外へ出ました。今は次のステップの実際の高さをそのつど測って --h へ入れています。さらにこのカスタムプロパティは @property で長さ型として登録が必要です。未登録だと値を長さではなく単なる文字列と見るため、中間値がなく一気に飛びます。
2番目はオンとオフの強さの差です。06 で完成ステップを畳む瞬間、開くときは見出し1行ぶんしか変わらなかった画面が、畳むときは本体全体が消えてはるかに大きく変わりました。24コマで拾うプレビューは、最も大きく変わるコマを代表画像に選ぶため、畳む瞬間が全員の第一印象になってしまいます。ループでは両方のタイミングをずらし、畳む側にカットを使いませんでした。09 も同じ壁に当たりました。確認行が閉じるコマが開くコマより広く変わり、空の画面が代表画像に選ばれました。開く側を1目盛りの升で一気に点け、閉じる側は同じ量を4升に分けて1コマあたりの変化を減らして、ようやく主役のいるコマが残りました。
3番目は条件なしで離脱確認を付けることです。09 の初版は戻るボタンを押すと値と無関係に確認行が開きました。しかし何も入力していない人に「値が消えます」と尋ねるのは無用な邪魔です。確認行は value.trim() !== '' のときだけ開くように改め、空なら何も聞かずに戻ります。08 の空欄検査も同じ筋です。最後のステップだけで検査すると、前のステップの空欄は永遠に見えません。先頭からすべて走査する理由がそこにあります。
最後は狭い画面です。部品は記事の中で480×300の枠に入り、スマホでは320×200へ縮みます。舞台の余白を引くと縦に使えるのは174pxしかなく、320px 幅で9つの部品の高さを測ったところ、確認リストが最も大きく出ました。そこで狭い画面ではグループごとに最初の値だけを見せ、フォントを10px に下げ、縦の間隔は増やさず減らしました。9つ分の規則を1つのフォルダーにまとめてあり、その圧縮ファイルのパスワードは gyxeywcf です。中には今見ている素の版と React 版が同じ値で入っています。
アクセシビリティ
動きを減らす設定(prefers-reduced-motion: reduce)がオンの画面では、9つともループが止まり、結果の状態だけが残ります。ロックが外れたボタンはその色のままで、畳まれたステップは畳まれたまま要約の一行だけを見せ、離脱確認は開いたコマで止まります。動きが消えても「何が確定したか」は消えてはならないからです。
ステップフォームのアクセシビリティは2つの役割に集約されます。進み具合は role="progressbar" に aria-valuenow を実際の数値で更新し(02)、今のステップは aria-current="step" で示します(03)。値の異常は aria-invalid(08)、ロックは aria-disabled(01)、畳み込みは aria-expanded(06)で伝えます。フォーカスの扱いも正直です。ステップが変わるときは見出しにフォーカスを渡しますが、preventScroll と併用するのは、この部品たちがホーム一覧の iframe の中に入っていて、ガードなしに呼ぶと親ページごと引きずられるからです。
| 項目 | 見た目 | スクリーンリーダー | 操作 |
|---|---|---|---|
| 01 ステップのロック | ロックされたボタン→本来の色 | aria-disabled の切替 |
クリック |
| 02 高さの伸縮 | 箱が伸びて縮む | progressbar の値が更新 |
次・前・矢印キー |
| 03 分かれる経路 | 3枠↔4枠の再構成 | aria-current="step" |
ラジオ |
| 04 続きから書く帯 | 上から降りてくる帯 | aria-live による再開の案内 |
続ける・最初から |
| 05 横の要約 | 要約行が入力に同期 | 要約パネルの aria-live |
入力・修正・↑↓ |
| 06 縦型ウィザード | 終わったステップが一行に | aria-expanded・要約の読み上げ |
見出しクリック・Enter |
| 07 確認画面 | 浮かんで沈むグループ | 鉛筆ボタンの aria-label |
クリック |
| 08 空欄への送り返し | 警告アイコンと赤い欄 | aria-invalid・見出しフォーカス |
送信 |
| 09 離脱確認 | ボタンの上の確認行 | alertdialog の案内文 |
Escape・Tab |
複数ステップのフォームにどの属性を付けるべきかは、MDN の aria-current 解説にまとまっています。同じ系統の入力部品はフォームのカテゴリに、JavaScript が実際に動く部品はJS のカテゴリに集めてあります。
FAQ
ステップ分割は CSS だけでできますか?
画面を隠すところまではできます。:target やラジオで、押したときに見える枠を切り替えられます。ただし「空欄なら先へ進めない」と「入力値を覚える」は CSS の守備範囲外です。値を読み、比べ、保存する仕事は JavaScript のもので、9つすべてにその数行を同梱してあります。画面だけ入れ替えるフォームは、08 の検査を通れません。
localStorage には何を保存すればよいですか?
ステップ番号と入力値をそのまま入れたオブジェクト1つで十分です。04 は { step: 3, v: '東京都千代田区' } の形で保存しています。ただし同じブラウザを別の人が使うと下書きが混ざるため、フォームごとにキーを変え、iframe 内など拒否される環境に備えてアクセス全体を try/catch で包むのがこの部品の作法です。センシティブな値は保存しない方が安全です。
ステップが10を超えても大丈夫ですか?
02 と 06 はステップ数の制約を受けません。高さの伸縮も畳み込みも配列の長さしか見ないので、20ステップでも3ステップと同じ挙動です。ただし 03 のように一覧全体を作り直す方式は、ステップが増えるほど1回の再描画が重くなるため、分岐は前の数ステップに寄せて構成する方が無難です。10を超えるステップ表示を一目で数えられる人もいません。