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ではスクリプトが実行されるまでにいくらか時間がかかるのでこのようになってしまいます。
ワークアラウンド
以下の記事を参考にしています。英語に抵抗がなければそちらを読んでいただいた方が良いと思います。
こちらの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-type
とscroll-snap-align
の指定と、アニメーションでwidth
を変化させている点が肝です。
簡単なのでアニメーションの方から解説します。
scrollStart
クラスに初期状態でwidth: 1px
を指定しています。それに対してアニメーションを利用して、scrollStart
クラスが0.01秒でwidth:0
に変化するようにしています。
そして、@container (width: 1px)
を利用してwidth: 1px
の場合にだけ.scrollStart::before
にscroll-snap-align: start
スタイルが当たるようにしています。
このようにすることで最初の一瞬だけ特定のスタイルを当てるということができるようになります。
他にも色々と応用できそうなワークアラウンドですね。
次にscroll-snap-type
とscroll-snap-align
の解説に移ります。(MDNの解説)
scroll-snap-type: x mandatory
を指定することで、scroll-snap-align
が指定された要素、つまり、最初の一瞬のみ.scrollStart::before
に固定することができます。
.scrollStart
はright:0
を指定しているので、コンテナの右端に初期表示でスクロールした状態にすることができます。(不恰好なスクロールのアニメーションが発生しません)
今回のサンプルでは末尾に.scrollStart
要素を追加してright:0
としたことで右端にスクロールさせましたが、中央やn番目の要素など工夫次第でお好きな場所に初期状態のスクロール位置を指定できると思います。
参考にした記事ではx軸ではなくy軸でのスクロールをやっていました。
ちょっとだけJavaScriptを追加して理解しやすくしたバージョン
上記のワークアラウンドの理解の難しさは
- アニメーションによる
width
の変化とコンテナクエリを組み合わせてscroll-snap-align
の着脱を行う - スクロールスナップを利用して初期表示のスクロール位置を指定する、本来の用途から外れた使用方法
の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
にスナップされてしまうからです。
どのような体験になるかはuseEffect
をコメントアウトすることで擬似的に体験することができます。(おそらくuseEffect
の実行前にスクロールスナップは解除されていると思いますが)
なんやかんや
早くscroll-start
が実現されてほしいですね。
こんなややこしいことしたくないです。
Discussion