GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

タグ 入力 ui 9パターン

チップ一覧の末尾に「+3」が出る瞬間から見る — 行幅の値がチップ数を計算してあふれた分を畳み、カンマ区切りで貼り付けた文字列は区切り値で割った個数分のチップを一度に作り、文字数の値がチップ幅を計算して伸びる。これがタグ 入力 ui の実際の動きです。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

以下の9つのタグ入力はどれもデータを取得したり保存したりしません。localStorage も本物の時計もなく、Math.random も呼ばれていません。09番の候補一覧はHTMLに直接書かれた固定配列で、07番の色は文字コードを足した値から決まり、05番の自動デモは本物のClipboard APIに触れず、文字列をすでに持たせたpasteイベントを疑似的に発火させているだけです。ライブラリもCDNスクリプトも使わず、9つとも単体のindex.htmlだけで動きます。

タグ 入力 フォームの9パターンが1回のキー入力をチップに変える仕組み

9つのどれかに文字を打つと、同じ形が繰り返されます。何かが測られるか数えられ、チップ要素が作られるか変わり、短いCSSトランジションが見える部分を運びます。違うのは測られる対象です。01番はcanvas.measureTextで打った直後の文字列の幅を測ります。02番はすでにあるチップの数を数えます。06番はつかんだ指の位置を隣のチップの中心と比べ続けます。タグ uiデザインとして見ると、9つは同じ入場アニメーションの使い回しではなく、9つの別々の値が次にチップが何をするかを決め、CSSトランジションはJavaScriptがすでに計算した2つの状態の間を補間するだけです。

9つのタグチップパターンを面積・強度・動いたフレームで比べる

計算した値の違いは、画面の動く量にも表れます。下の表は各項目の측정.jsonを実測した値です — 累積面積はフレームごとに変化したピクセル比率を積み上げた数字、平均強度は変化したピクセルの明暗差の平均、動いたフレームは23フレーム中いくつで変化があったかを示します。

# パターン 累積面積 平均強度 動いたフレーム
01 文字数連動チップ幅 1.5% 93.0 10/23
02 上限超え振動ブロック 13.4% 119.6 15/23
03 重複ハイライト統合 11.5% 80.4 9/23
04 キーボード2段削除 3.4% 81.3 16/23
05 貼り付け分割 14.6% 66.1 17/23
06 ドラッグ並べ替え 7.9% 99.7 16/23
07 文字ハッシュ着色 11.9% 114.5 9/23
08 行あふれ折りたたみ 2.4% 121.3 6/23
09 入力補完候補 3.2% 163.7 6/23

01番と08番と09番の面積が小さいのは欠陥ではありません。チップの幅を数ピクセル変える・バッジ1つを出す・候補リストを差し替えるという動作そのものが画面の小さな範囲しか占めないからで、これは静的なカード一覧が動かないのと同じ理由です。動くのは入力の瞬間だけで、それ以外は静止しているのがタグ入力コンポーネントとしては自然な形です。05番と02番の面積が大きいのは、貼り付けや上限超えのように複数チップ・入力欄全体が一度に反応する場面だからです。

index.htmlからそのまま取った、パターンごとのコード

9つのコードはindex.htmlの該当関数をそのままsed -nで切り出したものです。行の入れ替え・省略・インデント変更は一切していません。

01文字数の値がタイプ中のチップ幅を広げる

入力した文字数からチップの内側の幅を測り直します。最初のフレームは文字数だけの概算値を使い、次のフレームでcanvas.measureTextの実測値に補正します。

入力した文字数だけ幅が伸びるチップ入力
新しいタブで開く

01 · 入力した文字数だけ幅が伸びるチップ入力 — 計測 累積面積 1.497% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

WIDTH_PER_CHARmeasureTextMAX_W
  function measureWidth(text) {
    // 計算の一層: canvas measureText で実際のグリフ幅を測る — 文字数の掛け算による概算ではない
    return ctx.measureText(text).width;
  }

  var target = '';
  function applyWidth(text, precise) {
    var guess = Math.min(MAX_W, Math.max(MIN_W, text.length * WIDTH_PER_CHAR + CH_PAD));
    draft.style.width = guess.toFixed(1) + 'px';
    if (precise) {
      syncFont();
      var real = Math.min(MAX_W, Math.max(MIN_W, measureWidth(text) + CH_PAD));
      requestAnimationFrame(function () {
        draft.style.width = real.toFixed(1) + 'px';
        wOut.textContent = Math.round(real);
      });
    } else {
      wOut.textContent = Math.round(guess);
    }
    nOut.textContent = text.length;
  }

02チップ数の値が上限超えの入力を揺らして止める

すでにあるチップの数をMAX_COUNTと毎回比べます。上限に達していれば新しいチップを作らず、入力欄全体にスプリング揺れだけを再生します。

上限を超えると揺れて止めるチップ入力
新しいタブで開く

02 · 上限を超えると揺れて止めるチップ入力 — 計測 累積面積 13.369% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

MAX_COUNTblockcount
  function count() { return row.querySelectorAll('.tm__chip').length; }

  function render() {
    countEl.textContent = count();
    warnEl.hidden = count() < MAX_COUNT;
  }

  function human() { if (root.classList.contains('is-demo')) root.classList.remove('is-demo'); }

  function block() {
    // 計算の一層: 個数を MAX_COUNT と比較し、すでに上限ならチップを作らずスプリング揺れだけ再生する
    row.classList.remove('tm__row--shake');
    void row.offsetWidth; // リフロー — 同じクラスを再度付けてもアニメーションが再生されるように
    row.classList.add('tm__row--shake');
    live.textContent = 'タグ' + MAX_COUNT + '個で上限到達';
    if (reduce) render();
  }

03正規化した文字列の値が新規追加の代わりに既存チップを点滅させる

新しく打った文字列を正規化し、既存の全チップと比べます。一致するものがあれば新しいチップを作らず、その既存チップに減衰スプリングのパルスをかけ直します。

同じタグを再入力すると既存チップが点滅する入力
新しいタブで開く

03 · 同じタグを再入力すると既存チップが点滅する入力 — 計測 累積面積 11.464% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

normfindDupmerge
  function norm(text) { return text.trim().toLowerCase(); }
  function chips() { return Array.prototype.slice.call(row.querySelectorAll('.td__chip')); }
  function render() { countEl.textContent = chips().length; }
  function human() { if (root.classList.contains('is-demo')) root.classList.remove('is-demo'); }

  function findDup(text) {
    // 計算の一層: 正規化した文字列を既存チップ全部と比べ、同じものを探す — 大文字小文字・前後の空白は区別しない
    var key = norm(text);
    var list = chips();
    for (var i = 0; i < list.length; i++) {
      if (norm(list[i].dataset.text) === key) return list[i];
    }
    return null;
  }

  function merge(chip) {
    // 計算の一層: 新しいチップを作らず、既存チップに減衰スプリングのパルスをかけ直す(reflowで再生を保証)
    chip.classList.remove('td__chip--merge');
    void chip.offsetWidth;
    chip.classList.add('td__chip--merge');
    dupEl.hidden = false;
    live.textContent = chip.dataset.text + 'は既にある';
    if (reduce) render();
  }

042段階の値が最後のチップを選択してから削除する

入力欄が空のときにバックスペースを押すと、まず末尾のチップを選択状態にします。選択済みの状態でもう一度押したときだけ、そのチップが実際に削除されます。

バックスペース2回で消えるチップ入力
新しいタブで開く

04 · バックスペース2回で消えるチップ入力 — 計測 累積面積 3.374% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

backspaceStepselectlastChip
  function remove(chip) {
    // 計算の一層2段: 選択済みチップにだけ退場アニメーション(回転+収縮)をかけ、終わってからDOMから外す
    selectedId = null;
    chip.classList.add('tb__chip--exit');
    var text = chip.dataset.text;
    window.setTimeout(function () {
      chip.remove();
      hint.textContent = '空欄でバックスペース — 1回目は選択';
      live.textContent = text + ' 削除・タグ' + chips().length + '個';
    }, reduce ? 0 : 320);
  }

  function backspaceStep() {
    // 計算の一層: 入力欄が空のときだけ反応する — 文字があればブラウザ標準の削除に任せる
    if (input.value) return;
    var last = lastChip();
    if (!last) return;
    if (selectedId === last.id) { remove(last); }
    else { select(last); }
  }

05区切りパターンの値が貼り付けた文字列を複数チップに割る

pasteイベントのclipboardDataから文字列を取り出し、区切り文字で分割します。navigator.clipboardは一度も読みません。

貼り付けた文字列を一度にチップへ分割する入力
新しいタブで開く

05 · 貼り付けた文字列を一度にチップへ分割する入力 — 計測 累積面積 14.601% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

DELIM_PATTERNsplitfilter
  function handlePaste(e) {
    // 計算の一層: paste イベントの clipboardData から文字列を取り出し、区切り文字で分割する — navigator.clipboard は読まない
    // e.isTrusted で本物のユーザー貼り付けと自動デモの模擬イベント(dispatchEvent)を区別する
    if (e.isTrusted) human();
    e.preventDefault();
    var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
    var parts = text.split(DELIM_PATTERN).map(function (s) { return s.trim(); }).filter(Boolean);
    if (parts.length > 1) splitCount++;
    parts.forEach(function (p, i) { addChip(p, i); });
    input.value = '';
    render();
    live.textContent = parts.length + '個のタグに分割';
  }

06ドラッグ位置の値が隣のチップと入れ替える

つかんだチップの横位置を隣のチップの中心位置と比べ続けます。その位置を越えると、2つのチップの順序をその場で入れ替えます。

ドラッグで順序を入れ替えるチップ入力
新しいタブで開く

06 · ドラッグで順序を入れ替えるチップ入力 — 計測 累積面積 7.892% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

checkSwapdoSwapswapCompensation
  function checkSwap() {
    var dr = dragEl.getBoundingClientRect();
    var dragCenter = dr.left + dr.width / 2;
    var prev = dragEl.previousElementSibling;
    if (prev) {
      var pr = prev.getBoundingClientRect();
      if (dragCenter < pr.left + pr.width / 2) { doSwap(prev, true); return; }
    }
    var next = dragEl.nextElementSibling;
    if (next) {
      var nr = next.getBoundingClientRect();
      if (dragCenter > nr.left + nr.width / 2) { doSwap(next, false); return; }
    }
  }

  function doSwap(sibling, moveBefore) {
    var oldLeft = dragEl.offsetLeft;
    if (moveBefore) { row.insertBefore(dragEl, sibling); }
    else { row.insertBefore(sibling, dragEl); }
    var newLeft = dragEl.offsetLeft;
    swapCompensation += (oldLeft - newLeft);
    applyTransform(lastDx);
    renderOrder();
    live.textContent = orderText();
  }

07文字ハッシュの値がチップの色を自分の文字だけで決める

チップの文字コードを合計した値をパレット数で割った余りで色を選びます。Math.randomは使わないので、同じ文字列はいつでも同じ色になります。

文字だけで色が決まるチップ入力
新しいタブで開く

07 · 文字だけで色が決まるチップ入力 — 計測 累積面積 11.941% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

hashIndexPALETTE_LENhash-c0
  // 計算の一層: 文字コード値を合計した値をパレット数で割った余りで色を選ぶ — 同じ文字は常に同じ余りになる
  function hashIndex(text) {
    var h = 0;
    for (var i = 0; i < text.length; i++) { h += text.charCodeAt(i) * (i + 1); }
    return h % PALETTE_LEN;
  }

  function addChip(text) {
    seq++;
    var idx = hashIndex(text);
    var chip = document.createElement('span');
    chip.className = 'tc__chip tc__chip--enter tc__chip--c' + idx;
    chip.id = 'tcChip' + seq;
    chip.innerHTML = '<span class="tc__chipText"></span><button type="button" class="tc__chipX">×</button>';
    chip.querySelector('.tc__chipText').textContent = text;
    chip.querySelector('.tc__chipX').setAttribute('aria-label', 'タグ削除: ' + text);
    chip.querySelector('.tc__chipX').addEventListener('click', function () { human(); chip.remove(); render(); });
    row.insertBefore(chip, input);
    requestAnimationFrame(function () { chip.classList.remove('tc__chip--enter'); });
    render();
    live.textContent = text + ' タグ、' + (idx + 1) + '番目の色';
  }

08行幅の値があふれたチップをバッジに畳む

1行に収まる幅をチップ幅の合計と比べ続けます。あふれた時点から先のチップを隠し、隠した数を「+N」バッジに書き込みます。

行があふれると「+N」に畳むチップ入力
新しいタブで開く

08 · 行があふれると「+N」に畳むチップ入力 — 計測 累積面積 2.418% · 動いたフレーム 6/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

BADGE_RESERVEGAPrelayout
    var sum = 0, shown = 0;
    for (var i = 0; i < chips.length; i++) {
      chips[i].style.display = '';
    }
    for (i = 0; i < chips.length; i++) {
      var w = chips[i].getBoundingClientRect().width;
      var addGap = shown > 0 ? GAP : 0;
      var withThis = sum + addGap + w;
      var remaining = chips.length - (i + 1);
      var reserve = remaining > 0 ? GAP + BADGE_RESERVE : 0;
      if (withThis + reserve <= containerWidth) { sum = withThis; shown++; }
      else { break; }
    }
    for (var j = shown; j < chips.length; j++) { chips[j].style.display = 'none'; }
    var hidden = chips.length - shown;
    if (hidden > 0) {
      badge.textContent = '+' + hidden;
      badge.classList.add('ov__badge--show');
    } else {
      badge.classList.remove('ov__badge--show');
    }
    widthEl.textContent = Math.round(containerWidth);

09重なりスコアの値が入力補完候補チップを並べ替える

入力した文字と候補一覧のそれぞれを重なる文字数で比べてスコアを付け、スコアの高い順に候補を並べ替えます。

入力値に近い順で候補を出すチップ入力
新しいタブで開く

09 · 入力値に近い順で候補を出すチップ入力 — 計測 累積面積 3.226% · 動いたフレーム 6/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

scoreTOP_NrenderList
  function renderList(query) {
    var scored = CANDIDATES.map(function (c) { return { text: c, score: score(query, c) }; });
    scored.sort(function (a, b) { return b.score - a.score; });
    var top = scored.slice(0, TOP_N);
    list.innerHTML = '';
    top.forEach(function (item, i) {
      var li = document.createElement('li');
      li.className = 'as__opt' + (i === 0 && item.score > 0 ? ' as__opt--lead' : '');
      li.setAttribute('role', 'option');
      li.setAttribute('tabindex', '0');
      li.textContent = item.text;
      li.addEventListener('click', function (e) { if (e.isTrusted) human(); commit(item.text); });
      li.addEventListener('keydown', function (e) { if (e.key === 'Enter') { human(); commit(item.text); } });
      list.appendChild(li);
    });
  }

バックスペース2回とaria-liveの読み上げ件数がアクセシビリティに返すもの

04番のバックスペース2段は、Deleteキー1つの誤爆でタグ一覧全体が減っていく事故を防ぐための仕組みです。1回目は選択状態にするだけで何も消えず、hint要素が「空欄でバックスペース — 1回目は選択」と伝えます。選択済みの状態でもう一度押したときだけ実際に消え、live要素が消えたタグ名と残り件数を読み上げます。スクリーンリーダー利用者にとって、何が起きたか分からないまま複数のタグが黙って消えるのが最も怖い体験だからです。

9つの入力全部がaria-live領域を持ち、チップが増えた・減った・上限に達した・重複した、といった変化のたびにテキストが更新されます。prefers-reduced-motionが有効なときは、スプリング揺れやトランジションのrequestAnimationFrameループを飛ばし、入力の直後に一度だけ状態を描き直します。動きを見せない代わりに、結果の状態だけを即座に確定させる形です。

検索フィルターとメール宛先入力とブログタグがこれを実際に使う場所

01番の文字数連動幅は、検索フィルターのタグやメール宛先入力のように、短い単語が次々に追加される場面に向きます。チップが窮屈にならず、常に文字の分だけ広がるので、長い単語と短い単語が混ざっても読みにくくなりません。

05番の貼り付け分割は、表計算ソフトからコピーしたメール一覧やコードのリストを、タグ入力欄に一度で流し込みたい場面に使えます。ユーザーがカンマ区切りの文字列を一つ貼り付けるだけで、区切りの数だけチップが並びます。

09番の入力補完候補は、CMSやブログの投稿画面で既存タグの再利用を促したいときに向きます。入力した文字に近い順で候補が並ぶので、似た名前のタグを新しく作ってしまう重複を減らせます。

決まった区切りパターンがセミコロン区切りの貼り付けで壊れる場所

05番のDELIM_PATTERNはカンマと改行だけにマッチする正規表現です。セミコロンでつないだ文字列を貼り付けると、区切りとして認識されず、一つの長いチップになってしまいます。日本語の住所録やCSVエクスポートではセミコロン区切りがよく使われるため、そのままのDELIM_PATTERNではタグが正しく分かれません。この落とし穴は下の変形表のようにパターンへセミコロンを加えることで壊れなくなります。

チップ幅・区切りパターン・バッジ幅の値を変えると何が変わるか

同じ9つの構造でも、値を1つ変えるだけで手触りが変わります。

バリエーション 変更した値 感触の変化
チップ幅-文字数比率値を調整する 01 WIDTH_PER_CHAR 6.4 → 9.5 WIDTH_PER_CHARはcanvas measureTextの実測値が届く前の最初のフレームで使う文字あたりのpx概算値です。6.4から9.5に上げるとチップが最初に描かれる瞬間はより広くなり、次のフレームでmeasureText実測値に狭められる補正はそのままです。
貼り付け区切りパターン値を調整する 05 DELIM_PATTERN /[,\n]+/ → /[,;\n]+/ DELIM_PATTERNは貼り付けた文を分割する区切り文字の正規表現です。カンマ・改行だけだったものにセミコロンを加えると、セミコロンでつないだ文も同じ貼り付けで複数のチップに分かれます。
あふれ行のしきい値を調整する 08 BADGE_RESERVE 34 → 54 BADGE_RESERVEはrelayout()が「+N」バッジのために確保しておく幅(px)です。34を54に上げると同じ行幅でバッジが占める分が増え、畳まれる前に見えるチップ数が減ってバッジがより早く出ます。

SCSSトークン一式・9つ全部のReact移植・この3つのバリエーションのビルドをまとめたzipはkq4849c2で開き、すぐ隣に表示された値をそのまま打ち込めば中身が見られます。

入力欄・セレクトボックスとの違いと、もっと多くのタグ uiデザイン例を探せる場所

この9つは<input>単体とも<select>とも違います。入力欄は1つの値しか持たず、セレクトボックスは選択肢が確定済みの候補から選ぶだけですが、タグ入力は「複数の値を、ユーザー自身が打った文字列から作り続ける」という点で両方と別物です。チップという新しい要素をその都度生成し、削除し、並べ替える必要がある分、状態管理の量そのものが違います。

入力欄側のデザインをさらに見たいなら入力欄デザイン9選 — コピペで動くフォーム部品に9パターンがあります。確定済みの選択肢から選ぶ形を比べたいならセレクトボックスデザインcss9選 — 実際に開く、そして候補の並べ替えという09番と同じ考え方をもっと掘り下げたタグ uiデザイン例としてはオートコンプリート実装 — ハイライト・デバウンス・編集距離を重ねた9種が参考になります。

タグ 入力 uiを組み込む前に確認しておきたいFAQ

05番は本物のクリップボードを読んでいますか?

いいえ。pasteイベントのハンドラ内でe.clipboardDataから文字列を受け取っているだけで、navigator.clipboard.readText()のような能動的な読み取りAPIは一度も呼んでいません。自動デモはe.isTrustedがfalseの疑似pasteイベントを発火させて同じ処理を通しているだけです。

03番はなぜ重複を無視せず点滅させるのですか?

タグを打ったのに一覧に何も変化がないと、入力が効いていないように見えて再入力してしまう人が出ます。既存チップの背景色を一瞬明るくして戻すことで、「そのタグはすでにある」という結果を目に見える形で返し、aria-live領域が同じ内容を読み上げます。

08番のバッジの数字はウィンドウ幅を変えた直後にずれますか?

relayout()はresizeイベントのたびにチップの幅を丸ごと測り直すので、ウィンドウ幅を変えた直後の1フレームで再計算されます。測り直しの前に一瞬だけ古い並びが見えることはありますが、requestAnimationFrameの次のフレームでバッジの数字は現在の行幅に合ったものに更新されます。

タグ入力のpasteイベントの挙動はMDNのClipboardEventドキュメントにまとまっています。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。