先日公開した記事「HTML Web Components とは何か」では、HTML Web Components の概略について説明しました。
今回は、HTML Web Components のテクニックを使用してイメージスライダーを作成します。完成形は以下で、水平方向に画像が並び、スクロールバーとボタンでスクロール移動します。
実装内容
見出し「実装内容」HTML Web Components について簡単におさらいすると、Shadow DOM を使用しない Light DOM のカスタム要素で、HTML をベースに機能を積み増していく Progressive Enhancement の考え方に基づいたテクニックです。
今回作成するイメージスライダーは、この HTML Web Components の手法に基づき、以下の内容で実装していきます。
- 水平方向のスクロールは CSS で実装
- ボタンの機能は JavaScript で実装
- 選択した方向にスクロール
- 両端のいずれかに到達したら、対象となるボタンを非活性化
- JavaScript がオフでも最低限の機能を提供
- CSS 変数(カスタムプロパティ)で設定を変更できるようにする
それでは、HTML、CSS、JavaScript の順番で、具体的な実装内容を見ていきます。
1. HTML
見出し「1. HTML」まずは、基本となる HTML を以下に抜粋します。
カスタム要素 <image-slider>
に、スライダーとナビゲーションの要素を含んでいます。
もし、このカスタム要素を複数箇所で使用する場合には、コードの一貫性やメンテナンス性の観点から、ナビの HTML は JS で動的に生成したほうがよいかもしれません。今回はレイアウトシフトが発生することを考慮し、HTML に直接記述する方法を選択しました。
<button>
要素には aria-disabled="true"
を指定して、初期状態は無効であることを表しています。この状態は、後ほど JS で有効・無効を切り替えます。
スライダーのアクセシビリティ向上
見出し「スライダーのアクセシビリティ向上」スライダー(カルーセル)のアクセシビリティを向上させるには考慮すべきことが非常に多いのですが、本記事のデモでは、例えば aria-roledescription
といった属性の指定や、tabindex
指定の制御、ライブリージョンの指定などは対応していません。
より詳しい実装内容は、以下のウェブページが参考になりますが、これらを全部しっかりやろうとすると結構大変です。
上記で紹介している「Splide」ですが、2022 年 9 月から更新が止まっているようですので、使用する際には慎重に検討したほうがよいかもしれません。
2. CSS
見出し「2. CSS」続いて、少し長いですが CSS のコードを抜粋します。
CSS 変数
見出し「CSS 変数」カスタム要素に CSS 変数を指定しており、子孫要素のスタイル指定で使用しています。
image-slider {
--slider-padding: 40px; /* スライダーの外側の余白 */
--show-items: 2; /* 一度に表示するスライダーの数 */
--glance: 0.5; /* 次の要素が見切れるサイズ */
--move: 1; /* ナビのボタンで移動する距離 */
--gap: 20px; /* 要素間の余白 */
--item-min-size: 150px; /* 要素の最小サイズ */
--item-max-size: 300px; /* 要素の最大サイズ */
--scroll-snap-align: start; /* スナップする位置 */
--scrollbar-margin: 20px; /* スクロールバーとの余白 */
--scrollbar-width: 12px; /* スクロールバーのサイズ */
}
この CSS 変数は、後述する JS での処理やスライダーの設定を変更するためにも使用します。
スライダー
見出し「スライダー」スライダーのスクロール機能を実装するために、まずは .c-slider
に display: flex
で子要素を横並びにし、overflow-x: auto
で要素が収まらないときにスクロールバーが表示されるようにしています。
さらに、スクロールした後にスナップするように scroll-snap-type: inline mandatory
を指定し、スムーススクロールを有効にする scroll-behavior: smooth
を指定しています。
注意点として、scroll-behavior: smooth
の挙動はブラウザによって異なります。また、速度やイージングは個別に調整できません。
子要素の .c-item
には flex
のショートハンドプロパティでサイズを指定し、scroll-snap-align
でスナップする起点を指定しています。
image-slider .c-slider {
display: flex;
gap: var(--gap);
overflow-x: auto;
padding-block-end: var(--scrollbar-margin);
scroll-snap-type: inline mandatory;
scroll-behavior: smooth;
}
image-slider .c-item {
flex: 0 0 clamp(var(--item-min-size), var(--item-size), var(--item-max-size));
scroll-snap-align: var(--scroll-snap-align);
}
ナビゲーション
見出し「ナビゲーション」ナビのボタンは、aria-disabled="true"
が指定されている場合には、非活性を表すスタイルを指定しています。
image-slider .c-nav-btn[aria-disabled="true"] {
opacity: 0.4;
cursor: default;
}
ここまでの HTML と CSS の実装で、以下のようなスライダーの UI が実現できます。
ナビのボタンは機能しませんが、スクロールバーを操作したり、タッチデバイスであればスワイプで水平方向にスクロールできるので、最低限の機能を有しているといえます。
スクロールバーのスタイリング
見出し「スクロールバーのスタイリング」上記の CSS のコードでは割愛していますが、デモではスクロールバーのスタイルを変更しています。
スクロールバーの CSS を確認する
@media not (forced-colors: active) {
image-slider .c-slider {
--color-track: #bde1e1;
--color-thumb: #007373;
--color-thumb-hover: color-mix(in srgb, var(--color-thumb) 80%, white);
}
@supports not selector(::-webkit-scrollbar) {
image-slider .c-slider {
scrollbar-color: var(--color-thumb) var(--color-track);
scrollbar-width: var(--scrollbar-width);
}
}
@supports selector(::-webkit-scrollbar) {
image-slider .c-slider::-webkit-scrollbar {
block-size: var(--scrollbar-width);
}
image-slider .c-slider::-webkit-scrollbar-track {
border-radius: var(--scrollbar-width);
background: var(--color-track);
}
image-slider .c-slider::-webkit-scrollbar-thumb {
border-radius: var(--scrollbar-width);
background: var(--color-thumb);
}
image-slider .c-slider::-webkit-scrollbar-thumb:hover {
background: var(--color-thumb-hover);
}
}
image-slider[style*="--scrollbar-width: 0"] .c-slider {
--scrollbar-margin: 0;
scrollbar-width: none;
}
}
スクロールバーのスタイルを変更する方法は、現在 2 パターンあります。
- ベンダープレフィックスを使用する方法(
::-webkit-scrollbar
など) - W3C 仕様のプロパティを使用する方法(
scrollbar-width
など)
前者のベンダープレフィックスを使用した方法は、細かくスタイルを調整できるのですが、Firefox は対応しておらず、将来的には廃止される予定です。
一方で、W3C 仕様の scrollbar-width
は、現時点ではピクセルレベルでのサイズ指定はできず、明示できるのはバーを細くする thin
や、非表示にする none
のキーワードのみです。
こちらは現状は Safari が非対応ですが、ウェブブラウザの相互運用性を向上させるための取り組みである Interop 2024 に Scrollbar Styling が入ったことで1、今年中にサポートが進む可能性が高いです。
上記を踏まえると、ベンダープレフィックスを使用した方法は避けるべきですが、W3C 仕様の指定は自由度が低く、スタイルを細かく調整できないのでジレンマが残ります。
2024/04/04 追記
強制カラーモードが有効なときにスクロールバーをカスタマイズすると、スクロールバーが表示されなくなる危険性があるため、上記のコードに forced-colors
メディア特性を追加しました。
この変更は以下の記事を読んだことがきっかけですが、もちろん forced-colors
メディア特性で分岐すれば問題が発生しないということではありません。そもそも、スクロールバーのスタイルを変更すること自体を避けたほうがよいかもしれません。
3. JavaScript
見出し「3. JavaScript」それでは仕上げに、JS でカスタム要素を定義します。
こちらも CSS 同様に少し長いですが、順番に説明していきます。
クラスフィールド
見出し「クラスフィールド」まず、先頭でクラスフィールドを定義しています。ここで指定した値や取得した DOM 要素は、各メソッドで使用します。
class ImageSlider extends HTMLElement {
// クラスフィールド
edge = 'start';
styles = getComputedStyle(this);
move = parseInt(this.styles?.getPropertyValue('--move'), 10) || 1;
gap = parseInt(this.styles?.getPropertyValue('--gap'), 10) || 0;
slider = this.querySelector('[data-slider]');
navBtns = this.querySelectorAll('[data-nav-btn]');
image = this.slider?.querySelector('img');
controller = new AbortController();
timer;
// ...
}
いくつか補足すると、styles
フィールドは、getComputedStyle(this)
によってカスタム要素自身のスタイル情報を格納しています。これは、続く move
と gap
で CSS 変数の値を取得するときに使用しています。
controller
フィールドは、AbortController
のインスタンスを格納しています。これは、disconnectedCallback
でカスタム要素を削除したときにイベントを破棄するために使用します。
timer
フィールドは、スクロールイベントやリサイズイベントの処理を間引くために用意している debounce
メソッドで使用します。
コンストラクタ
見出し「コンストラクタ」constructor()
は、クラスをインスタンス化したときに実行されますが、初回に実行したい処理やイベントの設定は connectedCallback()
でおこなっているので、ここでは super()
のみ記述しています。
// 初期化
constructor() {
super();
}
ライフサイクルコールバック
見出し「ライフサイクルコールバック」ライフサイクルコールバックでは、カスタム要素の状態が変更されるタイミングでメソッドを呼び出すことができます。以下の 4 つのメソッドが用意されています。
メソッド | 呼び出されるタイミング |
---|---|
connectedCallback | カスタム要素が追加されたとき |
disconnectedCallback | カスタム要素が削除されたとき |
attributeChangedCallback | カスタム要素の属性が変更されたとき |
adoptedCallback | カスタム要素が別のドキュメントに移動したとき |
今回は adoptedCallback()
を除く、3 つのメソッドを使用しています。
connectedCallback
この見出しのリンクまず、connectedCallback()
では、スクロールの端に到達したかどうかを判定する detectScrollEdge
メソッドを、以下のタイミングで呼び出しています。
- カスタム要素が追加されたとき(初期化)
- スライダーのスクロール
- ウィンドウリサイズ
加えて、ナビのボタンをクリックしたときに、スライダーの scrollLeft
を変更してスクロールするようにしています。スクロール量は calcScrollLeft
メソッドで算出しています。
// カスタム要素が追加されたときに実行
connectedCallback() {
if (this.slider) {
this.detectScrollEdge();
const { signal } = this.controller;
this.slider.addEventListener('scroll', () => { this.debounce(this.detectScrollEdge, 50) }, { signal });
window.addEventListener('resize', () => { this.debounce(this.detectScrollEdge, 50) }, { signal });
this.navBtns.forEach((btn) => btn.addEventListener('click', () => {
this.slider.scrollLeft = this.calcScrollLeft(btn);
}, { signal }));
}
}
なお、addEventListener
の第三引数には、クラスフィールドで生成した AbortController
の signal
を渡しています。
disconnectedCallback
この見出しのリンクdisconnectedCallback()
では、 AbortController
の abort()
を呼び出すことによって、カスタム要素が削除されたときに connectedCallback()
で追加したイベントを破棄しています。
// カスタム要素が削除されたときに実行
disconnectedCallback() {
this.controller.abort();
}
attributeChangedCallback
この見出しのリンクattributeChangedCallback()
で監視する属性は、static get observedAttributes()
にリスト形式で指定します。
ここでは edge
属性をチェックし、スクロールの端に到達したときに指定される start
や end
の値に応じて、ナビのボタンに aria-disabled="true"
を指定しています。
// `attributeChangedCallback` で監視する属性
static get observedAttributes() { return ['edge']; }
// カスタム要素の属性が変更されたときに実行
attributeChangedCallback(name, oldValue, newValue) {
if (this.navBtns && name === 'edge') {
this.navBtns.forEach((btn) => btn.removeAttribute('aria-disabled'));
if (this.navBtns[0] && newValue === 'start') {
this.navBtns[0].setAttribute('aria-disabled', 'true');
} else if (this.navBtns[1] && newValue === 'end') {
this.navBtns[1].setAttribute('aria-disabled', 'true');
}
}
}
メソッド
見出し「メソッド」最後に、以下の 3 つのメソッドを定義しています。
メソッド | 呼び出されるタイミング |
---|---|
detectScrollEdge | スクロールの両端に到達したかを判定 |
calcScrollLeft | スクロール量を算出 |
debounce | スクロールイベントやリサイズイベントの処理を間引く |
対象となるコードは以下のとおりです。
// スクロールの両端に到達したかを判定し、`edge` 属性を指定
detectScrollEdge = () => {
const scrollLeft = this.slider.scrollLeft;
const scrollRight = this.slider.scrollWidth - (scrollLeft + this.slider.clientWidth);
if (scrollLeft <= 0) {
this.edge = 'start';
} else if (scrollRight <= 1) {
this.edge = 'end';
} else {
this.edge = 'false';
}
if (this.getAttribute('edge') !== this.edge) {
// 属性を変更すると `attributeChangedCallback()` が呼び出される
this.setAttribute('edge', this.edge);
}
}
// スクロール量を算出
calcScrollLeft = (btn) => {
const dir = (btn.getAttribute('data-nav-btn') === 'prev') ? -1 : 1;
const imageSize = this.image?.clientWidth ?? 300;
const totalItemsSize = imageSize * this.move;
const totalGap = this.gap * this.move;
return this.slider.scrollLeft + dir * (totalItemsSize + totalGap);
}
// debounce で処理を間引く
debounce = (fn, interval = 50) => {
clearTimeout(this.timer)
this.timer = setTimeout (() => fn(), interval);
}
これらのコードを組み合わせることで、イメージスライダーが完成します。
CSS 変数で設定を変更
見出し「CSS 変数で設定を変更」ここから発展させて、CSS 変数を経由してスライダーの設定をアレンジしていきます。CSS コードの先頭では、以下のように CSS 変数を指定していました。
image-slider {
--slider-padding: 40px; /* スライダーの外側の余白 */
--show-items: 2; /* 一度に表示するスライダーの数 */
--glance: 0.5; /* 次の要素が見切れるサイズ */
--move: 1; /* ナビのボタンで移動する距離 */
--gap: 20px; /* 要素間の余白 */
--item-min-size: 150px; /* 要素の最小サイズ */
--item-max-size: 300px; /* 要素の最大サイズ */
--scroll-snap-align: start; /* スナップする位置 */
--scrollbar-margin: 20px; /* スクロールバーとの余白 */
--scrollbar-width: 12px; /* スクロールバーのサイズ */
}
このとき、カスタム要素の style
属性で CSS 変数を直接上書きすることで、コンポーネントごとに個別に設定を変更することができます。
例えば、以下のように --scrollbar-width: 0;
と指定することで、スライダーのスクロールバーを非表示にできます。
<image-slider style="--scrollbar-width: 0;">
<!-- ... -->
</image-slider>
続いて、表示される要素数や余白、ナビのボタンで移動する距離を変更する例です。
<image-slider style="--show-items: 3; --move: 3; --gap: 1px;">
<!-- ... -->
</image-slider>
最後に、表示される要素数を 1 点のみに変更する例です。
<image-slider style="--show-items: 1; --glance: 0; --gap: 1px; --item-max-size: 100%;">
<!-- ... -->
</image-slider>
このように、CSS 変数を介して、さまざまなバリエーションで表示することが可能になります。
@property
この見出しのリンクCSS 変数で設定を変更する場合、@property
を使用して型をチェックすることで、より堅牢なコードになります。
以下はイメージスライダーの CSS に @property
を指定した例です。
@property --show-item {
syntax: "<number>";
inherits: false;
initial-value: 2;
}
@property --glance {
syntax: "<number>";
inherits: true;
initial-value: 0.5;
}
@property --move {
syntax: "<number>";
inherits: false;
initial-value: 1;
}
@property --gap {
syntax: "<length-percentage>";
inherits: true;
initial-value: 20px;
}
@property --scroll-snap-align {
syntax: "start | center | end";
inherits: true;
initial-value: start;
}
@property --scrollbar-width {
syntax: "<length-percentage>";
inherits: true;
initial-value: 0;
}
syntax
はプロパティが許容する型で、inherits
は継承の有無、initial-value
は初期値でフォールバックの役割を担います。syntax
が許可しないタイプの値を CSS 変数に指定すると、initial-value
の値が適用されます。
例えば、以下のように --move
に対して none
という異なる型の値を指定すると、initial-value
で指定した 1
が適用されます。
<image-slider style="--move: none;">
<!-- ... -->
</image-slider>
@property
は、現時点では Firefox が非対応です。
2024/06/20 追記 2024 年 7 月 9 日にリリース予定の Firefox 128 からサポートされる予定です。
Style Queries
見出し「Style Queries」前述の例では、以下の style
属性の指定でスクロールバーを非表示にしました。
<image-slider style="--scrollbar-width: 0;">
<!-- ... -->
</image-slider>
CSS コードは、以下のように属性セレクタでスタイルを指定しています。
image-slider[style*="--scrollbar-width: 0"] {
.c-slider {
--scrollbar-margin: 0;
scrollbar-width: none;
}
}
この指定方法でも有効なのですが、属性セレクタの値は一致している必要があるので、思わぬところで問題に発展する可能性があります。
具体的には、上記の指定では --scrollbar-width: 0
とコロンの後ろに半角スペースを含めていますが、--scrollbar-width:0
のように半角スペースを取り除くとセレクタが一致しなくなります。
この問題は、コンテナクエリの Style Queries を使用して、以下のように書き換えることで解消できます。
/* 基準となるコンテナを指定 */
image-slider {
container-type: inline-size;
}
/* Style Queries */
@container style(--scrollbar-width: 0) {
.c-slider {
--scrollbar-margin: 0;
scrollbar-width: none;
}
}
ここまでできると、いろいろと可能性が広がりそうなのですが、現時点では Chromium ベースのブラウザが一部の機能をサポートしているのにとどまります。
残念ながら、Interop 2024 の選出からも外れてしまったので2、実際に使用できるようになるには時間がかかりそうです。
おわりに
見出し「おわりに」この記事では、HTML Web Components のテクニックを使用して、Progressive Enhancement の思想をベースにしたイメージスライダーを作成しました。
今回は必要最小限の機能ですが、例えばマウス(ポインティングデバイス)のドラッグでスクロールできるようにする、スクロールバーのデザインを完全にカスタマイズする、ドットインジケータを付けるといったことも、JS や CSS を追加することで可能になります(実装するのは大変ですが)。
また、カスタム要素に style
属性を指定して CSS 変数を上書きすることで、柔軟にスタイルや振る舞いを変更できる点も確認できました。この特徴を利用することで、コンポーネントの再利用性を高めることができそうです。
本記事を作成していくにあたり、個人的には多くの学びがありましたので、また新たな題材があれば記事にしていければと考えています。
参考文献
見出し「参考文献」本記事の作成にあたり、以下のウェブページを参考にしました。
- CSS Scroll Snapping Aligned With Global Page Layout: A Full-Width Slider Case Study | Smashing Magazine(外部リンクを開く)
- Web Components | 12 Days of Web(外部リンクを開く)
- ウェブコンポーネント | MDN(外部リンクを開く)
- クラス | JavaScript Primer(外部リンクを開く)