CSSで、横スクロールの端に影を出す方法

​​CSS​HTML,Playwright,スクロール駆動アニメーション

横にスクロールする枠の端に影を出すと、その先に続きがあることが見て分かります。枠の background に、端に止まったまま動かない影と、中身といっしょに動く白いふたを重ねると、続きがある側にだけ影が出ます。いちばん左にいるときは左の影がふたで隠れ、いちばん右まで来ると右の影が隠れます。JavaScript は使いません。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Playwright 1.63.0、Chromium 153 です。HTML と CSS のページを Playwright で開き、scrollLeft を変えながら、画面の色と getComputedStyle() の値を読みました。Firefox と Safari、スマートフォンの実機でのタッチ操作、縦スクロールの枠は試していません。

横長の枠に「りんご」「みかん」などの枠付きの項目が並ぶ。マウスポインターを枠の上に置き、ホイールで右へスクロールする。ホイールを回した瞬間に、左上の「ホイール 1回目/6回」がピンクの地になり、右上の「スクロール位置 60px/622px」もピンクになる。最初は右の端にだけ灰色の影があり、スクロールすると左の端にも影が出て、いちばん右の622pxまで来ると右の影が消える
ホイールで右へスクロールした様子。左の影は動き始めると現れ、右の影は端まで来ると消える

コード全体

HTML は、横に長い中身を、スクロールする枠で包むだけです。

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)、影の仕組みには関係ありません。

css
.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行でした。カードがない余白の部分では、影は出ていました。

上に「背景だけの書き方」、下に「疑似要素の書き方」の枠を縦に並べた画面。どちらも薄い水色のカードが横に並び、少し右へスクロールしてある。上の枠では、左右の端の影がカードのすき間と上下の余白にしか見えず、カードの部分は影が途切れている。下の枠では、カードの上にも左右の端の影がかかっている
色付きのカードを並べ、どちらも100pxスクロールした状態。上の書き方ではカードの部分で影が途切れる

中身に背景色があるときは、疑似要素に影を描く

中身の上に影を重ねたいときは、枠の外側にもう1つ要素を置き、その ::before と ::after に影を描きます。どちらの影を見せるかは、スクロール位置で進むアニメーションで決めます。

html
<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>
css
.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 によって影が出ない表示になります。

​Posted by 86