会員登録 フォーム デザイン 入力欄9選 — キーボード操作対応
会員登録 フォーム デザインの良し悪しは送信ボタンではなく入力欄で決まります。ここに並べた9つは、登録画面が実際に求める入力欄で、メールアドレス欄からクリアボタンまで、コードを貼ればそのまま動きます。
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 アイコン・単位付き入力欄
- 02 フローティングラベル入力欄
- 03 日付の自動スラッシュ入力欄
- 04 国番号選択付き電話番号入力欄
- 05 住所オートコンプリート風入力欄
- 06 複数選択コンボボックス
- 07 メンション・ハッシュタグ強調入力欄
- 08 文字数カウンター入力欄
- 09 クリア(×)ボタン付き入力欄
並び順は画面に出てくる順です。一番上のメール欄(01)はアイコンと単位で何を書く欄か先に教え、名前欄(02)はラベルが上に浮いて場所を節約し、生年月日(03)と電話番号(04)は数字を人が読む形に組み直します。次は住所(05)や興味分野(06)のように一覧から選んで埋める欄です。最後の3つは自己紹介を書いている間に起きることです。メンションとハッシュタグ(07)は書きながら色が付き、文字数カウンター(08)は上限を見張り、クリアボタン(09)は書き直したくなったら一度に空にします。一覧が開く3つ(04·05·06)には role="listbox" と aria-activedescendant が付いて矢印キーが実際に効き、Escape は04·05·06·09の4つが受け付けます。
01アイコン・単位付き入力欄
左にメールアイコン、右に kg の単位が絶対配置で付いていて、触る前に何を書く欄か分かります。フォーカスが来ると 2px の枠とアイコン・単位が同じ色に変わります。アイコンと単位には aria-hidden="true" と pointer-events: none が付いていてクリックを横取りしません。
&__input {
box-sizing: border-box;
width: 100%;
border: 2px solid transparent;
border-radius: $r-control;
padding: $sp-3 $sp-4;
font-weight: 500;
font-size: clamp(14px, 4.2vw, 16px);
font-family: inherit;
color: $stage-ink;
background: #fff;
transition: border-color $duration $easing;
&--prefix { padding-left: $sp-10; }
&--suffix { padding-right: $sp-8; }
&:focus {
outline: none;
border-color: $color;
& ~ .affix__unit { color: $color; }
}
}
02フローティングラベル入力欄
空欄ではラベルが枠の真ん中にありますが、フォーカスか文字1つで translateY(-14px) scale(.78) で上に浮いて小さくなります。JS は使わず :focus と :not(:placeholder-shown) の2つのセレクタだけで作ってあり、placeholder=" " の1文字がこの仕掛けの材料です。
&__label {
position: absolute;
left: $sp-4;
top: 50%;
transform: translateY(-50%) scale(1);
transform-origin: left center;
font-size: 16px;
font-weight: 500;
font-family: inherit;
color: rgba(23, 20, 26, .48);
pointer-events: none;
transition: transform $duration $easing, color $duration $easing;
}
&__input:focus + &__label,
&__input:not(:placeholder-shown) + &__label {
transform: translateY(-14px) scale(.78);
color: $color;
}
03日付の自動スラッシュ入力欄
数字だけを打つと 2026/09/11 のように4桁目と6桁目の後にスラッシュが入ります。カレンダーを開くのではなく inputmode="numeric" を付けたテキスト欄なので、スマホでは最初に数字キーパッドが出ます。9桁目以降は切り捨てられます。
function format(raw) {
var digits = raw.replace(/\D/g, '').slice(0, 8);
var out = digits.slice(0, 4);
if (digits.length > 4) out += '/' + digits.slice(4, 6);
if (digits.length > 6) out += '/' + digits.slice(6, 8);
return out;
}
input.addEventListener('input', function () {
input.value = format(input.value);
});
04国番号選択付き電話番号入力欄
国旗ボタンで国を選ぶと国番号が切り替わり、番号欄はその国の桁の区切りで書き直されます。韓国 +82 は 3·4·4、アメリカ +1 は 3·3·4、日本 +81 は 2·4·4 で切られるため、同じ10桁でも読み方が変わります。
trigger.addEventListener('keydown', function (e) {
if (!open) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openListbox();
}
return;
}
if (e.key === 'ArrowDown') { e.preventDefault(); highlight((active + 1) % options.length); }
else if (e.key === 'ArrowUp') { e.preventDefault(); highlight((active - 1 + options.length) % options.length); }
else if (e.key === 'Enter') { e.preventDefault(); selectCountry(options[active].dataset.code); closeListbox(true); }
else if (e.key === 'Escape') { e.preventDefault(); closeListbox(true); }
else if (e.key === 'Tab') { closeListbox(false); }
});
05住所オートコンプリート風入力欄
住所を打つと、下に出る候補は固定された5行のうち一致するものだけが残り、矢印キーと Enter で選んで欄を埋められます。入力欄には role="combobox" と aria-autocomplete="list" が付いています。候補はあらかじめ決めた住所から選ぶ静的な一覧なので API キーは要りません。
input.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown') {
e.preventDefault();
if (!open && matches.length) { openList(); setActive(0); return; }
if (open && matches.length) setActive(Math.min(active + 1, matches.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (open) { if (active <= 0) setActive(-1); else setActive(active - 1); }
} else if (e.key === 'Enter') {
if (open && active > -1) { e.preventDefault(); selectOption(active, true); }
} else if (e.key === 'Escape') {
if (open) { e.preventDefault(); closeList(); setActive(-1); }
}
});
06複数選択コンボボックス
デザイン・開発・マーケティング・企画の4行から複数を選ぶと、選ぶたびにピル型のチップが入力欄の中に積み上がります。一覧には aria-multiselectable="true" が付いているので2つ目を選んでも1つ目が解除されません。チップは chip-pop のキーフレームで少し行き過ぎてから元の大きさに収まります。
@keyframes chip-pop {
0% { transform: scale(.5); opacity: 0; }
70% { transform: scale(1.06); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.multi__listbox { transition: none !important; }
.multi__trigger, .multi__chevron, .multi__check { transition: none; }
.multi__chip { animation: none; }
}
07メンション・ハッシュタグ強調入力欄
@名前 や #タグ を打った瞬間、その単語だけに色が付いて残りの文はそのままです。contenteditable は使わず、文字を隠した textarea の下に同じ幅と同じ折り返し規則の backdrop を重ねて、カーソルがあるべき場所に留まるようにしています。
&__backdrop,
&__input {
box-sizing: border-box;
width: 100%;
min-height: 96px;
max-height: 160px;
padding: $sp-3 $sp-4;
border: 2px solid transparent;
border-radius: $r-control;
font-weight: 500;
font-size: clamp(13px, 3.8vw, 15px);
line-height: 1.5;
font-family: inherit;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
}
08文字数カウンター入力欄
右下の数字が 0/80 から増えていき、残りが12文字以下になると数字と枠が赤くなります。上限そのものは HTML の maxlength="80" が守り、数字には aria-live="polite" が付いているため、画面を見ていない人にも今の文字数が読み上げられます。
function update() {
var len = area.value.length;
var remaining = MAX - len;
count.textContent = len + '/' + MAX;
if (remaining <= WARN) {
count.classList.add('is-warn');
area.classList.add('is-warn');
} else {
count.classList.remove('is-warn');
area.classList.remove('is-warn');
}
}
area.addEventListener('input', update);
09クリア(×)ボタン付き入力欄
1文字でも打つと右に丸い×ボタンが現れ、押すと欄が空になってフォーカスはその欄に残ります。ボタンは値がない間は opacity: 0 と pointer-events: none で完全に退避していて、中身ができた瞬間だけ戻ってきます。
&__clear {
position: absolute;
top: 50%;
right: $sp-2;
transform: translateY(-50%) scale(.5);
width: 24px;
height: 24px;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
background: rgba(23, 20, 26, .08);
color: $stage-ink;
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: opacity $duration $easing, transform $duration $easing, background $duration $easing;
&:hover, &:focus-visible { background: rgba(23, 20, 26, .16); }
}
どこで壊れるか — 落とし穴
壊れ方は縦方向で、幅 320px で現れました。07 と 08 はどちらも3行の textarea を中に持っていて、入力欄の最小の高さを 96px のままにするとカード全体がその時点のステージより 07 は 2px、08 は 3px 高くなり、下の枠が切れていました。480px のデモサイズでは余白が問題を隠していたため、広い画面を目で確認しただけではこの欠陥は見つかりませんでした。直し方は入力欄を流れから外すことではなく、画面の幅が 340px 以下に下がった瞬間から min-height を 96px から 88px へ下げる @media (max-width: 340px) ブロックを足すことでした。ラベルとヒントの文字はそのままに、入力欄の縦だけ 8px 縮めると、両方の項目からはみ出し判定が消えました。この規則を入れたソースは、解凍パスワード ttw779pg で開く zip の中に9つ分そろえて入っています。
アクセシビリティ
9つとも prefers-reduced-motion: reduce では transition を止めても状態はそのまま残します。枠とアイコンの色は切り替わりの動きなしで即座に変わり、浮いていたラベルは浮いた位置に立ったまま、06 のチップは animation: none で跳ねずにそのまま現れます。一覧が開く3つ(04·05·06)は aria-expanded で開閉を伝え、aria-activedescendant でフォーカスを移さずに指している行だけを変え、08 の文字数は aria-live="polite" で読み上げられます。フォーカスを戻すときに必ず focus({ preventScroll: true }) を使うのは、このデモが記事の中に iframe で埋め込まれているためで、素の focus() だと読んでいる人の画面が下へ引っ張られるからです。ここで使った入力属性の意味は MDN の input 要素の解説にまとまっています。
同じ場所で使える部品はフォーム・入力カテゴリに、入力が間違っていたときに震える反応はエラーカテゴリにまとめてあります。
FAQ
カレンダーが開く生年月日欄があるのに、なぜ自分で作るのですか
ブラウザのカレンダーは信用できますが、見た目がブラウザごとに違いますし、速く打つ人にとっては、開いて閉じる時間の分だけ入力がむしろ遅くなります。この欄は数字だけを受け付けて 2026/09/11 の形に組み立てるので、カレンダーなしで書式が崩れません。カレンダーが欲しい画面では両方を並べます。
9つともマウスなしで使えますか
使えます。01·02·03·07·08 は普通の input と textarea なので、ブラウザが無料でやってくれることをそのまま使います。一覧が開く 04·05·06 は矢印で移動し、Enter で確定し、Escape で閉じます。04 と 06 は矢印が両端で折り返し、Tab で離れると一覧が閉まります。09 は入力欄で Escape を押すと値が消えます。
React 版で上限や色を変えられますか
08 の文字数の上限は max という props で変えられて、初期値は 80 です。警告が始まる残り12文字はモジュール内の定数なので、変えるにはその行を書き換えます。色と速さは、バニラ版では style.scss の一番上にある $duration·$easing·$color の3行を、React 版では各 module.scss に直接書かれた値を変えます。