先日公開した記事「HTML Web Components とは何か」では、HTML Web Components の概略について説明しました。

今回は、HTML Web Components のテクニックを使用してイメージスライダーを作成します。完成形は以下で、水平方向に画像が並び、スクロールバーとボタンでスクロール移動します。

Live Demo
画像 1
画像 2
画像 3
画像 4
画像 5
画像 6
画像 7
画像 8
画像 9
画像 10

HTML Web Components について簡単におさらいすると、Shadow DOM を使用しない Light DOM のカスタム要素で、HTML をベースに機能を積み増していく Progressive Enhancement の考え方に基づいたテクニックです。

今回作成するイメージスライダーは、この HTML Web Components の手法に基づき、以下の内容で実装していきます。

  • 水平方向のスクロールは CSS で実装
  • ボタンの機能は JavaScript で実装
    • 選択した方向にスクロール
    • 両端のいずれかに到達したら、対象となるボタンを非活性化
  • JavaScript がオフでも最低限の機能を提供
  • CSS 変数(カスタムプロパティ)で設定を変更できるようにする

それでは、HTML、CSS、JavaScript の順番で、具体的な実装内容を見ていきます。

まずは、基本となる HTML を以下に抜粋します。

イメージスライダーの HTML
<image-slider>
  <div class="c-inner">
    <!-- スライダー -->
    <div class="c-slider" data-slider>
      <div class="c-item">
        <img src="img-01.webp" alt="画像 1" width="400" height="400" loading="lazy">
      </div>
      <div class="c-item">
        <img src="img-02.webp" alt="画像 2" width="400" height="400" loading="lazy">
      </div>
      <div class="c-item">
        <img src="img-03.webp" alt="画像 3" width="400" height="400" loading="lazy">
      </div>
      <!-- ... -->
    </div>

    <!-- ナビゲーション -->
    <div class="c-nav">
      <button type="button" data-nav-btn="prev" class="c-nav-btn" aria-disabled="true">
        <svg viewBox="0 0 8 10" class="c-nav-svg" aria-hidden="true">
          <path d="M5.5,2 L1.5,5 L5.5,8" />
        </svg>
        <span class="sr-only">前のスライド</span>
      </button>
      <button type="button" data-nav-btn="next" class="c-nav-btn" aria-disabled="true">
        <svg viewBox="0 0 8 10" class="c-nav-svg" aria-hidden="true">
          <path d="M2.5,2 L6.5,5 L2.5,8" />
        </svg>
        <span class="sr-only">次のスライド</span>
      </button>
    </div>
  </div>
</image-slider>

カスタム要素 <image-slider> に、スライダーとナビゲーションの要素を含んでいます。

もし、このカスタム要素を複数箇所で使用する場合には、コードの一貫性やメンテナンス性の観点から、ナビの HTML は JS で動的に生成したほうがよいかもしれません。今回はレイアウトシフトが発生することを考慮し、HTML に直接記述する方法を選択しました。

<button> 要素には aria-disabled="true" を指定して、初期状態は無効であることを表しています。この状態は、後ほど JS で有効・無効を切り替えます。

スライダーのアクセシビリティ向上

見出し「スライダーのアクセシビリティ向上」

スライダー(カルーセル)のアクセシビリティを向上させるには考慮すべきことが非常に多いのですが、本記事のデモでは、例えば aria-roledescription といった属性の指定や、tabindex 指定の制御、ライブリージョンの指定などは対応していません。

より詳しい実装内容は、以下のウェブページが参考になりますが、これらを全部しっかりやろうとすると結構大変です。

上記で紹介している「Splide」ですが、2022 年 9 月から更新が止まっているようですので、使用する際には慎重に検討したほうがよいかもしれません。

続いて、少し長いですが CSS のコードを抜粋します。

イメージスライダーの CSS
image-slider {
  /* CSS 変数の定義とカスタム要素のスタイル */
  --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;    /* スクロールバーのサイズ */

  display: block;
  padding: var(--slider-padding);
  background-color: #9296ab;
}
image-slider .c-inner {
  --item-size: calc((100% - var(--show-items) * var(--gap)) / (var(--show-items) + var( --glance)));

  display: grid;
  row-gap: 20px;
}

/* スライダー */
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);
}
image-slider .c-item img {
  display: block;
  inline-size: 100%;
  block-size: auto;
  aspect-ratio: 1;
  object-fit: cover;
}

/* ナビゲーション */
image-slider .c-nav {
  display: grid;
  grid-template-columns: repeat(2, auto);
  column-gap: 16px;
  justify-content: end;
  transition: opacity 0.2s ease;
}
image-slider .c-nav-btn {
  --btn-size: 50px;
  --btn-color: #004d4d;
  --btn-color-hover: color-mix(in srgb, var(--btn-color) 80%, white);

  display: grid;
  gap: 4px;
  align-content: center;
  place-items: center;
  inline-size: var(--btn-size);
  block-size: var(--btn-size);
  padding: 4px;
  border: 2px solid var(--btn-color);
  border-radius: 50%;
  background: none;
  cursor: pointer;
  transition: opacity 0.2s ease;
}
image-slider .c-nav-btn[aria-disabled="true"] {
  opacity: 0.4;
  cursor: default;
}
image-slider .c-nav-svg {
  inline-size: 18px;
  margin-inline: auto;
}
image-slider .c-nav-svg path {
  fill: none;
  stroke: var(--btn-color);
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-width: 1px;
  transition: stroke 0.2s ease;
}
@media (hover) {
  image-slider .c-nav-btn:not([aria-disabled="true"]):hover {
    background-color: var(--btn-color-hover);
  }
  image-slider .c-nav-btn:not([aria-disabled="true"]):hover path {
    stroke: #fff;
  }
}

@layer utilities {
  .sr-only {
    position: absolute;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    inline-size: 1px;
    block-size: 1px;
    margin: -1px;
    padding: 0;
    border-width: 0;
    white-space: nowrap;
  }
}

カスタム要素に 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-sliderdisplay: 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 が実現できます。

Live Demo
画像 1
画像 2
画像 3
画像 4
画像 5
画像 6
画像 7
画像 8
画像 9
画像 10

ナビのボタンは機能しませんが、スクロールバーを操作したり、タッチデバイスであればスワイプで水平方向にスクロールできるので、最低限の機能を有しているといえます。

スクロールバーのスタイリング

見出し「スクロールバーのスタイリング」

上記の 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 メディア特性を追加しました。

強制カラーモードが有効ではないときに適用
@media not (forced-colors: active) {
  /* スクロールバーの CSS */
}

この変更は以下の記事を読んだことがきっかけですが、もちろん forced-colors メディア特性で分岐すれば問題が発生しないということではありません。そもそも、スクロールバーのスタイルを変更すること自体を避けたほうがよいかもしれません。

それでは仕上げに、JS でカスタム要素を定義します。

イメージスライダーの JS
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;

  // 初期化
  constructor() {
    super();
  }

  // カスタム要素が追加されたときに実行
  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 }));
    }
  }

  // カスタム要素が削除されたときに実行
  disconnectedCallback() {
    this.controller.abort();
  }

  // `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');
      }
    }
  }

  // スクロールの両端に到達したかを判定し、`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) {
      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);
  }
}
customElements.define('image-slider', ImageSlider);

こちらも 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) によってカスタム要素自身のスタイル情報を格納しています。これは、続く movegap で CSS 変数の値を取得するときに使用しています。

controller フィールドは、AbortController のインスタンスを格納しています。これは、disconnectedCallback でカスタム要素を削除したときにイベントを破棄するために使用します。

timer フィールドは、スクロールイベントやリサイズイベントの処理を間引くために用意している debounce メソッドで使用します。

constructor() は、クラスをインスタンス化したときに実行されますが、初回に実行したい処理やイベントの設定は connectedCallback() でおこなっているので、ここでは super() のみ記述しています。

// 初期化
constructor() {
  super();
}

ライフサイクルコールバック

見出し「ライフサイクルコールバック」

ライフサイクルコールバックでは、カスタム要素の状態が変更されるタイミングでメソッドを呼び出すことができます。以下の 4 つのメソッドが用意されています。

メソッド呼び出されるタイミング
connectedCallbackカスタム要素が追加されたとき
disconnectedCallbackカスタム要素が削除されたとき
attributeChangedCallbackカスタム要素の属性が変更されたとき
adoptedCallbackカスタム要素が別のドキュメントに移動したとき

今回は adoptedCallback() を除く、3 つのメソッドを使用しています。

まず、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 の第三引数には、クラスフィールドで生成した AbortControllersignal を渡しています。

disconnectedCallback

この見出しのリンク

disconnectedCallback() では、 AbortControllerabort() を呼び出すことによって、カスタム要素が削除されたときに connectedCallback() で追加したイベントを破棄しています。

// カスタム要素が削除されたときに実行
disconnectedCallback() {
  this.controller.abort();
}

attributeChangedCallback

この見出しのリンク

attributeChangedCallback() で監視する属性は、static get observedAttributes() にリスト形式で指定します。

ここでは edge 属性をチェックし、スクロールの端に到達したときに指定される startend の値に応じて、ナビのボタンに 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);
}

これらのコードを組み合わせることで、イメージスライダーが完成します。

Live Demo
画像 1
画像 2
画像 3
画像 4
画像 5
画像 6
画像 7
画像 8
画像 9
画像 10

ここから発展させて、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>
Live Demo
画像 1
画像 2
画像 3
画像 4
画像 5
画像 6
画像 7
画像 8
画像 9
画像 10

続いて、表示される要素数や余白、ナビのボタンで移動する距離を変更する例です。

<image-slider style="--show-items: 3; --move: 3; --gap: 1px;">
  <!-- ... -->
</image-slider>
Live Demo
画像 1
画像 2
画像 3
画像 4
画像 5
画像 6
画像 7
画像 8
画像 9
画像 10

最後に、表示される要素数を 1 点のみに変更する例です。

<image-slider style="--show-items: 1; --glance: 0; --gap: 1px; --item-max-size: 100%;">
  <!-- ... -->
</image-slider>
Live Demo
画像 1
画像 2
画像 3
画像 4
画像 5
画像 6
画像 7
画像 8
画像 9
画像 10

このように、CSS 変数を介して、さまざまなバリエーションで表示することが可能になります。

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 年 4 月 16 日にリリース予定の Firefox 125 からサポートされる予定です

前述の例では、以下の 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 変数を上書きすることで、柔軟にスタイルや振る舞いを変更できる点も確認できました。この特徴を利用することで、コンポーネントの再利用性を高めることができそうです。

本記事を作成していくにあたり、個人的には多くの学びがありましたので、また新たな題材があれば記事にしていければと考えています。

本記事の作成にあたり、以下のウェブページを参考にしました。

脚注

  1. The web just gets better with Interop 2024 | WebKit

  2. CSS style container queries (custom properties) | Issue #433 | web-platform-tests/interop