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

セレクトボックスを開くと選択肢が順に降り、選ぶと閉じる

デモ

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

仕様

用途
フォーム
動作
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 を超えるなら、パネルに上限の高さを決めて中をスクロールさせます。お問い合わせの種類、ご予算、並べ替えの条件など、フォームの選択に向いています。

コード

HTML
<!-- 見た目を作り込んだセレクトボックス。選んだ値は 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>

CSS
/* 暗い地のフォーム。文字とパネルの色は、この地の上で読める明るさにしてある */
.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; } }

JavaScript
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();
  });
});

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

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