CSSで、横スクロールの端に影を出す方法
横にスクロールする枠の端に影を出すと、その先に続きがあることが見て分かります。枠の background に、端に止まったまま動かない影と、中身といっしょに動く白いふたを重ねると、続きがある側にだけ影が出ます。いちばん左にいるときは左の影がふたで隠れ、いちばん右まで来ると右の影が隠れます。JavaScript は使いません。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Playwright 1.63.0、Chromium 153 です。HTML と CSS のページを Playwright で開き、scrollLeft を変えながら、画面の色と getComputedStyle() の値を読みました。Firefox と Safari、スマートフォンの実機でのタッチ操作、縦スクロールの枠は試していません。

コード全体
HTML は、横に長い中身を、スクロールする枠で包むだけです。
<div class="scroll-shadow">
<ul class="items">
<li>りんご</li><li>みかん</li><li>ぶどう</li><li>もも</li><li>なし</li>
<li>かき</li><li>いちご</li><li>メロン</li><li>すいか</li><li>バナナ</li>
</ul>
</div>
CSS は次のとおりです。.items は中身を横に並べるためのもので(display: flex と width: max-content)、影の仕組みには関係ありません。
.scroll-shadow {
overflow-x: auto;
background:
/* 影を隠すふた。中身といっしょに動く */
linear-gradient(to right, #fff 30%, rgb(255 255 255 / 0)) left center / 40px 100% no-repeat local,
linear-gradient(to left, #fff 30%, rgb(255 255 255 / 0)) right center / 40px 100% no-repeat local,
/* 影。枠の端に止まったまま動かない */
radial-gradient(farthest-side at 0 50%, rgb(0 0 0 / 0.3), rgb(0 0 0 / 0)) left center / 16px 100% no-repeat scroll,
radial-gradient(farthest-side at 100% 50%, rgb(0 0 0 / 0.3), rgb(0 0 0 / 0)) right center / 16px 100% no-repeat scroll;
background-color: #fff;
}
background には、カンマで区切って4枚の背景を重ねています。複数の背景の重なり方の説明にあるとおり、先に書いたものほど手前に描かれます。手前の2枚が白いふた、奥の2枚が影です。
影は枠に止め、ふたは中身といっしょに動かす
ふたと影の違いは、各行の最後に書いた local と scroll です。これは background-attachment の値で、MDN の説明では、scroll の背景は要素そのものに対して固定され、中身といっしょにスクロールしません。local の背景は要素の中身に対して固定され、中身といっしょにスクロールします。位置の基準も、スクロールできる範囲全体になります。
影の2枚は scroll なので、いつも枠の左端と右端に、幅16pxで止まっています。影は radial-gradient で、端のまんなかが濃く、内側へ行くほど透明になります。
ふたの2枚は local なので、中身の左端と右端にくっついて動きます。ふたは幅40pxで、外側の30%(12px)は白、そこから内側へ透明になっていきます。いちばん左にいるときは、左のふたが左の影の上に重なり、影を白で隠します。右へスクロールすると、ふたは中身といっしょに左へ出ていき、隠れていた影が見えてきます。右のふたは中身の右端にあるので、いちばん右までスクロールしたときに、右の影の上に来ます。
ふたは、枠の背景色(background-color: #fff)と同じ白で塗っています。背景色を変えるときは、ふたの #fff と rgb(255 255 255 / 0) も同じ色にそろえます。
スクロール位置と影の濃さ
幅398pxの枠に、幅824pxの中身を入れました。スクロールできるのは0pxから426pxまでです。枠の左端から3px、右端から4px(どちらも1pxの枠線を含めて数えた位置)の点の色を、スクロール位置を変えながら読みました。影は灰色なので、赤の値(R)だけを書いています。白は255で、影が濃いほど小さくなります。
| スクロール位置 | 左端の色(R) | 右端の色(R) |
|---|---|---|
| 0px(いちばん左) | 255 | 243 |
| 10px | 255 | 243 |
| 20px | 251 | 243 |
| 30px | 247 | 243 |
| 40px | 244 | 243 |
| 100px | 244 | 243 |
| 386px | 244 | 243 |
| 406px | 244 | 250 |
| 426px(いちばん右) | 244 | 255 |
いちばん左では左端が白のままで、右端にだけ影が出ています。スクロールすると、左端は10pxではまだ白で、20pxで251、40pxで244になり、そこからは変わりませんでした。ふたの白い部分が外へ出ていき、透明になっていく部分が影の上を通るためです。右端は反対に、いちばん右の手前20px(406px)で250まで薄くなり、426pxで白に戻りました。影がいきなり出たり消えたりせず、スクロールに合わせてなめらかに変わります。
中身が枠に収まって、スクロールしないときも試しました。幅398pxの枠に2つだけ項目を入れると、左端も右端も255で、影は出ませんでした。中身が枠より短いと、ふたが両方とも枠の端に来て、影を両方とも隠すためです。
中身に背景色があると、影が隠れる
この書き方には弱点があります。background の説明にあるとおり、背景は要素の中身の下に描かれます。影は枠の背景なので、中身の要素に背景色があると、その下に隠れます。
項目を、薄い水色(#eefafd)の地のカードにして試しました。影を出したときの画面と、影を消したときの画面とで、端の縦の1列の色を比べました。スクロール位置100pxでは、左端の列でカードの地にあたる38行のうち、影で暗くなった行は0でした。右端の列も、30行のうち0行でした。カードがない余白の部分では、影は出ていました。

中身に背景色があるときは、疑似要素に影を描く
中身の上に影を重ねたいときは、枠の外側にもう1つ要素を置き、その ::before と ::after に影を描きます。どちらの影を見せるかは、スクロール位置で進むアニメーションで決めます。
<div class="shadow-wrap">
<div class="row">
<ul class="items">
<li>りんご</li><li>みかん</li><li>ぶどう</li><li>もも</li><li>なし</li>
<li>かき</li><li>いちご</li><li>メロン</li><li>すいか</li><li>バナナ</li>
</ul>
</div>
</div>
.shadow-wrap {
position: relative;
timeline-scope: --row;
}
.shadow-wrap > .row {
overflow-x: auto;
scroll-timeline: --row inline;
}
.shadow-wrap::before,
.shadow-wrap::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
width: 16px;
pointer-events: none;
opacity: 0;
}
.shadow-wrap::before {
left: 0;
background: radial-gradient(farthest-side at 0 50%, rgb(0 0 0 / 0.3), rgb(0 0 0 / 0));
}
.shadow-wrap::after {
right: 0;
background: radial-gradient(farthest-side at 100% 50%, rgb(0 0 0 / 0.3), rgb(0 0 0 / 0));
}
/* スクロール位置で動くアニメーションが使えるブラウザだけ */
@supports (animation-timeline: scroll()) and (timeline-scope: --row) {
.shadow-wrap::before,
.shadow-wrap::after {
animation-duration: 1ms; /* Firefox では 0 以外が必要 */
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: --row;
}
.shadow-wrap::before {
animation-name: shadow-in;
animation-range: 0 40px;
}
.shadow-wrap::after {
animation-name: shadow-out;
animation-range: calc(100% - 40px) 100%;
}
}
@keyframes shadow-in {
to { opacity: 1; }
}
@keyframes shadow-out {
from { opacity: 1; }
to { opacity: 0; }
}
スクロールする .row に scroll-timeline で --row という名前を付け、inline(横書きでは横)方向のスクロールを進み具合として使います。::before と ::after は animation-timeline: --row で、時間ではなく、このスクロール位置に合わせてアニメーションを進めます。
animation-range は、スクロールのどこからどこまででアニメーションを進めるかの指定です。左の影は、スクロールの最初の40pxで opacity を0から1にします。右の影は、最後の40pxで1から0にします。animation-fill-mode: both なので、その範囲の外では、はじめか終わりの値のままです。
animation-duration: 1ms を書いているのは、スクロール駆動アニメーションのタイムラインの説明に、Firefox ではアニメーションを当てるのに0以外の animation-duration が必要で、1ms を書くのが一般的だとあるためです。
左右の影は pointer-events: none にしてあります。いちばん右までスクロールし、左の影がかかっている点(枠線の内側から4px)にある要素を document.elementFromPoint() で調べると、影ではなく、下にあるカード(「かき」の li)でした。同じ点を Playwright の page.mouse.click() でクリックすると、その li に click イベントが届きました。
測った結果
前の節と同じカードで、::before と ::after の opacity を読みました。スクロールできる範囲は、こちらも0pxから426pxまでです。
| スクロール位置 | 左の影(::before) |
右の影(::after) |
|---|---|---|
| 0px(いちばん左) | 0 | 1 |
| 10px | 0.25 | 1 |
| 20px | 0.5 | 1 |
| 40px | 1 | 1 |
| 100px | 1 | 1 |
| 386px | 1 | 1 |
| 406px | 1 | 0.5 |
| 426px(いちばん右) | 1 | 0 |
画面の色も比べました。スクロール位置100pxで、左端の列のカードの地にあたる38行すべてと、右端の列の30行すべてが、影で暗くなっていました。前に載せた画像の下の枠が、この書き方です。
中身が枠に収まってスクロールしないときは、左右とも opacity は0で、影は出ませんでした。
timeline-scope を忘れると、左の影が出たままになる
scroll-timeline で付けた名前は、そのままでは、名前を付けた要素とその子孫からしか使えません。影を描いている .shadow-wrap は .row の親なので、.shadow-wrap に timeline-scope: --row を書いて、親からも --row を使えるようにしています。
timeline-scope を消して試すと、スクロール位置が0px、100px、426pxのどれでも、左の影は opacity が1、右の影は0のままで、スクロールしても変わりませんでした。いちばん左にいるのに左に影があり、続きがある右には影がない、逆さまの表示になります。
使えないブラウザでは、影を出さない
animation-name などを @supports (animation-timeline: scroll()) and (timeline-scope: --row) の中に入れているのは、使えないブラウザで、逆さまの表示にしないためです。timeline-scope も条件に入れているのは、下の表のとおり、Chrome では timeline-scope が animation-timeline より1つ後の版で使えるようになったためです。animation-timeline だけを条件にすると、Chrome 115 では条件が成り立ちますが、timeline-scope は使えません。前の節で timeline-scope を消して試したのと同じ状態です。
animation-timeline が使えないブラウザと同じ状態を作るため、@supports で包まずに、animation-timeline を初期値の auto(ふつうの時間で進むアニメーション)にして試しました。timeline-scope を消したときと同じく、左の影が1、右の影が0のままになりました。animation-duration が1msなので、時間で進むとアニメーションはすぐに終わり、animation-fill-mode: both によって終わりの値が残ります。
@supports の条件を、存在しないプロパティに書きかえて、条件が成り立たないときも試しました。条件全体を書きかえたときも、timeline-scope の条件だけを書きかえたときも、animation-name は none になり、左右とも opacity は0で、影はまったく出ませんでした。使えないブラウザでは影がないだけで、中身は読めてスクロールもできます。
どちらを使うか
中身が文字だけで、背景色のある要素を並べないなら、背景だけの書き方で足ります。HTML を増やさず、.scroll-shadow のクラスを付けるだけで使えます。中身にカードや画像のような地のある要素を並べるときは、疑似要素の書き方にします。
MDN の互換性データ(@mdn/browser-compat-data 8.1.8)で、使えるようになった版を調べました。
| 機能 | Chrome | Firefox | Safari |
|---|---|---|---|
background-attachment: local |
1 | 25 | 15.4 |
animation-timeline、scroll-timeline、animation-range |
115 | 159 | 26 |
timeline-scope |
116 | 159 | 26 |
疑似要素の書き方は、この表より前の版のブラウザでは、@supports によって影が出ない表示になります。





