デモ
デモを読み込めませんでした。
仕様
- 用途
- フォーム
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
枠を押すと、枠の線が文字の色に変わって矢印が 180° 回り、選択肢のパネルが下へ伸びます。選択肢は少し上から 1 行ずつずれて降りてくるので、上から順に並んでいくように見えます。行を選ぶと、その行の地が一瞬濃くなってチェックが移り、パネルは下の行から消えながら縮んで閉じます。枠の中の値は、前の値が上へ抜け、新しい値が下から入ります。
パネルの高さは 0.4 秒の power3.out で伸ばし、選択肢は 12px 上から 0.05 秒ずつずらして降ろします。文字の濃さは 0.15 秒で先に上げ、位置は 0.4 秒かけて落ち着かせるので、降りきる前から読めます。閉じるときは同じタイムラインを 1.8 倍の速さで逆再生します。逆にたどるので下の行から消え、開くときより素早く片付きます。
高さを伸ばすのは、余白を持たない外側の箱にします。余白のある ul の高さを 0 にしても、余白の分の帯が残ります。行き先の高さは auto にし、閉じきってから開くたびに測り直すので、書体の読み込みや文言で行の高さが変わっても合います。パネルの mousedown は既定の動きを止めておきます。止めないと、行を押した瞬間に焦点が枠から外れ、外を押したときと同じように閉じ始めます。
枠は role="combobox"、パネルは listbox にして、焦点は枠に置いたまま、キーボードで移っている行を aria-activedescendant で伝えます。↑↓ で移り、Enter かスペースで選び、Esc で選び直さずに閉じます。英数字のキーを押すと、その字で始まる次の行へ移ります(和文で始まる行は、読みを別に持たせないと字では探せません)。選んだ値は hidden の input でフォームに送ります。動きを減らす設定では、ほぼ一瞬で開け閉めします。
このデモでは、枠を押すと開き、行を押すと選べます(外を押しても閉じます。キーボードの操作は、コードのほうで受けます)。降りるずれを 0 にすると一斉に現れ、広げると 1 行ずつ数えるように並びます。選択肢が 10 を超えるなら、パネルに上限の高さを決めて中をスクロールさせます。お問い合わせの種類、ご予算、並べ替えの条件など、フォームの選択に向いています。
コード
<!-- 見た目を作り込んだセレクトボックス。選んだ値は hidden の input でフォームに送る -->
<form class="contact" action="/contact/" method="post">
<div class="select">
<span class="select__label" id="topic-label">ご依頼の内容</span>
<!-- 焦点を受けるのは枠だけ。キーボードで移っている行は aria-activedescendant で伝える -->
<div class="select__trigger" role="combobox" tabindex="0" aria-labelledby="topic-label" aria-haspopup="listbox" aria-expanded="false" aria-controls="topic-list">
<span class="select__value"><span class="select__text">Webサイト制作</span></span>
<svg class="select__chevron" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path d="M4 6l4 4 4-4" />
</svg>
</div>
<!-- 高さを伸ばすのは外側の箱(余白のある ul そのものを 0 まで縮めると、余白の分が残る) -->
<div class="select__panel" hidden>
<ul class="select__list" id="topic-list" role="listbox" aria-labelledby="topic-label">
<li class="select__option" id="topic-web" role="option" data-value="web">Webサイト制作</li>
<li class="select__option" id="topic-lp" role="option" data-value="lp">LP制作</li>
<li class="select__option" id="topic-wordpress" role="option" data-value="wordpress">WordPress構築</li>
<li class="select__option" id="topic-ui" role="option" data-value="ui">UI/UXデザイン</li>
<li class="select__option" id="topic-branding" role="option" data-value="branding">ロゴ・ブランディング</li>
<li class="select__option" id="topic-other" role="option" data-value="other">その他のご相談</li>
</ul>
</div>
<input type="hidden" name="topic" value="web">
</div>
</form>
/* 暗い地のフォーム。文字とパネルの色は、この地の上で読める明るさにしてある */
.contact {
--ground: #111111; /* フォームの地 */
--ink: #eeece6; /* 文字・開いている枠 */
--label: #a8a6a0; /* 欄の名前(16px 未満の補足) */
--surface: #262626; /* パネルの面 */
--line: rgba(238, 236, 230, 0.14); /* 休みの枠とパネルの縁 */
--active: rgba(238, 236, 230, 0.08); /* 乗っている行・キーボードで移った行の地 */
padding: 32px;
background-color: var(--ground);
color: var(--ink);
}
.select { position: relative; max-width: 544px; }
.select__label {
display: block;
margin-bottom: 8px;
color: var(--label);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.04em;
}
.select__trigger {
display: flex;
align-items: center;
gap: 16px;
height: 56px;
padding: 0 16px;
border-radius: 4px;
box-shadow: inset 0 0 0 1px var(--line);
font-size: 16px;
letter-spacing: 0.04em;
cursor: pointer;
transition: box-shadow 0.2s;
}
.select__trigger[aria-expanded="true"],
.select__trigger:focus-visible {
outline: none;
box-shadow: inset 0 0 0 1px var(--ink);
}
/* 値は 1 行の窓の中で入れ替える(上へ抜けて、下から入る) */
.select__value { flex: 1; min-width: 0; overflow: hidden; line-height: 24px; }
.select__text { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.select__chevron { flex: none; width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
/* 影は付けず、面の色と 1px の縁で重ねる */
.select__panel {
position: absolute;
top: calc(100% + 8px);
left: 0;
right: 0;
z-index: 10;
overflow: hidden;
border-radius: 4px;
background-color: var(--surface);
box-shadow: inset 0 0 0 1px var(--line);
}
.select__list { margin: 0; padding: 8px; list-style: none; }
.select__option {
display: flex;
align-items: center;
justify-content: space-between;
height: 48px;
padding: 0 16px;
border-radius: 4px;
font-size: 15px;
letter-spacing: 0.04em;
cursor: pointer;
}
.select__option[aria-selected="true"] { background-color: var(--active); }
/* 選んである行のチェック */
.select__option.is-chosen::after {
content: "";
width: 10px;
height: 5px;
margin: 0 3px 4px 0;
border: solid currentColor;
border-width: 0 0 2px 2px;
transform: rotate(-45deg);
}
@media (prefers-reduced-motion: reduce) { .select__trigger { transition: none; } }
import { gsap } from "gsap";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SPEED = reduceMotion ? 20 : 1; // 動きを減らす設定では、ほぼ一瞬で開け閉めする
document.querySelectorAll(".select").forEach((select) => {
const trigger = select.querySelector(".select__trigger");
const text = select.querySelector(".select__text");
const panel = select.querySelector(".select__panel");
const input = select.querySelector('input[type="hidden"]');
const options = [...select.querySelectorAll(".select__option")];
let chosen = Math.max(0, options.findIndex((option) => option.dataset.value === input.value));
let active = chosen;
let swap = null;
options[chosen].classList.add("is-chosen");
// 開く: パネルが伸び、選択肢が少し上から順に降りる。閉じるのは逆再生なので、下の行から消える
const open = gsap
.timeline({ paused: true, onReverseComplete: () => (panel.hidden = true) })
.fromTo(panel, { height: 0 }, { height: "auto", duration: 0.4, ease: "power3.out" }, 0)
.fromTo(options, { opacity: 0 }, { opacity: 1, duration: 0.15, ease: "none", stagger: 0.05 }, 0.06)
.fromTo(options, { y: -12 }, { y: 0, duration: 0.4, ease: "power3.out", stagger: 0.05 }, 0.06)
.fromTo(select.querySelector(".select__chevron"), { rotation: 0 }, { rotation: 180, duration: 0.3, ease: "power2.out" }, 0);
const isOpen = () => trigger.getAttribute("aria-expanded") === "true";
// 移っている行(キーボード・ホバー)。焦点は枠に置いたまま、aria-activedescendant で伝える
const activate = (i) => {
active = gsap.utils.clamp(0, options.length - 1, i);
options.forEach((option, k) => option.setAttribute("aria-selected", String(k === active)));
trigger.setAttribute("aria-activedescendant", options[active].id);
};
const show = () => {
if (isOpen()) return;
panel.hidden = false;
trigger.setAttribute("aria-expanded", "true");
activate(chosen);
if (open.progress() === 0) open.invalidate(); // 閉じきっていたら、パネルの高さを測り直す
open.timeScale(SPEED).play();
};
const hide = () => {
if (!isOpen()) return;
trigger.setAttribute("aria-expanded", "false");
trigger.removeAttribute("aria-activedescendant");
open.timeScale(SPEED * 1.8).reverse(); // 閉じるのは速く
};
// 選ぶ: チェックを移し、枠の中の値を入れ替えて(前の値が上へ抜け、新しい値が下から入る)閉じる
const choose = (i) => {
if (i !== chosen) {
options[chosen].classList.remove("is-chosen");
options[i].classList.add("is-chosen");
chosen = i;
input.value = options[i].dataset.value;
swap?.kill(); // 続けて選んだら、前の入れ替えを止める
swap = gsap
.timeline()
.to(text, { yPercent: -100, duration: 0.16 / SPEED, ease: "power2.in" })
.call(() => (text.textContent = options[i].textContent))
.fromTo(text, { yPercent: 100 }, { yPercent: 0, duration: 0.3 / SPEED, ease: "power3.out" });
}
hide();
};
trigger.addEventListener("click", () => (isOpen() ? hide() : show()));
trigger.addEventListener("blur", hide); // 外を押したときや、Tab で離れたとき
panel.addEventListener("mousedown", (event) => event.preventDefault()); // 行を押しても、焦点を枠から外さない
options.forEach((option, i) => {
option.addEventListener("pointermove", () => active !== i && activate(i));
option.addEventListener("click", () => choose(i));
});
trigger.addEventListener("keydown", (event) => {
const { key } = event;
if (key === "Tab") return isOpen() && choose(active); // 移っていた行を選んで、焦点は次の欄へ
if (key === "ArrowDown" || key === "ArrowUp") {
if (isOpen()) activate(active + (key === "ArrowDown" ? 1 : -1));
else show(); // 閉じているときは、選んである行から始める
} else if (key === "Home" || key === "End") {
show();
activate(key === "Home" ? 0 : options.length - 1);
} else if (key === "Enter" || key === " ") {
isOpen() ? choose(active) : show();
} else if (key === "Escape" && isOpen()) {
hide(); // 選び直さずに閉じる
} else if (key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey) {
// 頭文字: その字で始まる次の行へ(同じ字を続けて押すと、その字で始まる行を順に回る)
const order = [...options.slice(active + 1), ...options.slice(0, active + 1)];
const hit = order.find((option) => option.textContent.toLowerCase().startsWith(key.toLowerCase()));
if (!hit) return;
show();
activate(options.indexOf(hit));
} else {
return;
}
event.preventDefault();
});
});