ネットソリューション

【スマホ対応】ドラムロール風の選択肢を作る

こんにちは。ネットソリューション事業部の山下です。
以前のiOSのセレクトタグ(プルダウン)の挙動はドラムロールでのクルクル式だった気がするんですが、気が付いたら普通のプルダウン選択式になっていましたね。(iOS15から?)
今回はそんな懐かしのドラムロール風の選択肢を作ってみましたので解説します。

htmlとCSS

<div class="selecter">
  <div class="selecter__inner">
    <div></div>
    <div><input type="radio" name="select" checked />1</div>
    <div><input type="radio" name="select" />2</div>
    <div><input type="radio" name="select" />3</div>
    <div><input type="radio" name="select" />4</div>
    <div><input type="radio" name="select" />5</div>
    <div><input type="radio" name="select" />6</div>
    <div><input type="radio" name="select" />7</div>
    <div><input type="radio" name="select" />8</div>
    <div><input type="radio" name="select" />9</div>
    <div><input type="radio" name="select" />10</div>
    <div><input type="radio" name="select" />11</div>
    <div><input type="radio" name="select" />12</div>
    <div></div>
  </div>
</div>
.selecter {
  margin-inline: auto;
  position: relative;
  width: 200px;
  background: #454343;
  border-radius: 16px;
  padding: 10px;
  font-size: 1rem;
  line-height: 1.2;
}

.selecter::before {
  content: "";
  display: block;
  position: absolute;
  height: calc(1rem*1.2);
  background: #bfbfbf;
  left: 0;
  right: 0;
  top: 0;
  bottom: 0;
  margin: auto;
}

.selecter__inner {
  margin-inline: auto;
  height: calc(1rem*1.2*3);
  overflow-y: scroll;
  padding: 0;
  scroll-snap-type: y mandatory;
  position: relative;
}

.selecter__inner::-webkit-scrollbar {
  width: 4px;
  height: 4px;
}
.selecter__inner::-webkit-scrollbar-thumb {
  background-color: #ccc;
  border-radius: 4px;
}
.selecter__inner::-webkit-scrollbar-thumb:hover {
  background-color: #ccc;
}
.selecter__inner::-webkit-scrollbar-track {
  background-color: transparent;
}

.selecter__inner div {
  font-weight: 700;
  text-align: center;
  user-select: none;
  scroll-snap-align: center;
  scroll-snap-stop: always;
  color: #C0C0C0;
}

.selecter__inner div:empty {
  height: calc(1rem * 1.2);
}

.selecter__inner div:has(input:checked) {
  color: #000;
}

.selecter__inner div input {
  appearance: none;
}
1
2
3
4
5
6
7
8
9
10
11
12

このような見た目になりました。

ポイント

selecter__innerがスクロールする要素となります。
selecter__innerには選択肢が上中下の3行表示されるようしたいのでheight: calc(1rem*1.2*3);としました。
これは1文字分x行間x3行分という意味になります。

また、スクロールが中途半端な位置で止まらないよう(スナップするよう)にscroll-snap-type: y mandatory;を設定しています。
選択肢となる子供要素のdivにもscroll-snap-align: center; scroll-snap-stop: always;を設定しています。

また一番最初の要素と一番最後の要素は空白の選択肢を表示するために、空欄のdivを追加しています。

いまのままだとまだスクロールしてもカレント表示が変わりませんので、Javascriptを適用して、選択肢として機能するようにしていきます。

Javascriptで中央に表示された要素を選択状態とする。

window.addEventListener('load', function () {
  const boxes = document.querySelectorAll('.js-selecter');
  boxes.forEach(box => {
    box.addEventListener('wheel', function (event) {
      event.preventDefault();
      const childDiv = this.querySelector('div');
      const scrollAmount = childDiv ? childDiv.offsetHeight : 20;
      if (event.deltaY > 0) {
        this.scrollTop += scrollAmount;
      } else {
        this.scrollTop -= scrollAmount;
      }
    }, {
      passive: false
    });
    box.addEventListener('scroll', function (event) {
      requestAnimationFrame(() => {
        updateCurrent(box);
      });
    }, {
      passive: false
    });
  });
});

function updateCurrent(box) {
  const boxHeight = box.clientHeight;
  const scrollPosition = box.scrollTop;
  const middlePosition = scrollPosition + boxHeight / 2;

  let closestElement = null;
  let closestDistance = Infinity;
  const choices = box.querySelectorAll('div');

  choices.forEach(choice => {
    const cTop = choice.offsetTop;
    const cBottom = cTop + choice.offsetHeight; 
    const cMiddle = (cTop + cBottom) / 2;

    const distance = Math.abs(middlePosition - cMiddle);

    if (distance < closestDistance) {
      closestDistance = distance;
      closestElement = choice;
    }
  });
  choices.forEach(choice => {
    const input = choice.querySelector('input[type="radio"]');
    if (input) {
      input.checked = false;
    }
  });
  if (closestElement) {
    const closestInput = closestElement.querySelector('input[type="radio"]');
    if (closestInput) {
      closestInput.checked = true;
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12

ポイント

まずスクロールの対象となる、selecter__innerjs-selecterクラスを追加します。
そしてそのjs-selecter対象にJavascriptを追加していきます。

.addEventListener('wheel')を追加し、ホイールでのデフォルトスクロールを無効化し、選択肢の高さ分だけスクロールするように変更しています。
これはマウスホイールでのスクロールだとどうしてもスクロール量が大きくなりがちで、1つずつ選択肢をスライドしていくのが難しかったためです。
.addEventListener('scroll')でスクロール時の動作を追加していきます。

updateCurrent

この関数は、現在のスクロール位置と選択肢の位置を比較し、最も近い要素を選択状態にするものです。

const boxHeight = box.clientHeight;
const scrollPosition = box.scrollTop;
const middlePosition = scrollPosition + boxHeight / 2;

ここでは、ボックスの高さとスクロール位置を取得し、画面中央の位置(middlePosition)を計算します。

const distance = Math.abs(middlePosition - cMiddle);

if (distance <hr closestDistance) {
  closestDistance = distance;
  closestElement = choice;
}

各選択肢(divタグ)に対して、その中央位置(cMiddle)と現在の中央位置(middlePosition)との距離を計算し、一番近い要素を特定しています。

choices.forEach(choice => {
  const input = choice.querySelector('input[type="radio"]');
  if (input) {
    input.checked = false;
  }
});
if (closestElement) {
  const closestInput = closestElement.querySelector('input[type="radio"]');
  if (closestInput) {
    closestInput.checked = true;
  }
}

最も近い要素を選択した後、すべての選択肢から選択状態を外し、closestElement内のラジオボタンをチェック状態にします。


下記のような日付選択式なども実装することができます。

1
2
3
4
5
6
7
8
9
10
11
12
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

いかがだったでしょうか。
デザイン的に凝った選択UIを作成する必要があるときにでも、参考にしていただけますと幸いです。

Webの進化の速さにアップアップですがなんとか喰らいついていきたい。

山下 X@Frencel_ns

フロントエンドエンジニア
フレームワーク頑張りたい人
モンハンワイルズではキアヌ・リーブス似のイケオジでプレイしたい

好きなモンスターハンターの武器

双剣・狩猟笛

mail お問い合わせ

ご覧いただきありがとうございます。
当メディアへのご質問や各事業部へのお仕事のご相談がありましたら、お気軽にお問い合わせください。

article 過去記事