タグ入力

入力欄にEnterでタグを追加し、×ボタンで削除できるチップ式のタグ入力。大文字小文字を無視して重複を検知し、既存タグを軽く揺らして知らせます。

#css#js#badge#input

ライブデモ

コード

HTML
<!-- タグ入力: Enterでチップ追加、×で削除、大小無視で重複はシェイクして拒否 -->
<section class="panel" aria-label="タグ入力のデモ">
  <label class="field-label" for="tagInput">興味のあるタグ</label>
  <div class="tag-field" id="tagField">
    <!-- チップはJSがinputの直前に動的挿入する -->
    <input
      type="text"
      id="tagInput"
      class="tag-input"
      placeholder="タグを入力してEnter"
      autocomplete="off"
      aria-describedby="tagCount"
    >
  </div>
  <p class="field-hint" id="tagCount" role="status" aria-live="polite">0 個のタグ</p>
</section>
CSS
* { box-sizing: border-box; }
body {
  margin: 0;
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: 24px;
  font-family: "Segoe UI", system-ui, sans-serif;
  background: linear-gradient(160deg, #fdfbf6 0%, #f5efe3 55%, #ece2d0 100%);
}

.panel {
  width: min(380px, 92vw);
  background: #ffffff;
  border-radius: 20px;
  padding: 26px 26px 22px;
  box-shadow: 0 24px 48px -22px rgba(120, 92, 40, .22), 0 2px 8px rgba(60, 45, 20, .06);
  border: 1px solid rgba(180, 150, 100, .18);
}

.field-label {
  display: block;
  margin: 0 0 12px;
  font-size: 13px;
  font-weight: 700;
  color: #57534e;
  letter-spacing: .02em;
}

.tag-field {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  padding: 10px 12px;
  border-radius: 14px;
  background: #faf7f0;
  border: 1.5px solid #e7ddc8;
  transition: border-color .2s ease, box-shadow .2s ease;
}
.tag-field:focus-within {
  border-color: #d6a94c;
  box-shadow: 0 0 0 3px rgba(214, 169, 76, .18);
}

/* チップ(.chip)はtag-field直下にJSで挿入され、inputと同じ行でフローする */
.chip {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 6px 8px 6px 12px;
  border-radius: 999px;
  background: #fdf0d8;
  color: #92660f;
  font-size: 13px;
  font-weight: 600;
  animation: chip-in .28s cubic-bezier(.34, 1.56, .64, 1);
}

.chip__label { line-height: 1; }

.chip__remove {
  appearance: none;
  border: none;
  background: rgba(146, 102, 15, .12);
  color: #92660f;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  font-size: 12px;
  line-height: 1;
  cursor: pointer;
  display: grid;
  place-items: center;
  transition: background .15s ease, transform .15s ease;
}
.chip__remove:hover { background: rgba(146, 102, 15, .24); }
.chip__remove:active { transform: scale(.88); }
.chip__remove:focus-visible { outline: 2px solid #d6a94c; outline-offset: 2px; }

/* 重複入力時の拒否フィードバック */
.chip.is-dup { animation: chip-shake .38s ease; }

.tag-input {
  flex: 1 1 120px;
  min-width: 100px;
  border: none;
  background: transparent;
  font-size: 14px;
  color: #3d3a34;
  padding: 4px 2px;
}
.tag-input:focus { outline: none; }
.tag-input::placeholder { color: #b7ac96; }

.field-hint {
  margin: 12px 2px 0;
  font-size: 12px;
  color: #a89a78;
}

@keyframes chip-in {
  0%   { transform: scale(.6); opacity: 0; }
  100% { transform: scale(1); opacity: 1; }
}

@keyframes chip-shake {
  0%, 100% { transform: translateX(0); }
  20%      { transform: translateX(-5px); }
  40%      { transform: translateX(4px); }
  60%      { transform: translateX(-3px); }
  80%      { transform: translateX(2px); }
}

@media (prefers-reduced-motion: reduce) {
  .chip, .chip.is-dup { animation: none !important; }
}
JavaScript
// タグ入力: Enterでチップ追加、×ボタンで削除。大小無視で重複ならシェイクして拒否する。
(() => {
  const field = document.getElementById('tagField');
  const input = document.getElementById('tagInput');
  const count = document.getElementById('tagCount');
  if (!field || !input || !count) return; // null安全

  let tags = ['デザイン', 'リサーチ']; // 初期表示用のサンプルタグ

  const render = () => {
    field.querySelectorAll('.chip').forEach((el) => el.remove());
    tags.forEach((tag) => {
      const chip = document.createElement('span');
      chip.className = 'chip';

      const label = document.createElement('span');
      label.className = 'chip__label';
      label.textContent = tag; // textContentでXSSを回避

      const remove = document.createElement('button');
      remove.type = 'button';
      remove.className = 'chip__remove';
      remove.setAttribute('aria-label', `${tag} を削除`);
      remove.textContent = '×';
      remove.addEventListener('click', () => {
        tags = tags.filter((t) => t !== tag);
        render();
        input.focus();
      });

      chip.append(label, remove);
      field.insertBefore(chip, input);
    });
    count.textContent = `${tags.length} 個のタグ`;
  };

  const shake = (index) => {
    const chips = field.querySelectorAll('.chip');
    const target = chips[index];
    if (!target) return;
    target.classList.remove('is-dup');
    void target.offsetWidth; // リフローでアニメを再生成
    target.classList.add('is-dup');
  };

  input.addEventListener('keydown', (e) => {
    if (e.key !== 'Enter') return;
    e.preventDefault();
    const raw = input.value.trim();
    if (!raw) return;

    const dupIndex = tags.findIndex((t) => t.toLowerCase() === raw.toLowerCase());
    if (dupIndex !== -1) {
      shake(dupIndex); // 既存タグと重複 -> 追加せず既存チップを揺らして知らせる
      input.value = '';
      return;
    }

    tags.push(raw);
    render();
    input.value = '';
  });

  render();
})();

🤖 AIエージェント用プロンプト

このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「タグ入力」の効果を追加してください。

# 追加してほしい効果
タグ入力(バッジ & アバター)
入力欄にEnterでタグを追加し、×ボタンで削除できるチップ式のタグ入力。大文字小文字を無視して重複を検知し、既存タグを軽く揺らして知らせます。

# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- タグ入力: Enterでチップ追加、×で削除、大小無視で重複はシェイクして拒否 -->
<section class="panel" aria-label="タグ入力のデモ">
  <label class="field-label" for="tagInput">興味のあるタグ</label>
  <div class="tag-field" id="tagField">
    <!-- チップはJSがinputの直前に動的挿入する -->
    <input
      type="text"
      id="tagInput"
      class="tag-input"
      placeholder="タグを入力してEnter"
      autocomplete="off"
      aria-describedby="tagCount"
    >
  </div>
  <p class="field-hint" id="tagCount" role="status" aria-live="polite">0 個のタグ</p>
</section>

【CSS】
* { box-sizing: border-box; }
body {
  margin: 0;
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: 24px;
  font-family: "Segoe UI", system-ui, sans-serif;
  background: linear-gradient(160deg, #fdfbf6 0%, #f5efe3 55%, #ece2d0 100%);
}

.panel {
  width: min(380px, 92vw);
  background: #ffffff;
  border-radius: 20px;
  padding: 26px 26px 22px;
  box-shadow: 0 24px 48px -22px rgba(120, 92, 40, .22), 0 2px 8px rgba(60, 45, 20, .06);
  border: 1px solid rgba(180, 150, 100, .18);
}

.field-label {
  display: block;
  margin: 0 0 12px;
  font-size: 13px;
  font-weight: 700;
  color: #57534e;
  letter-spacing: .02em;
}

.tag-field {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  padding: 10px 12px;
  border-radius: 14px;
  background: #faf7f0;
  border: 1.5px solid #e7ddc8;
  transition: border-color .2s ease, box-shadow .2s ease;
}
.tag-field:focus-within {
  border-color: #d6a94c;
  box-shadow: 0 0 0 3px rgba(214, 169, 76, .18);
}

/* チップ(.chip)はtag-field直下にJSで挿入され、inputと同じ行でフローする */
.chip {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 6px 8px 6px 12px;
  border-radius: 999px;
  background: #fdf0d8;
  color: #92660f;
  font-size: 13px;
  font-weight: 600;
  animation: chip-in .28s cubic-bezier(.34, 1.56, .64, 1);
}

.chip__label { line-height: 1; }

.chip__remove {
  appearance: none;
  border: none;
  background: rgba(146, 102, 15, .12);
  color: #92660f;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  font-size: 12px;
  line-height: 1;
  cursor: pointer;
  display: grid;
  place-items: center;
  transition: background .15s ease, transform .15s ease;
}
.chip__remove:hover { background: rgba(146, 102, 15, .24); }
.chip__remove:active { transform: scale(.88); }
.chip__remove:focus-visible { outline: 2px solid #d6a94c; outline-offset: 2px; }

/* 重複入力時の拒否フィードバック */
.chip.is-dup { animation: chip-shake .38s ease; }

.tag-input {
  flex: 1 1 120px;
  min-width: 100px;
  border: none;
  background: transparent;
  font-size: 14px;
  color: #3d3a34;
  padding: 4px 2px;
}
.tag-input:focus { outline: none; }
.tag-input::placeholder { color: #b7ac96; }

.field-hint {
  margin: 12px 2px 0;
  font-size: 12px;
  color: #a89a78;
}

@keyframes chip-in {
  0%   { transform: scale(.6); opacity: 0; }
  100% { transform: scale(1); opacity: 1; }
}

@keyframes chip-shake {
  0%, 100% { transform: translateX(0); }
  20%      { transform: translateX(-5px); }
  40%      { transform: translateX(4px); }
  60%      { transform: translateX(-3px); }
  80%      { transform: translateX(2px); }
}

@media (prefers-reduced-motion: reduce) {
  .chip, .chip.is-dup { animation: none !important; }
}

【JavaScript】
// タグ入力: Enterでチップ追加、×ボタンで削除。大小無視で重複ならシェイクして拒否する。
(() => {
  const field = document.getElementById('tagField');
  const input = document.getElementById('tagInput');
  const count = document.getElementById('tagCount');
  if (!field || !input || !count) return; // null安全

  let tags = ['デザイン', 'リサーチ']; // 初期表示用のサンプルタグ

  const render = () => {
    field.querySelectorAll('.chip').forEach((el) => el.remove());
    tags.forEach((tag) => {
      const chip = document.createElement('span');
      chip.className = 'chip';

      const label = document.createElement('span');
      label.className = 'chip__label';
      label.textContent = tag; // textContentでXSSを回避

      const remove = document.createElement('button');
      remove.type = 'button';
      remove.className = 'chip__remove';
      remove.setAttribute('aria-label', `${tag} を削除`);
      remove.textContent = '×';
      remove.addEventListener('click', () => {
        tags = tags.filter((t) => t !== tag);
        render();
        input.focus();
      });

      chip.append(label, remove);
      field.insertBefore(chip, input);
    });
    count.textContent = `${tags.length} 個のタグ`;
  };

  const shake = (index) => {
    const chips = field.querySelectorAll('.chip');
    const target = chips[index];
    if (!target) return;
    target.classList.remove('is-dup');
    void target.offsetWidth; // リフローでアニメを再生成
    target.classList.add('is-dup');
  };

  input.addEventListener('keydown', (e) => {
    if (e.key !== 'Enter') return;
    e.preventDefault();
    const raw = input.value.trim();
    if (!raw) return;

    const dupIndex = tags.findIndex((t) => t.toLowerCase() === raw.toLowerCase());
    if (dupIndex !== -1) {
      shake(dupIndex); // 既存タグと重複 -> 追加せず既存チップを揺らして知らせる
      input.value = '';
      return;
    }

    tags.push(raw);
    render();
    input.value = '';
  });

  render();
})();

# 外部ライブラリ
なし(追加ライブラリ不要)

# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。