CSSのmask-imageで、画像や一覧の端をふわっと消す方法

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

画像や一覧の端をだんだん透明にするには、CSS の mask-image に、黒から透明へ変わるグラデーションを指定します。マスクの不透明な所は表示され、透明な所は消えます。白いグラデーションを重ねる方法と違って後ろの背景が透けるので、背景が模様や写真でも、つなぎ目が目立ちません。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Playwright 1.63.0、Chromium 153 です。白い背景の上に黒い箱を置いてマスクをかけ、画面を撮って、画素の明るさから不透明度を読みました。Firefox と Safari での表示は試していません。各ブラウザーの対応バージョンは、互換性データ(@mdn/browser-compat-data 8.1.8)の値です。

画像の下をふわっと消す

画像の下半分を、下に行くほど透明にする例です。

html
<img class="photo" src="photo.svg" alt="">
css
.photo {
  mask-image: linear-gradient(to bottom, #000 50%, transparent);
}

グラデーションの #000 50% は「上から 50% までは黒(表示する)」、最後の transparent は「下の端で透明(消す)」という意味です。検証環境で、高さ 200px の黒い箱にこのマスクをかけて不透明度を測ると、上から 99px までは 1 のままで、125px で 0.75、150px で 0.49、175px で 0.24、いちばん下で 0 になりました。

マスクの色は関係ありません。効くのは、色の不透明度だけです。linear-gradient(#fff, #fff) や linear-gradient(red, red) のような不透明な色のマスクでは、箱はそのまま表示されました。不透明度が 0.5 の色なら、青でも白でも、箱の不透明度は 0.5 になりました。MDN の mask-image のページにあるとおり、mask-mode の初期値は match-source で、グラデーションのような画像では、不透明度(アルファ)でマスクされます。この記事では、表示する所を #000、消す所を transparent と書いています。

白いグラデーションを重ねる方法との違い

端を消す方法には、画像の上に、透明から白へ変わるグラデーションを重ねるやり方もあります。この方法は、白いグラデーションで画像を隠しているだけです。後ろの背景が白でないと、白い帯が見えます。

水玉模様の背景の上に、同じ風景の画像を2つ縦に並べた画面。上の「白いグラデーションを重ねた」では、画像の下の端が白く塗られ、背景の水玉が見えない。下の「mask-image で消した」では、画像の下の端から背景の水玉が透けて見える
上は白いグラデーションを重ねたもの、下は mask-image で消したもの

mask-image は画像そのものを透明にするので、上の画像の下側の例のように、背景の水玉がそのまま透けて見えます。

一覧の左右の端を消す

横にスクロールする一覧の端を消すときは、スクロールする枠に mask-image を付けます。消す幅は、% ではなく px で書くと、枠の幅が変わっても同じ幅になります。

css
.list {
  overflow-x: auto;
  mask-image: linear-gradient(
    to right,
    transparent,
    #000 60px,
    #000 calc(100% - 60px),
    transparent
  );
}

検証環境で、消す幅を 40px にして、幅 300px と 600px の箱で比べました。#000 40px と書いた箱は、どちらの幅でも、左端から 39px の位置で不透明度が 0.99 以上になりました。#000 15% と書いた箱では、その位置が 44px と 89px になり、幅に合わせて消える幅も変わりました。

マスクは枠に付いたまま動きません。幅 400px の枠で scrollLeft を 0、200、600 と変えても、不透明度は、左端から 30px の位置で 0.51、60px の位置で 1、右端から 30px の位置で 0.49 と、どのスクロール位置でも同じでした。中身がスクロールしても、消えるのは常に枠の両端です。

4辺を消すときは mask-composite: intersect

画像の上下左右の4辺を消すには、横向きと縦向きのグラデーションを、カンマで区切って2つ書きます。このとき、mask-composite: intersect も書きます。

css
.photo {
  mask-image:
    linear-gradient(to right, transparent, #000 20%, #000 80%, transparent),
    linear-gradient(to bottom, transparent, #000 20%, #000 80%, transparent);
  mask-composite: intersect;
}
同じ風景の画像に4辺を消すマスクをかけ、縦に2つ並べた画面。上の「mask-composite: add(初期値)」では、四隅だけが少し透けて、辺の中ほどははっきり見えている。下の「mask-composite: intersect」では、4辺すべてがふわっと透けて、背景の水玉に溶け込んでいる
マスクの重ね方が add(上)と intersect(下)のときの見え方

MDN の mask-composite のページにあるとおり、初期値の add は、マスクを上に重ねていきます。横のマスクで消した左の辺も、縦のマスクでは表示する所なので、重ねると表示されてしまいます。検証環境で、200px の箱に、端から 25% までを消すマスクを2つかけて(コードの例とは違い 25% にしました)、左の辺の中ほど(端から 2px)の不透明度を測りました。add では 1 のままで、intersect では 0.05 になりました。intersect は、両方のマスクが重なる所だけを残すので、どちらかのマスクで消した所は消えます。

影やフォーカスの枠が切れる

mask-image を付けた要素では、枠線(border-box)の外側に描かれる影や outline も消えます。検証環境で、box-shadow: 0 0 0 12px #000 の影を付けた箱に、全体を表示するマスク(linear-gradient(#000, #000))をかけると、箱の外の影は表示されませんでした。:focus で付けた outline も消えました。

MDN の mask-clip のページにあるとおり、マスクがかかる範囲は mask-clip で決まり、その外に描いたものは切り取られます。初期値は border-box で、要素の枠線の外側は範囲に入りません。

カードのように影を付けたい要素なら、マスクはカードではなく、中の画像に付けます。

css
.card {
  box-shadow: 0 0 0 12px #000;
}
.card img {
  mask-image: linear-gradient(to bottom, #000 50%, transparent);
}

検証環境で、カードの中の img にマスクを付けると、カードの影は消えず、中の画像だけが下に向かって消えました。

mask-clip: no-clip を書くと、影が表示されました。ただし、互換性データでは Safari は no-clip に対応していません。

注意

マスクで透明にした所も、クリックは要素に届きます。検証環境で、下半分を完全に透明にした箱の、下半分の位置をクリックすると、箱の click イベントが起きました。透明にした部分の下にボタンを重ねて置いた場合も、その位置をクリックすると、click が起きたのは箱のほうで、ボタンには届きませんでした。

続きがある側だけを消す

一覧の両端をいつも消していると、いちばん左までスクロールしているときにも、左の端が消えています。スクロール位置に合わせて、続きがある側だけを消すには、スクロール駆動アニメーションで、消す幅を動かします。

横に並んだ「りんご」「みかん」などの四角い項目を、マウスのホイールで右へスクロールする。最初は右の端だけが透けている。ホイールを回すたびに、左上の「ホイール 1回目/6回」と右上の「位置」がピンクの地になり、位置の数字が増えるにつれて、左の端も少しずつ透けて見えるようになる。6回目で 654px の右端まで来ると、右の端は透けなくなる
ホイールで右へスクロールした様子。続きがある側の端だけが透ける
css
@property --fade-left {
  syntax: "<length>";
  inherits: false;
  initial-value: 0px;
}
@property --fade-right {
  syntax: "<length>";
  inherits: false;
  initial-value: 0px;
}

.list {
  overflow-x: auto;
  --fade-left: 0px;
  --fade-right: 0px;
  mask-image: linear-gradient(
    to right,
    transparent,
    #000 var(--fade-left),
    #000 calc(100% - var(--fade-right)),
    transparent
  );
}

@supports (animation-timeline: scroll()) {
  .list {
    animation: fade-edges linear both;
    animation-timeline: scroll(self inline);
  }
}

@keyframes fade-edges {
  0% { --fade-left: 0px; --fade-right: 60px; }
  10% { --fade-left: 60px; }
  90% { --fade-right: 60px; }
  100% { --fade-left: 60px; --fade-right: 0px; }
}

--fade-left と --fade-right は、左右それぞれの消す幅です。@property で <length> の型を付けておくと、アニメーションの途中で値が少しずつ変わります。MDN の scroll() のページにあるとおり、scroll(self inline) は、要素自身の横方向のスクロール位置をアニメーションの進み具合にします。いちばん左が 0%、いちばん右が 100% です。

検証環境で、幅 400px、スクロールできる量が 600px の枠で値を読むと、次のようになりました。

scrollLeft –fade-left –fade-right
0 0px 60px
30 30px 60px
300 60px 60px
570 60px 30px
600 60px 0px

左の端は、スクロールできる量の最初の 10%(この枠では 60px)を進むあいだに、だんだん消えるようになります。右の端は、最後の 10% で、だんだん消えなくなります。

@supports で囲んでいるのは、animation-timeline が効かないときに、スクロール位置が 0 でも左の端が消え、右の端が消えない状態にならないようにするためです。検証環境で、animation-timeline を書かずに同じ animation だけを残すと、アニメーションの長さが 0s になり、スクロール位置が 0 でも、いちばん右まで進んだときの値(左が 60px、右が 0px)のままになりました。MDN の @supports のページにあるとおり、@supports の中は、その機能に対応したブラウザーでだけ使われます。検証環境で @supports の中を消した状態にすると、--fade-left と --fade-right は 0px のままで、左右どちらの端も消えませんでした。

対応しているブラウザー

互換性データでは、接頭辞なしの mask-image に対応しているのは、Chrome 120、Edge 120、Firefox 53、Safari 15.4(iOS 版も 15.4)からです。それより前の Chrome と Safari は、-webkit-mask-image だけに対応しています。検証環境の Chromium 153 では、-webkit-mask-image だけを書いても、同じように下が消えました。古いブラウザーも対象にするなら、両方を書いておきます。

css
.photo {
  -webkit-mask-image: linear-gradient(to bottom, #000 50%, transparent);
  mask-image: linear-gradient(to bottom, #000 50%, transparent);
}

mask-composite は Chrome 120、Edge 120、Firefox 53、Safari 15.4 から、animation-timeline の scroll() は Chrome 115、Edge 115、Firefox 159、Safari 26 から対応しています。

​Posted by 86