📝

2022版 JavaScriptを使わずにCSSだけでイメージスライドをつくる

2022/10/17に公開

JavaScriptなし(CSSだけ)でつくると何が嬉しいの?

  • 昨今では、JavaScriptがOffの環境を考慮する必要はほぼないけれど、なくても動作するという一定の自己満足を得られる。
  • これひとつで決して劇的に変わるものではないけれど、JS実装と比較して動作パフォーマンスの向上に貢献できる。
  • scriptやiframeタグを使用できないなど、使用タグが制限されているCMSで活躍できる可能性がある。

既に似たようなものがあるよね?

あります。この記事では、進化を続けているCSSの新しめの機能を使って、以前のものを、よりブラッシュアップできるのではないかというチャレンジです。

これまでの実装方法の確認

僕の観察範囲では、CSSのみで実装するイメージスライドには2つの方法があります。

URLハッシュフラグメントを使う

URLハッシュフラグメントを用いてスクロール位置を調整する方法。
現時点では、もっとも理想に近い実装手段。

有利な点

  • タッチ端末でのフリック操作にも対応できる
  • 演出の幅が広い(やろうと思えばいろいろできそう)
  • セマンティックなhtmlが書ける

不利な点

  • URLを変更するため、履歴が汚れる。(進む、戻るの操作がユーザーの意図しない結果を生む)
  • 履歴汚染を避けるには、iframeでの実装が必要になる
  • 上記が問題なければ、この方法がおそらくベスト。

参考URL
https://css-tricks.com/can-get-pretty-far-making-slider-just-html-css/

:checkedを使う

input[type="radio"]の:checkedを判断して操作する方法

有利な点

  • キーボード操作に対応できる

不利な点

  • html/cssが複雑になる(セマンティックhtmlになりづらい)
  • 画像枚数によってCSSの変更が必要(メンテナンスコストが高い)
  • タッチ端末でのフリック操作には対応できない
  • トランジションは限定的

参考URL
https://webdesign.tutsplus.com/tutorials/how-to-build-a-simple-slideshow-with-the-css-checkbox-hack--cms-34465

今回作ったもの

scriptやiframeタグが使えない環境を想定し、URLフラグメントを使う方法は見送ります。
つまり、:checkedを使った実装をブラッシュアップします。

解決できること

  • html/cssがセマンティックになり、とてもシンプルになる
    (inputを使う時点でセマンティックじゃない説)
  • 画像枚数によってCSSを変更する必要がない
    htmlに必要な要素を追加するだけでOK

解決できないこと

  • タッチ端末でのフリック操作には対応できない
  • トランジションは限定的

デモページ

https://scrap.pages.dev/slide/

何をどのように解決したか

これ以降は、実装で苦労した部分を書いているだけなので、時間がどうしようもなく余っている人だけ読んでください。m(_ _)m

概要

:checkedを使ったスライド機能の仕組みは、:checkedな要素に対して、隣接セレクタなどを用いて画像の表示を切り替えるというものです。そのため、CSSで:checkedからimgにアクセスできる場所に、html側で要素を記述しなければならないという制約があります。

<input type="radio" name="slide" id="slide1">
<img src="slide1.jpg">
<style>
img {
  opacity: 0;
}
:checked + img {
  opacity: 1;	
}
</style>

また、この制約上、スライドにページャー機能や進む・戻る機能を設ける場合、CSS側で画像の枚数分のスタイルを指定する必要がありました。以下のとおり、従来の方法では、セマンティックではないhtmlが生成され、CSSも画像枚数分のメンテナンスコストが発生しています。

<input type="radio" name="slide" id="slide1">
<img src="slide1.jpg">
<label class="pager" for="slide1"></label>
<style>
/* no select */
.pager {
  color: gray;
}
/* idを基準にしているため、この記述が画像枚数分必要 */
#slide1:checked ~ .pager[for="slide1"]{
  color: black;
}
</style>

今回のチャレンジは、このhtml/cssの複雑さを、:hasを利用することで取り払うというものです。
CSS側で:checkedな要素を起点にして他要素を選択する場合、:has登場以前は、隣接セレクタを利用した「直後の兄弟要素」や「それ以降の兄弟要素」という選択方法しかありませんでしたが、:hasを利用することで、「親要素」「親要素の直後の要素など、アクセスできる要素が増えたことにより、html記述の制約が大幅に緩和されました。

<ul class="slider">
  <li>
    <label><input type="radio" name="slide" id="slide1"></label>
    <img src="slide1.jpg">
  </li>
</ul>
<style>
/* checkedな要素を含むliが持つ画像を表示する */
li:has(:checked) img {
  opacity: 1;
}
</style>

レイアウト

今回のレイアウト方法は、pager部分にinputそのものを利用し、進む・戻る機能をinputに対応するlabelの:before:afterが担当します。デモと併せて確認すると分かりやすいかもしれません。さらっと書いてますが、レイアウトとしてはかなりトリッキーです。

<ul class="slide">
  <li>
    <label for="slide1">
      <input type="radio" name="slide" id="slide1" checked />
    </label>
    <img src="https://placeimg.com/800/533/tech" />
  </li>
  .
  .
  .
</ul>

スライドのロジック

レイアウト自体もかなりトリッキーな実装でしたが、メインディッシュはそこではありません。
今回のチャレンジは、進む・戻るボタンが肝です。
labelをクリックすることで、対応するinputのcheckを切り替えられる仕様を利用し、進む・戻るボタンはlabel要素の:before/:afterに担当させます。つまり、CSS側で:checkedに対応するlabelを選択する必要があります。

「進む」ボタンについては、:hasさえ使えれば簡単でした。
「:checkedな子要素を含むliを選択し、それに対して隣接しているliを選択」できれば完成です。

li:has(:checked) + li [for^="slide"]:after

問題は、「戻る」ボタンです。
本来なら、「:checkedな子要素を含むliを直後に持つli」を選択したいのですが、どうやら:hasのネストはできない仕様のようです。(たぶん

/* これは機能しない */
li:has(+ li:has(:checked)) [for^="slide"]:after

そこで、半ば力技で導き出したコードが以下です。

li:not(li:has(:checked), li:has(:checked) ~ li) [for^="slide"]:before

戻るボタンなので、:checkedになっている要素以降のボタンには用がないため除外しています。
答えを言ってしまえば、それだけですが、なぜこれで動作するかというと、重なり順です。
仮に3番目の画像が選択されている際には、2番目に該当するボタンを「戻る」に表示したいですが、このコードでは、1番目と2番目のボタンが同時に重なって表示されています。1番目よりも2番目の方がhtml上で新しい要素(後続の要素)なので、2番目のボタンが1番目のボタンの上に表示されます。結果として、2番目のボタンが戻るボタンとして機能している、という仕様になりました。苦しい力技でした。

まとめ

実装はトリッキーでしたが、一度作ってしまえばこちらのものです。あとは、仕様に従ってliの内容を増減させるだけで運用ができます。html/cssをスッキリさせて、わりと簡単に運用できるようになったかと思います。
根本的な実装方法を理解していることを前提に記事を起こしたため、多くの方への説明不足は否めません。最後にhtml/cssの全容を残しますが、もし不明な点などがあったら、Twitterで絡んでもらえると喜びます。
では、よいhtml/cssライフを。

<ul class="slide">
      <li>
        <label for="slide1"><input type="radio" name="slide" id="slide1" checked /></label>
        <img src="https://placeimg.com/800/533/tech" />
      </li>
      <li>
        <label for="slide2"><input type="radio" name="slide" id="slide2" /></label>
        <img src="https://placeimg.com/800/533/people" />
      </li>
      <li>
        <label for="slide3"><input type="radio" name="slide" id="slide3" /></label>
        <img src="https://placeimg.com/800/533/animals" />
      </li>
      <li>
        <label for="slide4"><input type="radio" name="slide" id="slide4" /></label>
        <img src="https://placeimg.com/800/533/nature" />
      </li>
      <li>
        <label for="slide5"><input type="radio" name="slide" id="slide5" /></label>
        <img src="https://placeimg.com/800/533/grayscale" />
      </li>
      <li>
        <label for="slide6"><input type="radio" name="slide" id="slide6" /></label>
        <img src="https://placeimg.com/800/533/sepia" />
      </li>
      <li>
        <label for="slide7"><input type="radio" name="slide" id="slide7" /></label>
        <img src="https://placeimg.com/800/533/animals" />
      </li>
      <li>
        <label for="slide8"><input type="radio" name="slide" id="slide8" /></label>
        <img src="https://placeimg.com/800/533/nature" />
      </li>
</ul>
.slide {
  --img-size: min(800px, 85vw);
  --aspect-ratio: 1.5;
  position: relative;
  display: flex;
  align-items: flex-end;
  justify-content: center;
  gap: 8px;
  inline-size: var(--img-size);
  aspect-ratio: var(--aspect-ratio);
  border: 1px solid #ccc;
  box-sizing: content-box;
  margin: auto;
  padding: 0;
  background-color: #ccc;
  text-align: center;
}
.slide li {
  list-style: none;
  width: 18px;
  aspect-ratio: 1;
  user-select: none;
}
.slide input {
  translate: 0 50px;
  width: 100%;
  height: 100%;
  border-radius: 100%;
  background-color: #eee;
  cursor: pointer;
}
.slide input:checked {
  background-color: #bbb;
}
.slide img {
  --opacity: 0;
  position: absolute;
  top: 0;
  left: 0;
  inline-size: var(--img-size);
  aspect-ratio: var(--aspect-ratio);
  opacity: var(--opacity);
  transition: opacity 0.2s 0s ease-out;
  pointer-events: none;
}
[for^="slide"]:has(:checked) + img {
  --opacity: 1;
}
[for^="slide"]:before,
[for^="slide"]:after {
  --btn-size: calc(var(--img-size) / 13);
  position: absolute;
  top: calc(50% - (var(--btn-size) / 2));
  inline-size: var(--btn-size);
  aspect-ratio: 1;
  background: #ffffffaa url(data:image/svg+xml;charset=utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%3E%3Cpath%20d%3D%22M16.67%200l2.83%202.829-9.339%209.175%209.339%209.167-2.83%202.829-12.17-11.996z%22%2F%3E%3C%2Fsvg%3E) no-repeat 50% 50% / calc(var(--btn-size) * 0.4);
  border: 1px solid #eee;
  border-radius: 100%;
  color: #fff;
  cursor: pointer;
  z-index: 1;
}
[for^="slide"]:before {
  left: calc(var(--btn-size) / 2 * -1);
}
[for^="slide"]:after {
  left: calc(var(--img-size) - var(--btn-size) / 2);
  rotate: 180deg;
}
/* 大事なのはここだけ
-----------------------------------
*/
li:has(:checked) + li [for^="slide"]:after,
li:not(li:has(:checked), li:has(:checked) ~ li) [for^="slide"]:before {
  content: "";
}

Discussion