🤿

「このデザイン、CSSで再現できないかも😫」を解決してくれるかもしれないプロパティ、mask

に公開
2

CSSにおけるmaskというプロパティによってデザインを実装可能にしてくれたケースが3つありましたので、私見をしたためていきます。

既にCSSのmaskプロパティについてご存知の方はmaskが可能にしてくれるデザインパターン
マスクという行為およびCSSのmaskについてこれから知りたい方は前提知識

前提知識

そもそも「マスク」とは

左の画像を、星の形にくり抜きたい。そのくり抜く行為を、この記事内では「マスク」と呼びます。
※くり抜く行為はクリッピングであり、それを用いてマスクを行うのであればクリッピングマスクなのではないかというもっともな主張があると思いますが、CSSのプロパティ名と統一して呼称するために、この記事内だけではそう呼ぶことにしました。

画像同士を重ねて、「この形にくり抜きたい」という形を「この形に繰り抜かれたい」という画像に対してマスクすると

その形にくり抜かれます。

「この形にくり抜きたい」という画像の不透明度を下げると、くり抜かれた形の画像部分だけが、その不透明度に応じて表示されます。

不透明度に関しては「画像の不透明度を調整しなくてもCSS側でopacityを調整すればいいのでは」と思われてしまいそうですが、くり抜きたい画像の不透明度に応じてくり抜かれたい画像の表示範囲が変わるという振る舞いはopacityでは代替できないものだったりします。例えば、


上記例ですと、中心から外側に向かって段々透明になっていくようにマスクを実現することができます。
マスクについてピンと来なかった方向けに、photoshopにおけるマスクの解説を置いておきます。
レイヤーのマスク

CSSにおけるmaskとは

上述の行為をCSSでも可能にしてくれるのがmaskプロパティです。mask関連のプロパティの一例には以下のようなものがあります。以下のうちいくつかはbackground関連のプロパティとして親しみがあるのではないでしょうか。

mask-image: /* マスクとして使用する画像またはlinear-gradientを指定 */
mask-mode: /* 輝度とアルファマスクのどちらで扱うか */
mask-repeat: /* マスク画像の繰り返し方法を指定 */
mask-position: /* マスク画像の配置位置を指定 */
mask-clip: /* マスクを適用する領域を指定 */
mask-origin: /* マスクの基準位置を指定 */
mask-size: /* マスク画像のサイズを指定 */
mask-composite: /* 複数のマスクレイヤーの合成方法を指定 */

これらのプロパティを使用して、上述のデザインツール上のマスクと同じことをやると以下のようになります。

mask指定前 mask指定後

maskが可能にしてくれるデザインパターン

逆に、「この形にくりぬきたい」の画像部分だけ隠したいという場合

こうじゃなくて

こうしたいとき

上記のような場合、mask-imageプロパティに1枚の画像を指定しても、これを実現する方法はありません。しかし、前提知識で記述したように、CSSのmaskプロパティはデザインツールで実施されるマスクと似たような事ができます。だとするならば、デザインツール上で「この形にくりぬきたい」の画像部分だけ隠したいという行いを実現するときと同じアプローチを取れることが予想できます。

  • マスクされる画像全体を覆う矩形
  • 「この形にマスクしたい」の画像

上記2つを用意します。mask-imageには複数の画像を指定できますので、「この二つが重なり合わない部分」だけを表示するようにできれば実現できそうですね。mask-compositeというプロパティがそれにふさわしそうです。
https://developer.mozilla.org/ja/docs/Web/CSS/Reference/Properties/mask-composite

exclude
関連付けられたマスク画像と、その下のマスクレイヤーの重複しない領域が、対応する合成操作が適用されて結合されます。

上記の値が期待通りの動作をしてくれそうですね。ではまず「マスクされる画像全体を覆う矩形」が必要なので、サクッとSVGを手書きしてみます。

<svg  xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1">
    <rect width="1" height="1"/>
</svg>

mask-sizeで大きさを指定すればよいため、widthもheightも1でいいだろうと見込みます。

「この形にくりぬきたい」の画像部分だけ隠したい を実現できました!
いいえ、厳密にはできていません。img要素を注視してみます。

左右が隠れ、正方形にクリップされたような見え方になっていますね。しかし、mask-sizeの値には矩形の部分に100% 100%と間違いなく記述しています。なぜこうなってしまうのか。
これは、svg要素が初期値として持っているpreserveAspectRatioという属性が関係しています。
https://developer.mozilla.org/ja/docs/Web/SVG/Reference/Attribute/preserveAspectRatio
正確に近い説明は上記リンクを拝見いただくとして、感覚的に一言で説明してしまうと、SVGのpreserveAspectRatioはCSSで言う所のobject-fitのような属性で、デフォルトの値がcontainなのです。先ほど手書きでSVGを用意しましたが、widthもheightも1と記述しており、そのアスペクト比は1/1です。これがcontainな振る舞いをしたとき、mask-sizeに100% 100%と書いていようが正方形になってしまいます。それでこのような見え方になっていました。
そこまで判明したのなら、containではなくcoverな振る舞いに変えてあげればよいと考えられます。preserveAspectRatioをnoneと明記してみます。

<svg preserveAspectRatio="none"  xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1">
    <rect width="1" height="1"/>
</svg>


期待通りの見え方を実装することができました。これで、マスクの対象要素のアスペクト比が何であれ、完全に覆い尽くすことにできる矩形となりました。
SVGにおけるアスペクト比の問題、mask以外でも直面したことがある方もいらっしゃるのではないでしょうか。例えば、SVGのアイコンにwidthとheightをCSSで指定してもその通りにならないなどです。preserveAspectRatioが起因していたのですね。

マスクしたいシルエットを逆転させた画像を最初から用意できるのであればこのような工程は不要ですが、外部デザイナーとの連携がとりづらかったり、先方からボールが返ってきづらかったりで、もう実装側でなんとかしてしまいたいときに役立つかもしれません。

セクション境目のデザインが複雑な形をしている場合

ただセクションの境目に図形が配置してあったり、セクションの境目が複雑な図形であるがそのセクションの背景色がベタ塗りであるなどの場合は特に困難は発生しないでしょう。しかし以下のように、セクションの境界とセクションの中身に渡って連続的に背景またはグラデーションが表示される場合、どうしようかなとなるのではないでしょうか。

上記のようなケースでも、mask-imageに画像を複数枚指定することでデザインの実装が可能になります。
デザインツール上で視覚的に考えてみます。以下のようなmask-imageの配置になるように複数枚指定することで、セクションの境界の図形を含めて統一したグラデーションの描画ができそうです。

波のような形は明示的に高さの指定を行い、セクションを覆う矩形の部分は前述のケースと同様に100% 100%にするとよさそうです。

この実装アプローチがあれば、セクションの境界がどのようなデザインでも実装できてしまいそうに思えてきますね。

場面切り替えのトランジションを実装する

そもそもトランジションとはなんだ

動画をデザインするという世界において、画面全体を覆いながら場面の切り替えを行うようなアプローチをトランジションと呼称することがあります。例えば以下のような演出です。

背景色から画像 画像から画像

スライダーやページ遷移の演出などでリクエストされることも多いのではないでしょうか。この演出はmask-imageによって可能になります。
永遠にループして動き続けているデザイン仕様であればgifやアニメーションwebpをmask-imageに指定してしまえば完了です。しかし、上述の例のように、

  • hoverしたら
  • スライドを進めたら
  • ページが切り替わる時に

何かをトリガーとして1度だけ実行したいときにはgifやアニメーションwebpを用いたアプローチは仕様を満たすに及びません。

じゃあどうしようかな〜となるワケですが、CSSでどうするかという前に大前提としてそもそもアニメーションとはどういう仕組みで連続的に見えているのかを考えてみます。パラパラ漫画的な感じですよね。CSSにおけるmaskの特徴を振り返って、実現方法を考えてみます。

まず、maskは輝度や不透明度に応じて見える部分見えない部分が決まるのでした。
だとするなら、開始時点では全面が完全に透明のレイヤーが、有機的なアニメーションを経由して、最終的に全面不透明になる動画を作り、その動画を例えば20コマくらいに画像として分割保存ができれば、mask-imageの参照するurlを20回切り替えるkeyframesを作って実現できるのではないか?

いい感じの仮説ですね。やってみます。
まずは動画の準備をしましょう。これはAfter Effectsなどを触ることのできる方は自作しても構いませんし、adobe stockなどのストックサイトから購入しても構いませんし、最近だとAI生成なども選択肢に入ってきそうですね。今回は手っ取り早くadobe stockから購入したものを使用していきます。上記の例でも使用したトランジション動画です。

これをパラパラ漫画にすべく画像として1コマ1コマ保存したいワケです。使用するツールによって様々な方法がありそうですが、After Effectsを使用した場合の例を示してみます。バージョン差によってGUIの見え方が異なっている場合はすみません。やることは同じなので該当のUIを探していただけますと幸いです。

  1. 新規コンポジションを作成

  2. タイムラインに動画をドラッグ&ドロップしてレンダーキューに追加

  3. 出力モジュールの右側の青い文字をクリックして形式をpngシーケンスにする

  4. 出力先を希望のディレクトリに設定

  5. レンダリングを押すとコマ割りに保存される

所望していた画像群が手に入りました。

ではこれをmask-imageに指定し、keyframesで切り替えればいいワケですね。

@keyframes maskTransition {
  0% {
    mask-image: url("/assets/images/mask-sample1.png");
  }
  12.5% {
    mask-image: url("/assets/images/mask-sample2.png");
  }
  25% {
    mask-image: url("/assets/images/mask-sample3.png");
  }
  37.5% {
    mask-image: url("/assets/images/mask-sample4.png");
  }
  50% {
    mask-image: url("/assets/images/mask-sample5.png");
  }
  62.5% {
    mask-image: url("/assets/images/mask-sample6.png");
  }
  75% {
    mask-image: url("/assets/images/mask-sample7.png");
  }
  87.5% {
    mask-image: url("/assets/images/mask-sample8.png");
  }
  100% {
    mask-image: url("/assets/images/mask-sample9.png");
  }
}


アニメーションできてはいますが、チカチカしておりますし、スムーズではありません。
mask-imageに指定する画像を動的に変更してしまうとチカチカしてしまうのはあるあるですね。つまり、

開始時点では全面が完全に透明のレイヤーが、有機的なアニメーションを経由して、最終的に全面不透明になる動画を作り、その動画を例えば20コマくらいに画像として分割保存ができれば、mask-imageの参照するurlを20回切り替えるkeyframesを作って実現できるのではないか?

上記アプローチがお釈迦になりました。チカチカしてしまってはアニメーション要件を満たしているとは言えません。しかし、アニメーションの原理をそのまま再現しようとする筋は悪くないはず。別の視点を探りましょう。映画のフィルムのことを考えてみます。フィルムは上記CSSのように絵を差し替えているのではなく、1枚の長いフィルムをスライドさせて動きを表現しているような気がしてきました。だとするなら、さきほどの分割した画像を横長に1連結させて、mask-positionをアニメーションさせれば全く同じ原理を再現できるのではないか?これでいってみます。

横長に1連結するのであればFigmaのオートレイアウトで横に並べてしまうのが手っ取り早そうです。

この超横に長い画像をmask-sizeプロパティに超長いまま指定し、そのmask-positionを横にズラしていきます。

mask-positionを動かしているのだから、mask画像が滑らかに横に移動しています。つまり後満たせていないのはパラパラ漫画みたいに決まったコマ間隔ずつ一瞬で切り替わるという点のみとなりました。着実に前進しています。そもそも映画のフィルムも単純移動させているだけではなく、各コマずつ停止して投影しているから連続的なアニメーションを実現できているようです。

そしてCSSには、この決まった間隔ずつ一瞬で切り替えることのできるsteps()という関数が存在しています。
https://developer.mozilla.org/ja/docs/Web/CSS/Reference/Values/easing-function/steps

例えば、

animation: maskTransition 0.6s steps(8) forwards;

などにしてしまえば、滑らかな横移動ではなく、アニメーション開始から完了までの変化を8分割して、非滑らかに見せてくれるというワケです。今回は画像を9分割しましたが、最初の1枚目は最初から見えているので、stepに必要な数は最初の1枚目を引いた8ということになります。
最終的にこんな感じにしてみましょう。

<div class="container">
    <div class="item">
        <img src="画像パス" alt="" class="image" />
    </div>
</div>
.container {
  --size: 540px; /* すきなサイズ */
  width: var(--size);
  margin-inline: auto;
  margin-top: 120px;
  & .item {
    width: 100%;
    aspect-ratio: 1/1;
    --step: 8; /* 分割した画像の数 -1 */
    background-color: orange;
    & .image {
      width: 100%;
      height: 100%;
      object-fit: cover;
      mask-repeat: no-repeat;
      mask-position: 0 0;
      mask-image: url("画像パス");
      mask-size: calc(var(--size) * var(--step) + var(--size)) 100%;
      &:hover {
        animation: maskTransition 0.6s steps(var(--step)) forwards;
      }
    }
  }
}
@keyframes maskTransition {
  0% {
    mask-position: 0 0;
  }
  100% {
    mask-position: calc(var(--size) * var(--step) * -1) 0;
  }
}

ではhoverしてみます。

無事、成し遂げることができました。
ありがとう、mask-image。

chot Inc. tech blog

Discussion

junerjuner

calc(calc(var(--size) * var(--step)) * -1) 0;

calc((var(--size) * var(--step)) * -1) 0;

でいいのではないでしょうか?

chot_tkngchot_tkng

コメントいただきありがとうございます!
本当ですね🥶冗長なcalcのネストを取り払うよう修正いたしました🙇