アワード系のWebサイトで見かける、ゆったりした慣性スクロールの実装

 :::note info
[Lenis](https://github.com/darkroomengineering/lenis) という慣性スクロールのライブラリが素敵です。
:::

[Awwwards](https://www.awwwards.com)や[FWA](https://thefwa.com)に掲載されているサイトでよく見かける、ゆったりした慣性スクロール。

http://madies.mx
http://www.amandabraga.com
http://56k.studiovoila.com

見覚えがある人もいると思いますが、スクロールの反応が気持ち遅れて来る、自前の慣性スクロールっぽいやつです。これのやり方の参考が少なく実装に手間取ったので、実装例をまとめました。他にも色々なアプローチがありそうですが、参考のひとつとして。

最初に断っておきますが、かなり邪悪な実装です。ブラウザの本来の機能を上書きする実装の多くは、ユーザーの利益にはならないので、ご利用は計画的に。。

**デモ**
https://codepen.io/nishinoshake/full/dyowbyr

# 採用する利点と欠点
利点があまり浮かばない・・・

## 利点
* このスクロールに心地よさを感じる人がいるらしい

## 欠点
* 慣れていない人にとっては違和感がある
* スクロール系のライブラリと相性が悪い
* 低スペックなマシンだと見るに耐えない
* 邪悪な実装がサイトの寿命を縮める


# 原理
スクロールと同期して、transformで要素を滑らせる。以下、スクロールしたいエリアをコンテナと呼びます。

```
1. コンテナにposition:fixedを設定
2. bodyにコンテナの高さを設定
3. スクロール量をコンテナのtransformに少しづつ設定
```

1.のfixedだけだと、bodyの高さが無くなってスクロールができなくなり、スクロールバーも表示されません。スクロールバーが無いままのサイトや、独自でカスタムしている邪悪なサイトもありますが、これ以上は罪を重ねない方が良いと思います。

bodyにコンテナの高さを設定すると、通常のスクロールイベントを拾えるので、transformとの同期も楽になります。mousewheelやtouchmoveイベントの実装は辛いので、こちらの方がまだマシです。

前に[JavaScriptのイベントをたくさん見られるサイト](https://listener.noplan.cc)を作ったので、イベントに不慣れな方はぜひ。

# 実装例

```html
<div id="container">
  ここにコンテンツ
</div>
```
```js
class MomentumScroll {
  constructor(selector) {
    this.container = document.querySelector(selector)
    this.scrollY = 0
    this.translateY = 0
    this.speed = 0.1
    this.rafId = null
    this.isActive = false
    
    this.scrollHandler = this.scroll.bind(this)
    this.resizeHandler = this.resize.bind(this)
    
    this.run()
  }
  
  run() {
    if (this.isActive) {
      return
    }
    
    this.isActive = true

    this.on()
    this.setStyles()
  }
  
  destroy() {
    if (!this.isActive) {
      return
    }
    
    this.isActive = false
    
    this.off()
    this.clearStyles()
    
    if (this.rafId) {
      cancelAnimationFrame(this.rafId)
    }
    
    this.rafId = null
  }
  
  resize() {
    document.body.style.height = `${this.container.clientHeight}px`
  }
  
  scroll() {
    this.scrollY = window.scrollY || window.pageYOffset
    
    if (!this.rafId) {
      this.container.style.willChange = 'transform'
      this.rafId = requestAnimationFrame(() => this.render())
    }
  }
  
  on() {
    this.resize()
    this.scroll()
    window.addEventListener('scroll', this.scrollHandler, { passive: true })
    window.addEventListener('resize', this.resizeHandler)
    window.addEventListener('load', this.resizeHandler)
  }
  
  off() {
    window.removeEventListener('scroll', this.scrollHandler)
    window.removeEventListener('resize', this.resizeHandler)
    window.removeEventListener('load', this.resizeHandler)
  }
  
  setStyles() {
    this.container.style.position = 'fixed'
    this.container.style.width = '100%'
    this.container.style.top = 0
    this.container.style.left = 0
  }
  
  clearStyles() {
    document.body.style.height = ''
    this.container.style.position = ''
    this.container.style.width = ''
    this.container.style.top = ''
    this.container.style.left = ''
    this.container.style.transform = ''
    this.container.style.willChange = ''
  }
  
  render() {
    const nextY = this.translateY + (this.scrollY - this.translateY) * this.speed    
    const isNear = Math.abs(this.scrollY - nextY) <= 0.1
    
    this.translateY = isNear ? this.scrollY : nextY
    
    const roundedY = Math.round(this.translateY * 100) / 100
    
    this.container.style.transform = `translate3d(0, -${roundedY}px, 0)`
    
    if (isNear) {
      this.rafId = null
      this.container.style.willChange = ''
    } else {
      this.rafId = requestAnimationFrame(() => this.render())
    }
  }
}

document.addEventListener('DOMContentLoaded', () => {
  const momentumScroll = new MomentumScroll('#container')
  
  // document.body.addEventListener('click', () => {
  //   if (momentumScroll.isActive) {
  //     momentumScroll.destroy() 
  //   } else {
  //     momentumScroll.run() 
  //   }
  // })
})
```

## Codepen

<p class="codepen" data-height="350" data-theme-id="light" data-default-tab="js,result" data-user="nishinoshake" data-slug-hash="dyowbyr" style="height: 350px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;" data-pen-title="Minimal Momentum Scroll">
  <span>See the Pen <a href="https://codepen.io/nishinoshake/pen/dyowbyr">
  Minimal Momentum Scroll</a> by nishinoshake (<a href="https://codepen.io/nishinoshake">@nishinoshake</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://static.codepen.io/assets/embed/ei.js"></script>

## スマホを除外したい
iPhoneやAndroidで実行したくない場合は、ユーザーエージェントを確認して、PCのブラウザの時だけ実行してください。

## ゆったりさせるところの抜粋
スピードをパラメータで設定して、段階的に適用していくイメージ。

```js
// 必要なところだけ
this.scrollY = 0
this.translateY = 0
this.speed = 0.1

// スクロール時に値を設定
this.scrollY = window.scrollY || window.pageYOffset

// レンダリング時に少しづつ実際のスクロール値に近づける
const nextY = this.translateY + (this.scrollY - this.translateY) * this.speed
```

# 他に良い実装があったら知りたい
できる限りの最適化はしているつもりですが、良い実装があったら教えて下さい！