🥽

こういうデザイン (Augmented UI? 拡張 UI?) を CSS で実装する方法

2024/09/17に公開

はじめに

言葉では説明しにくいので、以下の画像を見ていただいたほうが早いです。

斜めのストライプの背景の上に、抽象的な曲線の形状を持つグラデーションのカラフルな角丸画像が中央に配置されており、画像の左下は角丸を保持した状態で切り抜かれており、切り抜かれた部分にはテキストが配置されています。

このように、画像の一部が切り抜かれたようなデザイン。

このようなデザインを指す特定な名称は見つかりませんでしたが、個人的には Augmented UI (拡張 UI) がもっとも近いような気がします。

Augmented UI

Augmented UI とは、サイバーパンクなどでよく見る以下のような形を CSS で再現するライブラリの名称です。

黒い背景にサイバーパンクスタイルの黄色い角張ったフレーム

augmented-ui - Integrate your apps with technology
https://augmented-ui.com/

このライブラリ以外でこの名称を使用している例はまったく見当たりませんでしたが、共通認識を持つためにも、この名称がもっと広まることを願います。

なんでこのような形が Augmented UI なのかについて個人的に考えてみました。Augmented とは AR (Augmented Reality = 拡張現実) の「A」の部分です。

ゴーグル型 AR デバイスのインターフェース。視界がサイバーパンクスタイルのフレームのように一部が切り取られて、文字などの情報が表示される

この画像を見ると確かに AR デバイスの視界って augmented-ui の形になってますね!ここから発展したんじゃないかなと。

しかし残念ながら、augmented-ui では最初のデザインを再現することはできません。

実装方法

前置きが長くなりましたが、実装方法について解説していきます。

よくあるやり方

全体の背景色と同じ色をテキスト部分の背景に指定し、同じ色の赤い部分をくっつければ完成です。しかし見てのとおり、この方法は単色の背景にしか使えません。

まずは切り抜き部分を固定サイズで

html
<div>
  <img src="..." alt="" />
  <p>...</p>
</div>

テキスト部分が固定サイズであれば、上記の HTML に対して mask だけで実装できます。最終的な実装でも使う手法なので、ここでは詳細について割愛します。

切り抜き部分をテキストに応じる

(C) 右上の角丸をどうするかは一旦置いておいて、このようなグリッドを作れば、(C) のテキストに応じて (A) (B) (D) を可変にはできます。

(A) (B) (D) に画像を表示させる

background を使った手法

2 人の方がこの手法で実装していただきました。下記投稿やスレッドに実際のソースコードへのリンクも張ってありますのでよかったら参照してみてください。

https://x.com/achamaromi/status/1822513553141625176

https://x.com/tks_hrs/status/1822517253516198062

しかし、background-image による画像の読み込みはウェブパフォーマンスにはあまりよろしくないので避けたいところです。ここでは詳しく述べませんが気になる方はぜひ調べてみてください。

img を使った手法

background を使った手法でも同様ですが、(AB) もしくは (BD) で区切れば、最低でも画像を 2 枚表示させる必要があります。なお、HTML 上で同じ画像の記述が複数回あっても、読み込みは 1 回のみなので、パフォーマンスにはほぼ影響しません。

実装の流れと説明

ここからが本番の実装になります。

グリッドを作成して配置

html
<div class="aug-img">
  <img src="..." alt="" />
  <img src="..." alt="" />
  <div>...</div>
</div>
css
.aug-img {
  display: grid;
  grid-template-rows: 1fr auto;
  /**
   * (B) (D) の最小幅を40%に指定。
   * (C) に最大幅指定でも可。その場合は `auto 1fr` でよい。
   */
  grid-template-columns: auto minmax(40%, 1fr);

  > img {
    grid-row: 1 / 2;
    grid-column: 1 / -1;

    + img {
      grid-row: 2 / -1;
      grid-column: 2 / -1;
    }
  }
}

画像を適切に配置して切り抜く

html
 <div class="aug-img">
-  <img src="..." alt="" />
-  <img src="..." alt="" />
+  <div><img src="..." alt="" /></div>
+  <div><img src="..." alt="" /></div>
   <div>...</div>
 </div>

画像を切り抜くために、img を何かしらの要素で括る必要があります。

css
 .aug-img {
   display: grid;
   grid-template-rows: 1fr auto;
   grid-template-columns: auto minmax(40%, 1fr);
+  position: relative;

+  img {
+    position: absolute;
+    inset: 0;
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+  }
+
-  img {
+  > :has(img) {
     grid-row: 1 / 2;
     grid-column: 1 / -1;
+    clip-path: inset(0);

-    + img {
+    + :has(img) {
       grid-row: 2 / -1;
       grid-column: 2 / -1;
     }
   }
 }

画像をコンテナに対して絶対配置して、その親要素にグリッドの配置を付け直し clip-path を使って切り抜きます。

角丸にする

css
 .aug-img {
+  --r: 24px;
   display: grid;
   grid-template-rows: 1fr auto;
   grid-template-columns: auto minmax(40%, 1fr);
   position: relative;

   img {
     position: absolute;
     inset: 0;
     width: 100%;
     height: 100%;
     object-fit: cover;
   }

   > :has(img) {
     grid-row: 1 / 2;
     grid-column: 1 / -1;
-    clip-path: inset(0);
+    clip-path: inset(0 round var(--r));

     + :has(img) {
       grid-row: 2 / -1;
       grid-column: 2 / -1;
     }
   }
 }

一括指定できるよう角丸の半径を CSS カスタムプロパティに設定して clip-pathround に与えます。

あとで使う mask で必要な部分の角丸だけを再現してもいいんですが、一旦 clip-path で一括で角丸にしておいたほうが楽です。

(AB) と (D) を重ねて、間の角丸をなくす

css
 .aug-img {
   --r: 24px;
+  --d: calc(var(--r) * 2);
   display: grid;
   grid-template-rows: 1fr auto;
   grid-template-columns: auto minmax(40%, 1fr);
   position: relative;

   img {
     position: absolute;
     inset: 0;
     width: 100%;
     height: 100%;
     object-fit: cover;
   }

   > :has(img) {
     grid-row: 1 / 2;
     grid-column: 1 / -1;
     clip-path: inset(0 round var(--r));

     + :has(img) {
       grid-row: 2 / -1;
       grid-column: 2 / -1;
+      margin-top: calc(var(--d) * -1);
     }
   }
 }

あとでも使うので、角丸の直径 (--d) を設定しておく。その分ネガティブマージンで重ねておけば角丸はなくなります。

mask を使って (D) を地道に切り抜いていく

ここからがわりと大変です。

(C) の右上の部分を作るには、(D) を角丸の半径分左に出して、(E) の部分を削る必要があります。

矩形を重ねたマスクを作る

まずは上図のように青、緑、黄の 3 つの矩形を重ねたマスクを作ります。なんで矩形を並べるのではなく重ねるかというと、1px 未満の値があると隙間が生じるリスクがあるのと、重ねたほうが配置やサイズの数値がシンプルです。

css
 .aug-img {
   --r: 24px;
   --d: calc(var(--r) * 2);
   display: grid;
   grid-template-rows: 1fr auto;
   grid-template-columns: auto minmax(40%, 1fr);
   position: relative;

   img {
     position: absolute;
     inset: 0;
     width: 100%;
     height: 100%;
     object-fit: cover;
   }

   > :has(img) {
     grid-row: 1 / 2;
     grid-column: 1 / -1;
     clip-path: inset(0 round var(--r));

     + :has(img) {
       grid-row: 2 / -1;
       grid-column: 2 / -1;
       margin-top: calc(var(--d) * -1);
+      margin-left: calc(var(--r) * -1);

+      --rect: linear-gradient(#000 0 0);
+      mask:
+        var(--rect) var(--d) top / calc(100% - var(--d)) 100%, /* 青 */
+        var(--rect) right top / calc(100% - var(--r)) calc(100% - var(--r)), /* 緑 */
+        var(--rect) left top / 100% calc(var(--r) * 3); /* 黄 */
+      mask-repeat: no-repeat;
     }
   }
 }

ここで問題が発生します。上記スクリーンショットでもわかるように、Chrome で (D) の左側と左下の角丸が薄っすら見えています。この現象 Firefox と Safari では発生しないし、仕様を考えても見えるべきではないので、Chrome のバグだと思います。

結論から言うと、(D) の clip-pathinset() にマイナス値を指定することで回避できますが、同時に round キーワードによる角丸もやめる必要があります。

詳細

左側は inset() にマイナス値を与えることで消せますが、(D) 左下の角丸はどうやっても消えないので、左下の角丸をなくす必要があります。そうすると下側が薄っすら見えることがあるので、下側も inset() にマイナス値を指定する必要があって、それだと右下の角丸に影響してしまいますので、結局 round による角丸が使えなくなります。

css
 .aug-img {
   --r: 24px;
   --d: calc(var(--r) * 2);
   display: grid;
   grid-template-rows: 1fr auto;
   grid-template-columns: auto minmax(40%, 1fr);
   position: relative;

   img {
     position: absolute;
     inset: 0;
     width: 100%;
     height: 100%;
     object-fit: cover;
   }

   > :has(img) {
     grid-row: 1 / 2;
     grid-column: 1 / -1;
     clip-path: inset(0 round var(--r));

     + :has(img) {
       grid-row: 2 / -1;
       grid-column: 2 / -1;
       margin-top: calc(var(--d) * -1);
       margin-left: calc(var(--r) * -1);
+      clip-path: inset(-1px);

       --rect: linear-gradient(#000 0 0);
       mask:
         var(--rect) var(--d) top / calc(100% - var(--d)) 100%, /* 青 */
         var(--rect) right top / calc(100% - var(--r)) calc(100% - var(--r)), /* 緑 */
         var(--rect) left top / 100% calc(var(--r) * 3); /* 黄 */
       mask-repeat: no-repeat;
     }
   }
 }

リファクタリング

こうなると、(AB) と (D) の clip-path の共通化はできなくなったので、(AB) の角丸も必要な分だけにします。こうすると、(D) の上のマイナスマージンも必要なくなるように思いますが、サイズに 1px 未満の値があると Chrome で隙間が出るので、最低 -1px 重ねることをおすすめします。

css
 .aug-img {
   --r: 24px;
   --d: calc(var(--r) * 2);
   display: grid;
   grid-template-rows: 1fr auto;
   grid-template-columns: auto minmax(40%, 1fr);
   position: relative;

   img {
     position: absolute;
     inset: 0;
     width: 100%;
     height: 100%;
     object-fit: cover;
   }

   > :has(img) {
     grid-row: 1 / 2;
     grid-column: 1 / -1;
-    clip-path: inset(0 round var(--r));
+    clip-path: inset(0 round var(--r) var(--r) 0 var(--r));

     + :has(img) {
       grid-row: 2 / -1;
       grid-column: 2 / -1;
-      margin-top: calc(var(--d) * -1);
+      margin-top: -1px;
       margin-left: calc(var(--r) * -1);
       clip-path: inset(-1px);

       --rect: linear-gradient(#000 0 0);
       mask:
         var(--rect) var(--d) top / calc(100% - var(--d)) 100%, /* 青 */
         var(--rect) right top / calc(100% - var(--r)) calc(100% - var(--r)), /* 緑 */
-        var(--rect) left top / 100% calc(var(--r) * 3); /* 黄 */
+        var(--rect) left top / 100% calc(var(--r) + 1px); /* 黄 */
       mask-repeat: no-repeat;
     }
   }
 }

(D) の矩形を調整し円形を重ねて角丸を作る

上図のように右下の角丸も作る必要があるので青い矩形の幅を調整して適切な位置に円形を重ねます。

css
 .aug-img {
   --r: 24px;
   --d: calc(var(--r) * 2);
   display: grid;
   grid-template-rows: 1fr auto;
   grid-template-columns: auto minmax(40%, 1fr);
   position: relative;

   img {
     position: absolute;
     inset: 0;
     width: 100%;
     height: 100%;
     object-fit: cover;
   }

   > :has(img) {
     grid-row: 1 / 2;
     grid-column: 1 / -1;
     clip-path: inset(0 round var(--r) var(--r) 0 var(--r));

     + :has(img) {
       grid-row: 2 / -1;
       grid-column: 2 / -1;
       margin-top: -1px;
       margin-left: calc(var(--r) * -1);
       clip-path: inset(-1px);

       --rect: linear-gradient(#000 0 0);
+      --circle: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'><circle cx='50%' cy='50%' r='50%' /></svg>");
       mask:
-        var(--rect) var(--d) top / calc(100% - var(--d)) 100%, /* 青 */
+        var(--rect) var(--d) top / calc(100% - var(--r) * 3) 100%, /* 青 */
         var(--rect) right top / calc(100% - var(--r)) calc(100% - var(--r)), /* 緑 */
+        var(--circle) var(--r) bottom / var(--d) var(--d), /* (D) 左下角丸 */
+        var(--circle) right bottom / var(--d) var(--d), /* (D) 右下角丸 */
         var(--rect) left top / 100% calc(var(--r) + 1px); /* 黄 */
       mask-repeat: no-repeat;
     }
   }
 }

データ URL に SVG を書けば円形を作ることができます。ポイントは cx cy r に相対値を使うことで、CSS カスタムプロパティでサイズを変えられるようになります。

subtract でマスクを切り抜く

黄色い矩形に subtract を指定し、円形で切り抜きます。図にすると以下のような感じです。

css
 .aug-img {
   --r: 24px;
   --d: calc(var(--r) * 2);
   display: grid;
   grid-template-rows: 1fr auto;
   grid-template-columns: auto minmax(40%, 1fr);
   position: relative;

   img {
     position: absolute;
     inset: 0;
     width: 100%;
     height: 100%;
     object-fit: cover;
   }

   > :has(img) {
     grid-row: 1 / 2;
     grid-column: 1 / -1;
     clip-path: inset(0 round var(--r) var(--r) 0 var(--r));

     + :has(img) {
       grid-row: 2 / -1;
       grid-column: 2 / -1;
       margin-top: -1px;
       margin-left: calc(var(--r) * -1);
       clip-path: inset(-1px);

       --rect: linear-gradient(#000 0 0);
       --circle: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'><circle cx='50%' cy='50%' r='50%' /></svg>");
       mask:
         var(--rect) var(--d) top / calc(100% - var(--r) * 3) 100%, /* 青 */
         var(--rect) right top / calc(100% - var(--r)) calc(100% - var(--r)), /* 緑 */
         var(--circle) var(--r) bottom / var(--d) var(--d), /* (D) 左下角丸 */
         var(--circle) right bottom / var(--d) var(--d), /* (D) 右下角丸 */
-        var(--rect) left top / 100% calc(var(--r) + 1px); /* 黄 */
+        var(--rect) left top / 100% calc(var(--r) + 1px) subtract, /* 黄 */
+        var(--circle) calc(var(--r) * -1) 1px / var(--d) var(--d);
       mask-repeat: no-repeat;
     }
   }
 }

完成

完成したように見えますが、Safari で落とし穴が…

Safari では subtractexclude と同じような挙動をするというバグがあります (Safari、またお前か…Chrome、お前もだけどな…という気持ちです) 。

なので、円形ではなく扇形 (もしくは半円) を作る必要があります。

扇形で切り抜く

css
 .aug-img {
   --r: 24px;
   --d: calc(var(--r) * 2);
   display: grid;
   grid-template-rows: 1fr auto;
   grid-template-columns: auto minmax(40%, 1fr);
   position: relative;

   img {
     position: absolute;
     inset: 0;
     width: 100%;
     height: 100%;
     object-fit: cover;
   }

   > :has(img) {
     grid-row: 1 / 2;
     grid-column: 1 / -1;
     clip-path: inset(0 round var(--r) var(--r) 0 var(--r));

     + :has(img) {
       grid-row: 2 / -1;
       grid-column: 2 / -1;
       margin-top: -1px;
       margin-left: calc(var(--r) * -1);
       clip-path: inset(-1px);

       --rect: linear-gradient(#000 0 0);
       --circle: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'><circle cx='50%' cy='50%' r='50%' /></svg>");
+      --sector-tr: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'><circle cx='0' cy='100%' r='100%' /></svg>");
       mask:
         var(--rect) var(--d) top / calc(100% - var(--r) * 3) 100%, /* 青 */
         var(--rect) right top / calc(100% - var(--r)) calc(100% - var(--r)), /* 緑 */
         var(--circle) var(--r) bottom / var(--d) var(--d), /* (D) 左下角丸 */
         var(--circle) right bottom / var(--d) var(--d), /* (D) 右下角丸 */
         var(--rect) left top / 100% calc(var(--r) + 1px) subtract, /* 黄 */
-        var(--circle) calc(var(--r) * -1) 1px / var(--d) var(--d);
+        var(--sector-tr) left 1px / var(--r) var(--r);
       mask-repeat: no-repeat;
     }
   }
 }

扇形は SVG の circlecx cy r を調整することで作ることができます。中心点を左下にして、半径を 100% にすれば、円形右上部分の扇形になります。

これでついに完成です 🎉

補足

img を 3 回記述すれば、mask による切り抜きをもっとシンプルにできますが、その分 CSS は少し複雑になります。個人的には HTML がシンプルな方がいいかなと。

余談

今回 clip-pathmask で一番問題がなかったのは Firefox でした。clip-path の対応状況も Firefox が一番よかったです。みんなも Firefox を使おう。

おわりに

最後に、ソースコード全体とライブデモを置いておきます。角丸の値やテキストを変更して実際に確認してみてください。レスポンシブにも対応しています。

html
<div class="aug-img">
  <div><img src="..." alt="" /></div>
  <div><img src="..." alt="" /></div>
  <div>...</div>
</div>
css
.aug-img {
  /**
   * 角丸の半径
   */
  --r: 24px;
  --d: calc(var(--r) * 2);
  display: grid;
  grid-template-rows: 1fr auto;
  /**
   * (B) (D) の最小幅を40%に指定。
   * (C) に最大幅指定でも可。その場合は `auto 1fr` でよい。
   */
  grid-template-columns: auto minmax(40%, 1fr);
  position: relative;

  img {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
  }

  > :has(img) {
    grid-row: 1 / 2;
    grid-column: 1 / -1;
    clip-path: inset(0 round var(--r) var(--r) 0 var(--r));

    + :has(img) {
      grid-row: 2 / -1;
      grid-column: 2 / -1;
      margin-top: -1px;
      margin-left: calc(var(--r) * -1);
      clip-path: inset(-1px);

      --rect: linear-gradient(#000 0 0);
      --circle: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'><circle cx='50%' cy='50%' r='50%' /></svg>");
      --sector-tr: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'><circle cx='0' cy='100%' r='100%' /></svg>");
      mask:
        var(--rect) var(--d) top / calc(100% - var(--r) * 3) 100%, /* 青 */
        var(--rect) right top / calc(100% - var(--r)) calc(100% - var(--r)), /* 緑 */
        var(--circle) var(--r) bottom / var(--d) var(--d), /* (D) 左下角丸 */
        var(--circle) right bottom / var(--d) var(--d), /* (D) 右下角丸 */
        var(--rect) left top / 100% calc(var(--r) + 1px) subtract, /* 黄 */
        var(--sector-tr) left 1px / var(--r) var(--r);
      mask-repeat: no-repeat;
    }
  }
}

Discussion