【JavaScript】スクロールして画面と要素が交差したら表示

今や当たり前となったスクロールして要素をふわっと表示させるアレ。

* プラグインを利用したくない
* HTMLのdata属性でオプションを設定したい

というわけで、自作してみました！

## 仕様

単一要素への設定と複数要素への一括設定が可能です。

### 単一設定

* 表示したい要素にclass（`js-inview`）を設定します
* 表示したい要素のdata属性（`data-inview-animation`）を設定します
  * data属性の値にCSSアニメーション名を設定します
* 画面と表示したい要素が交差した時にclass（`is-inview`）が付与されます

#### オプション

オプションは表示したい要素のdata属性に設定します。

| data属性               | デフォルト | 設定内容 |
| ----------------------|----------|-------------- |
| data-inview-animation | なし（必須） | CSSアニメーション名 |
| data-inview-delay     | 0 | 交差してからの遅延秒数（ミリ秒） |
| data-inview-options 　| { rootMargin: '0px'} | Intersection Observer のオプション |
| data-inview-toggle    | - | 交差するごとに表示・非表示を繰り返す |
| data-inview-trigger   | なし | 要素の表示をトリガーとして表示させたい要素 |

### 複数設定

* 表示したい要素の親要素にclass（`js-inviews`）を設定します
* 表示したい要素の親要素にdata属性（`data-inview-targets-animation`）を設定します
  * data属性の値にCSSアニメーション名を設定します
* 表示したい要素にdata属性（`data-inview-target`）を設定します
  * data属性の値は設定しません
* 画面と表示したい要素が交差した時にclass（`is-inView`）が付与されます

#### オプション

表示させたい要素の親要素のdata属性に設定します。

| data属性               | デフォルト | 設定内容 |
| ----------------------|----------|-------------- |
| data-inview-targets-animation | なし（必須） | 表示させたい子要素に設定するCSSアニメーション名 |
| data-inview-delay     　| 0 | 交差してからの遅延秒数（ミリ秒） |
| data-inview-delay-loop | 0 | 表示させたい子要素を連続して表示する間隔（ミリ秒） |
| data-inview-options 　| { rootMargin: '0px'} | Intersection Observer のオプション |
| data-inview-toggle    | - | 交差するごとに表示・非表示を繰り返す |

:::note
複数設定した場合でも、表示させたい要素に単一設定のオプションを設定することで上書きが可能です。
:::

## CSS

```css:css
/* 要素を非表示にする設定 */
[data-inview-animation],
[data-inview-target] {
  opacity: 0;
}
/* アニメーション設定 */
[data-inview-animation='fade-in'].is-inView {
  animation: fade-in 1s 0s forwards;
}
@keyframes fade-in {
  0% {
    opacity: 0;
  }
  100% {
    opacity: 1;
  }
}
```

## JavaScript

```javascript:JS
class Inview {
  #defaults
  #options
  #inviewList
  #inviewsList
  constructor(config) {
    this.#defaults = {
      inviewSelector: '.js-inview',
      inviewsSelector: '.js-inviews',
      activeClass: 'is-inview',
      rootMargin: '0px'
    }
    this.#options = { ...this.#defaults, ...config }
    this.#inviewList = document.querySelectorAll(this.#options.inviewSelector)
    this.#inviewsList = document.querySelectorAll(this.#options.inviewsSelector)
    this.#init()
  }

  #init() {
    this.#single()
    this.#multiple()
  }

  // 単一要素を表示
  #single() {
    for (const target of this.#inviewList) {
      const callback = (entries) => {
        for (const entry of entries) {
          const isInviewToggle = target.dataset.inviewToggle === ''
          // 画面と要素が交差
          if (entry.isIntersecting) {
            this.#show(target)
            this.#showTrigger(target)
            // target.dataset.inviewToggle属性がない場合
            // 表示後は監視を解除
            if (!isInviewToggle) {
              observer.unobserve(target)
            }
          }
          // target.dataset.inviewToggle属性がある場合
          // 画面内から消えたら要素を非表示
          if (!entry.isIntersecting && isInviewToggle) {
            this.#hide(target)
          }
        }
      }
      const observer = new IntersectionObserver(
        callback,
        this.#getOptions(target)
      )
      observer.observe(target)
    }
  }

  // 複数要素を表示
  #multiple() {
    for (const inviewsItem of this.#inviewsList) {
      const inviewTargets = inviewsItem.querySelectorAll('[data-inview-target]')
        .length
        ? inviewsItem.querySelectorAll('[data-inview-target]')
        : inviewsItem.querySelectorAll('[data-inview-animation]')

      for (const target of inviewTargets) {
        this.#setAnimation(target)
        const inviewsTarget = inviewsItem.dataset.inviewDelayLoop
          ? inviewsItem
          : target

        const callback = (entries) => {
          entries.forEach((entry, index) => {
            // 画面と要素が交差
            if (entry.isIntersecting) {
              // inview-delay-loopが設定されていたら
              if (inviewsItem.dataset.inviewDelayLoop) {
                inviewTargets.forEach((item, index) => {
                  this.#show(item, index)
                  this.#showTrigger(item)
                })
              }
              // inview-delay-loop設定なし
              else {
                this.#show(target, index)
                this.#showTrigger(target)
              }
              // target.dataset.inviewToggle属性がない場合
              // 表示後は監視を解除
              if (!this.#isInviewToggle(target)) {
                observer.unobserve(target)
              }
            }
            // target.dataset.inviewToggle属性がある場合
            // 画面内から消えたら要素を非表示
            if (!entry.isIntersecting && this.#isInviewToggle(target)) {
              this.#hide(target)
            }
          })
        }
        const observer = new IntersectionObserver(
          callback,
          this.#getOptions(inviewsTarget)
        )
        observer.observe(target)
      }
    }
  }

  // アニメーション対象となる子要素に
  // アニメーション設定用のdata属性を付与
  #setAnimation(el) {
    const closest = el.closest(this.#options.inviewsSelector)
    if (!el.dataset.inviewAnimation && closest.dataset.inviewTargetsAnimation) {
      el.dataset.inviewAnimation = closest.dataset.inviewTargetsAnimation
    }
  }

  // 交差するごとに表示・非表示を繰り返す
  #isInviewToggle(el) {
    const closest = el.closest(this.#options.inviewsSelector)
    if (
      el.dataset.inviewToggle === '' ||
      (closest && closest.dataset.inviewToggle === '')
    ) {
      return true
    }
  }

  // Intersection Observer のオプションを返す
  #getOptions(el) {
    const closest = el.closest(this.#options.inviewsSelector)
    let options = null
    if (el.dataset.inviewOptions) {
      options = el.dataset.inviewOptions
    } else if (closest && closest.dataset.inviewOptions) {
      options = closest.dataset.inviewOptions
    }
    return options ? JSON.parse(options) : this.#options
  }

  // 要素が見えてからの表示タイミング（ミリ秒）を返す
  #getDelay(el, index) {
    const closest = el.closest(this.#options.inviewsSelector)
    // 単一要素を表示
    if (el.dataset.inviewDelay) {
      return el.dataset.inviewDelay
    }
    // 複数要素を表示
    if (closest) {
      // 親要素にdata-inview-delay-loop属性あり
      if (closest.dataset.inviewDelayLoop) {
        // 親要素にdata-inview-delay属性あり
        if (closest.dataset.inviewDelay) {
          if (index === 0) {
            return closest.dataset.inviewDelay
          }
          return (
            closest.dataset.inviewDelayLoop * index +
            parseInt(closest.dataset.inviewDelay)
          )
        }
        // 親要素にdata-inview-delay属性なし
        return closest.dataset.inviewDelayLoop * index
      }
      // 親要素にdata-inview-delay属性あり
      if (closest.dataset.inviewDelay) {
        return closest.dataset.inviewDelay
      }
    }
  }

  // 要素にclassを付与して表示させる
  #show(el, index) {
    const duration = this.#getDelay(el, index)
    let startTime = null
    const timer = (currentTime) => {
      if (!startTime) startTime = currentTime
      const progress = Math.floor(currentTime - startTime)
      if (progress < duration) {
        window.requestAnimationFrame(timer)
      } else {
        el.classList.add(this.#options.activeClass)
      }
    }
    window.requestAnimationFrame(timer)
  }

  // 要素のclassを削除して非表示にする
  #hide(el) {
    el.classList.remove(this.#options.activeClass)
  }

  // 要素の表示と同時に指定した要素にclassを付与して表示させる
  #showTrigger(el) {
    if (el.dataset.inviewTrigger) {
      const triggers = document.querySelectorAll(el.dataset.inviewTrigger)
      for (const trigger of triggers) {
        this.#show(trigger)
      }
    }
  }
}
const inview = new Inview()
```

## DEMO

<p class="codepen" data-height="500" data-default-tab="result" data-slug-hash="ZEajwKm" data-user="taqumo" style="height: 500px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/taqumo/pen/ZEajwKm">
  show element on scroll</a> by Takuya Mori (<a href="https://codepen.io/taqumo">@taqumo</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>