デモ
デモを読み込めませんでした。
仕様
- 用途
- フォーム
- 動作
- 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 にします。プロフィールのスキル、記事のタグ、メールの宛先、絞り込み検索の条件の入力に向いています。
コード
<!-- 送る値は、隠した 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>
.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; }
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();
});
});