🐧

overflow:scrollの初期表示でJavaScriptを使わず任意の位置にスクロールさせるワークアラウンド

に公開

前置き

スクロール可能な要素で初期表示時点で終端にスクロールしておきたいという場面が稀にあるかもしれません。

目標

そのような場合、React、Next.jsではuseEffectを利用して実現することが多いと思います。

export default function Page() {
  const containerRef = useRef<HTMLDivElement|null>(null)
  useEffect(() => {
    containerRef.current?.scrollTo({left: containerRef.current.scrollWidth})
  },[])

  return (
    <div className={styles.container} ref={containerRef}>
        <div className={styles.item}>Slide 1</div>
        <div className={styles.item}>Slide 2</div>
        <div className={styles.item}>Slide 3</div>
        <div className={styles.item}>Slide 4</div>
        <div className={styles.item}>Slide 5</div>
        <div className={styles.item}>Slide 6</div>
        <div className={styles.item}>Slide 7</div>
        <div className={styles.item}>Slide 8</div>
        <div className={styles.item}>Slide 9</div>
        <div className={styles.item}>Slide 10</div>
    </div>
  )
}
.container {
  display: flex;
  overflow-x: scroll;
}

.item {
  border: 1px solid red;
  text-wrap: nowrap;
}

しかし、Next.jsでは以下のように初期表示で一度左端にスクロールされた状態で表示されてから、右端にスクロールされてしまいます。

ガタガタ

CLSとは判定されないようですが、不安定に見えて不恰好です。

この記事では、初期表示で右端にスクロールされた状態になることを目指します。JavaScriptを利用する場合は上記のような問題が発生する場合があるため、HTMLとCSSのみで実現していきます。

stackoverflowなどの多くの記事でJavaScriptを利用している例が出てきますが、Next.jsではスクリプトが実行されるまでにいくらか時間がかかるのでこのようになってしまいます。

ワークアラウンド

以下の記事を参考にしています。英語に抵抗がなければそちらを読んでいただいた方が良いと思います。
https://blog.kizu.dev/snappy-scroll-start/

こちらのIssueで議論されているscroll-startという機能が実現されるまでのワークアラウンドについて解説します。

なかなか複雑なことをやらないといけないので早くscroll-startが欲しいです。

それでは解説していきます。まず、コードをペタペタしていきます。
実装はNext.jsで行っています。

export default function Page() {
  return (
    <div className={styles.container}>
        <div className={styles.item}>Slide 1</div>
        <div className={styles.item}>Slide 2</div>
        <div className={styles.item}>Slide 3</div>
        <div className={styles.item}>Slide 4</div>
        <div className={styles.item}>Slide 5</div>
        <div className={styles.item}>Slide 6</div>
        <div className={styles.item}>Slide 7</div>
        <div className={styles.item}>Slide 8</div>
        <div className={styles.item}>Slide 9</div>
        <div className={styles.item}>Slide 10</div>
        <div className={styles.scrollStart}></div>
    </div>
  )
}

JSX側の差分はシンプルです。最後に<div className={styles.scrollStart}></div>を追加するだけです。初期表示でこの要素にスクロールされた状態にすることが目標です。

CSSの方は複雑です。一旦、実装をペタペタしてから解説を進めます。何をやってるかパッと見ではわからないと思います。

.container {
  display: flex;
  overflow-x: scroll;
  scroll-snap-type: x mandatory;
  position: relative;
}

.item {
  border: 1px solid red;
  text-wrap: nowrap;
}

.scrollStart {
  position: absolute;
  right: 0;
  container-type: size;
  width: 1px;
  visibility: hidden;
  animation: --snap 0.01s both;
}

.scrollStart::before {
  content: "";
  display: block;
}


@container (width: 1px) {
  .scrollStart::before {
    scroll-snap-align: start;
  }
}

@keyframes --snap {
  to {
    width: 0
  }
}

scroll-snap-typescroll-snap-alignの指定と、アニメーションでwidthを変化させている点が肝です。

簡単なのでアニメーションの方から解説します。
scrollStartクラスに初期状態でwidth: 1pxを指定しています。それに対してアニメーションを利用して、scrollStartクラスが0.01秒でwidth:0に変化するようにしています。
そして、@container (width: 1px)を利用してwidth: 1pxの場合にだけ.scrollStart::beforescroll-snap-align: startスタイルが当たるようにしています。
このようにすることで最初の一瞬だけ特定のスタイルを当てるということができるようになります。
他にも色々と応用できそうなワークアラウンドですね。

次にscroll-snap-typescroll-snap-alignの解説に移ります。(MDNの解説)
scroll-snap-type: x mandatoryを指定することで、scroll-snap-alignが指定された要素、つまり、最初の一瞬のみ.scrollStart::beforeに固定することができます。
.scrollStartright:0を指定しているので、コンテナの右端に初期表示でスクロールした状態にすることができます。(不恰好なスクロールのアニメーションが発生しません)

今回のサンプルでは末尾に.scrollStart要素を追加してright:0としたことで右端にスクロールさせましたが、中央やn番目の要素など工夫次第でお好きな場所に初期状態のスクロール位置を指定できると思います。
参考にした記事ではx軸ではなくy軸でのスクロールをやっていました。

ちょっとだけJavaScriptを追加して理解しやすくしたバージョン

上記のワークアラウンドの理解の難しさは

  1. アニメーションによるwidthの変化とコンテナクエリを組み合わせてscroll-snap-alignの着脱を行う
  2. スクロールスナップを利用して初期表示のスクロール位置を指定する、本来の用途から外れた使用方法

の2点にあると思います。JavaScriptを利用することで1番目の複雑さを軽減することができます。

以下のようにuseEffectを利用してscroll-snap-alignの着脱を行えば良いのです。

export default function Page() {
  const [initialized, setInitialized] = useState(false)
  useEffect(() => {
    setInitialized(true)
  }, [setInitialized])
  return (

    <div className={styles.container}>
      <div className={styles.item}>Slide 1</div>
      <div className={styles.item}>Slide 2</div>
      <div className={styles.item}>Slide 3</div>
      <div className={styles.item}>Slide 4</div>
      <div className={styles.item}>Slide 5</div>
      <div className={styles.item}>Slide 6</div>
      <div className={styles.item}>Slide 7</div>
      <div className={styles.item}>Slide 8</div>
      <div className={styles.item}>Slide 9</div>
      <div className={styles.item}>Slide 10</div>
      <div
        className={styles.scrollStart}
        style={{
          ...(!initialized ? { scrollSnapAlign: "start" } : {}),
        }}
      ></div>
    </div>
  )
}

これによってCSSがこんなにも単純化されます。

.container {
  display: flex;
  overflow-x: scroll;
  scroll-snap-type: x mandatory;
  position: relative;
  
}

.item {
  border: 1px solid red;
  text-wrap: nowrap;
}

.scrollStart {
  position: absolute;
  right: 0;
  width: 1px;
  content: "";
  visibility: hidden;
  display: block;
}

しかし、上記の方法では少し問題があります。
おそらく、多くの場合には問題にならないほどの間隔ではあると思いますがuseEffectが実行されるまでスクロールができないのです。
これはスクロールスナップを利用しているので.scrollStartにスナップされてしまうからです。

https://developer.mozilla.org/ja/docs/Web/CSS/CSS_scroll_snap/Basic_concepts

どのような体験になるかはuseEffectをコメントアウトすることで擬似的に体験することができます。(おそらくuseEffectの実行前にスクロールスナップは解除されていると思いますが)

useEffectが実行されるまで

なんやかんや

早くscroll-startが実現されてほしいですね。
こんなややこしいことしたくないです。

Discussion