本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
フォーム

入力欄に打った語がタグのチップになって並び、× で消える

デモ

操作:クリック。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

用途
フォーム
動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

入力欄に語を打って Enter を押すと、打った語がその場でチップになって弾み、後ろの入力が右へ送られます。チップの × を押すと、チップが縮んで消えてから空いた幅が詰まり、右隣のチップと入力が左へ滑ります。チップを包む li の幅を、0 と本来の幅のあいだで補間するだけなので、並びは flex の流れのまま自然に詰まり、位置を測って動かす必要がありません。

幅は 0.35 秒の power3.out で開き、チップは 0.6 倍の大きさから 0.5 秒の back.out(2.2) で弾んで入ります。消すときは、チップを 0.18 秒で縮めて消し、0.12 秒遅れて幅を詰め始めます。縮むのと詰めるのを同時にすると、消えきる前のチップに隣のチップが重なって見えます。

日本語の入力では、かな漢字変換を確定する Enter にも keydown が届きます。そのまま受けると、変換の途中の語がチップになってしまうので、keydown で isComposing を見て、確定の Enter は受け流します。Safari は確定の keydown で isComposing が false になるため、keyCode が 229 のときも受け流します。

チップどうしの間は、flex の gap ではなく li の右の余白で作ります。gap は幅が 0 になっても残るので、消したチップの場所に隙間が残り、要素を外した瞬間に 8px 跳ねます。幅と余白を一緒に 0 へ縮め、li には min-width の 0 を指定して、中身より狭く縮められるようにします。

送る値は、隠した input にカンマ区切りで入れます。入力が空のときの Backspace で最後のタグを消せ、同じ語は増やさずに今あるチップを小さく揺らして知らせます。弾みを強くするなら back.out の数を上げ、落ち着かせるなら power3.out にします。プロフィールのスキル、記事のタグ、メールの宛先、絞り込み検索の条件の入力に向いています。

コード

HTML
<!-- 送る値は、隠した input にカンマ区切りで入る。チップの ul は読み上げで「一覧・2 項目」と数えられる -->
<div class="tag-input">
  <label class="tag-input__label" for="skill-input">スキル</label>
  <div class="tag-input__field">
    <ul class="tag-input__list" aria-label="追加したスキル">
      <li class="tag">
        <span class="tag__chip">
          <span class="tag__text">HTML</span>
          <button class="tag__remove" type="button" aria-label="HTMLを削除">
            <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 5 19 19M19 5 5 19" /></svg>
          </button>
        </span>
      </li>
      <li class="tag">
        <span class="tag__chip">
          <span class="tag__text">CSS</span>
          <button class="tag__remove" type="button" aria-label="CSSを削除">
            <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 5 19 19M19 5 5 19" /></svg>
          </button>
        </span>
      </li>
    </ul>
    <input class="tag-input__input" id="skill-input" type="text" autocomplete="off" enterkeyhint="done" placeholder="スキルを入力" aria-describedby="skill-help">
  </div>
  <p class="tag-input__help" id="skill-help"><kbd>Enter</kbd>で追加・<kbd>Backspace</kbd>で最後のタグを削除</p>
  <input type="hidden" name="skills" value="HTML,CSS">
  <p class="tag-input__status" role="status"></p>
  <!-- 追加するチップのひな形。文字と × の名前は script.js が入れる -->
  <template>
    <li class="tag">
      <span class="tag__chip">
        <span class="tag__text"></span>
        <button class="tag__remove" type="button">
          <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 5 19 19M19 5 5 19" /></svg>
        </button>
      </span>
    </li>
  </template>
</div>

CSS
.tag-input {
  --ink: #262626;                 /* 文字と、フォーカスした欄の枠 */
  --mute-small: #6b6b6b;          /* × の印・ヘルプ・入力例(16px 未満の補足) */
  --chip: #e1e6ea;                /* チップの地(スレート #688297 を白に 20% 混ぜた色) */
  --paper: #f2f1ed;               /* キーの札の地 */
  --surface: #ffffff;             /* 欄の地 */
  --rule: rgba(38, 38, 38, 0.12); /* 休んでいる欄の枠とチップの縁 */
  --gap: 8px;                     /* チップの間 */
  max-width: 640px;
  color: var(--ink);
}

.tag-input__label {
  display: block;
  margin-bottom: 12px;
  font-size: 14px;
  font-weight: 600;
  letter-spacing: 0.04em;
}

/* チップの列と入力を 1 つの欄に見せる。並びきらなければ入力が次の行へ回る */
.tag-input__field {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  min-height: 56px;
  padding: 11px 12px;
  box-sizing: border-box;
  border: 1px solid var(--rule);
  border-radius: 4px;
  background-color: var(--surface);
  cursor: text;
}

.tag-input__field:focus-within { border-color: var(--ink); }

.tag-input__list { display: flex; flex-wrap: wrap; row-gap: var(--gap); margin: 0; padding: 0; list-style: none; }

/* 幅を 0 まで縮められるように min-width: 0。間は gap ではなく margin にして、幅と一緒に縮める */
.tag { display: flex; flex: none; min-width: 0; margin-right: var(--gap); }

.tag__chip {
  display: flex;
  flex: none;
  align-items: center;
  gap: 4px;
  height: 32px;
  padding: 0 4px 0 12px;
  box-sizing: border-box;
  border: 1px solid var(--rule);
  border-radius: 999px;
  background-color: var(--chip);
  font-size: 14px;
  font-weight: 600;
  letter-spacing: 0.02em;
  white-space: nowrap;
  cursor: default;
}

.tag__remove {
  display: grid;
  place-items: center;
  width: 24px;
  height: 24px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background-color: transparent;
  color: var(--mute-small);
  cursor: pointer;
}

.tag__remove:hover { background-color: rgba(38, 38, 38, 0.1); color: var(--ink); }
.tag__remove:focus-visible { outline: 2px solid var(--ink); outline-offset: -2px; }
/* 画面の上で線が 2px になる太さ(24 の枠を 10px で描く) */
.tag__remove svg { width: 10px; height: 10px; fill: none; stroke: currentColor; stroke-width: 4.8; stroke-linecap: round; }

.tag-input__input {
  flex: 1 0 120px;
  min-width: 0;
  height: 32px;
  padding: 0 4px;
  border: 0;
  background: none;
  color: var(--ink);
  font: inherit;
  font-size: 16px;
}

.tag-input__input::placeholder { color: var(--mute-small); }
/* 欄の枠がフォーカスを示すので、入力の既定の輪は消す */
.tag-input__input:focus { outline: none; }

.tag-input__help { margin: 8px 0 0; color: var(--mute-small); font-size: 13px; line-height: 1.8; letter-spacing: 0.04em; }
.tag-input__help kbd { padding: 2px 6px; border: 1px solid var(--rule); border-radius: 4px; background-color: var(--paper); color: var(--ink); font: inherit; font-size: 12px; font-weight: 600; }

.tag-input__status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

JavaScript
import { gsap } from "gsap";

const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const POP_SCALE = 0.6; // 入る前・消えるときのチップの大きさ

document.querySelectorAll(".tag-input").forEach((root) => {
  const field = root.querySelector(".tag-input__field");
  const list = root.querySelector(".tag-input__list");
  const input = root.querySelector(".tag-input__input");
  const hidden = root.querySelector('input[type="hidden"]');
  const status = root.querySelector(".tag-input__status");
  const template = root.querySelector("template");

  // 消えている途中のチップは数えない
  const tags = () => [...list.querySelectorAll(".tag:not(.is-leaving)")];
  const textOf = (tag) => tag.querySelector(".tag__text").textContent;

  function sync(message) {
    hidden.value = tags().map(textOf).join(",");
    status.textContent = message;
  }

  // 動きを減らす設定では、ほぼ一瞬で済ませる
  const run = (tl) => (reduceMotion ? tl.timeScale(20) : tl);

  function add(text) {
    if (!text) return;
    const same = tags().find((tag) => textOf(tag) === text);
    if (same) {
      // 同じ語は増やさず、今あるチップを小さく揺らして知らせる
      input.value = "";
      status.textContent = `${text}はもう追加されています`;
      if (!reduceMotion) gsap.fromTo(same.firstElementChild, { x: -6 }, { x: 0, duration: 0.5, ease: "elastic.out(1, 0.3)" });
      return;
    }
    const tag = template.content.firstElementChild.cloneNode(true);
    tag.querySelector(".tag__text").textContent = text;
    tag.querySelector(".tag__remove").setAttribute("aria-label", `${text}を削除`);
    list.append(tag);
    input.value = "";
    sync(`${text}を追加しました`);
    // 枠の幅を 0 から開くと、後ろの入力が押し出される。チップは開いた所で弾んで入る
    run(
      gsap
        .timeline()
        .from(tag, { width: 0, marginRight: 0, duration: 0.35, ease: "power3.out", clearProps: "width,marginRight" })
        .from(tag.firstElementChild, { scale: POP_SCALE, autoAlpha: 0, duration: 0.5, ease: "back.out(2.2)" }, 0.02)
    );
  }

  function remove(tag) {
    if (!tag || tag.classList.contains("is-leaving")) return;
    tag.classList.add("is-leaving");
    sync(`${textOf(tag)}を削除しました`);
    // 先にチップを縮めて消し、そのあと空いた幅を詰める。同時に詰めると、縮むチップに隣が重なる
    run(
      gsap
        .timeline({ onComplete: () => tag.remove() })
        .to(tag.firstElementChild, { scale: POP_SCALE, autoAlpha: 0, duration: 0.18, ease: "power2.in", overwrite: "auto" })
        .to(tag, { width: 0, marginRight: 0, duration: 0.35, ease: "power3.inOut", overwrite: "auto" }, 0.12)
    );
  }

  input.addEventListener("keydown", (event) => {
    // かな漢字変換を確定する Enter はチップにしない。Safari は確定の keydown で isComposing が false なので 229 も見る
    if (event.isComposing || event.keyCode === 229) return;
    if (event.key === "Enter") {
      event.preventDefault(); // フォームを送らない
      add(input.value.trim());
    } else if (event.key === "Backspace" && input.value === "") {
      remove(tags().at(-1));
    }
  });

  list.addEventListener("click", (event) => {
    const button = event.target.closest(".tag__remove");
    if (!button) return;
    remove(button.closest(".tag"));
    input.focus(); // × は消えるので、焦点を入力へ戻す
  });

  // 欄の余白を押しても打てるように
  field.addEventListener("click", (event) => {
    if (event.target === field || event.target === list) input.focus();
  });
});

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。