🍃

Tailwind CSS のプラグインで逆角丸を実装する

2024/12/29に公開

ゴール

逆角丸こと、こういうやつをつくります。
やり方はいくつかありますが、SVGでマスクする方法で実装します。

実装

tailwindcss/plugin からプラグインを作成する関数をインポートし、そこに渡すコールバック関数を実装していきます。

import plugin from "tailwindcss/plugin";
export function roundedMask() {
  return plugin(({ addBase, matchUtilities, theme }) => {
    // この中で実装
  },
  {
    // コンフィグ
  });
}

プラグイン用のデフォルトテーマを定義

角丸のサイズを定義したデフォルトのテーマを用意します。
角丸なので borderRadius のテーマを流用することにします。

import defaultTheme from "tailwindcss/defaultTheme";

return plugin(({ addBase, matchUtilities, theme }) => {
  // この中で実装
},
{
  // コンフィグ
  theme: {
    roundedMask: {
      ...defaultTheme.borderRadius,
      // borderRadiusのfullは大きい値が設定されていて大変なことになってしまうのでこれだけ上書き
      full: "auto",
    },
  },
});

SVGマスクの定義

addBase でカスタムプロパティにマスクに使用するSVGを定義していきます。
SVGと一緒に位置も定義します。

addBase({
  ":root": {
    "--rounded-mask-image-tl": `url('data:image/svg+xml;,<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 2 2"><path d="M1 0A1 1,1,0,0,0 1L0 0Z"/></svg>') 0 0`,
    "--rounded-mask-image-tr": `url('data:image/svg+xml;,<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 2 2"><path d="M1 0A1 1,1,0,1,2 1L2 0Z"/></svg>') 100% 0`,
    "--rounded-mask-image-bl": `url('data:image/svg+xml;,<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 2 2"><path d="M1 2A1 1,1,0,1,0 1L0 2Z"/></svg>') 0 100%`,
    "--rounded-mask-image-br": `url('data:image/svg+xml;,<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 2 2"><path d="M2 1A1 1,1,0,1,1 2L2 2Z"/></svg>') 100% 100%`,
  },
});

ユーティリティクラスの定義

rounded-mask にサイズ( sm lg 等)を組み合わせたユーティリティを作りたいのでmatchUtilities を使用します。
matchUtilities は、クラス名とそのスタイルを定義したオブジェクトを返す関数を登録します。

先ほど定義した4つの角のSVGを全て指定し、動的に渡ってくるサイズをそのまま指定すれば簡単なユーティリティクラスが出来上がります。

matchUtilities<string>({
  "rounded-mask": (maskSize: string) => {
    return {
      mask: "var(--rounded-mask-image-tl), var(--rounded-mask-image-tr), var(--rounded-mask-image-bl), var(--rounded-mask-image-br)",
      maskSize,
      maskRepeat: "no-repeat",
    }
  },
},
{
  // プラグイン用のテーマから値セットを指定
  values: theme("roundedMask"),
});

同じように4つの角を個別で指定できるようにクラス名とスタイルを返す関数を追加していきます。

matchUtilities<string>({
  // ...

  // 上側の2つの角
  "rounded-mask-t": (maskSize: string) => {
    return {
      mask: "var(--rounded-mask-image-tl), var(--rounded-mask-image-tr)",
      maskSize,
      maskRepeat: "no-repeat",
    }
  },

  // ...

  // 左下の角
  "rounded-mask-bl": (maskSize: string) => {
    return {
      mask: "var(--rounded-mask-image-bl)",
      maskSize,
      maskRepeat: "no-repeat",
    }
  },
},
{
  values: theme("roundedMask"),
});

これでそれぞれのクラスが使えるようになりますが、 rounded-t rounded-bl のように異なるクラスを一緒に指定すると一方の mask で上書きされてしまい期待した結果になりません。
そこで mask にはあらかじめデフォルト none で4つ分の指定をしておき、マスクを置きたい場所にだけカスタムプロパティでマスクを指定するようにします。

matchUtilities<string>({
  "rounded-mask": (maskSize: string) => {
    return {
      // 前クラス共通の設定
      mask: "var(--rounded-mask-tl, none), var(--rounded-mask-tr, none), var(--rounded-mask-bl, none), var(--rounded-mask-br, none)",
      maskSize: "var(--rounded-mask-size-tl,auto), var(--rounded-mask-size-tr,auto), var(--rounded-mask-size-bl,auto), var(--rounded-mask-size-br,auto)",
      maskRepeat: "no-repeat",

      // マスクを置きたい場所だけカスタムプロパティで指定する
      "--rounded-mask-tl": "var(--rounded-mask-image-tl)",
      "--rounded-mask-tr": "var(--rounded-mask-image-tr)",
      "--rounded-mask-bl": "var(--rounded-mask-image-bl)",
      "--rounded-mask-br": "var(--rounded-mask-image-br)",

      "--rounded-mask-size-tl": maskSize,
      "--rounded-mask-size-tr": maskSize,
      "--rounded-mask-size-bl": maskSize,
      "--rounded-mask-size-br": maskSize,
    }
  },

  // ...
});

これを全クラス分用意します。
そのまま全部書くのは大変なのでコードの整理も行います。

rounded-mask.ts
import defaultTheme from "tailwindcss/defaultTheme";
import plugin from "tailwindcss/plugin";

// Plugin
export function roundedMask() {
  return plugin(
    ({ addBase, matchUtilities, theme }) => {
      addBase({
        ":root": {
          "--rounded-mask-image-tl": `url('data:image/svg+xml;,<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 2 2"><path d="M1 0A1 1,1,0,0,0 1L0 0Z"/></svg>') 0 0`,
          "--rounded-mask-image-tr": `url('data:image/svg+xml;,<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 2 2"><path d="M1 0A1 1,1,0,1,2 1L2 0Z"/></svg>') 100% 0`,
          "--rounded-mask-image-bl": `url('data:image/svg+xml;,<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 2 2"><path d="M1 2A1 1,1,0,1,0 1L0 2Z"/></svg>') 0 100%`,
          "--rounded-mask-image-br": `url('data:image/svg+xml;,<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 2 2"><path d="M2 1A1 1,1,0,1,1 2L2 2Z"/></svg>') 100% 100%`,
        },
      });

      const tl = { "--rounded-mask-tl": "var(--rounded-mask-image-tl)" };
      const tr = { "--rounded-mask-tr": "var(--rounded-mask-image-tr)" };
      const bl = { "--rounded-mask-bl": "var(--rounded-mask-image-bl)" };
      const br = { "--rounded-mask-br": "var(--rounded-mask-image-br)" };
      const l = { ...tl, ...bl };
      const t = { ...tl, ...tr };
      const r = { ...tr, ...br };
      const b = { ...bl, ...br };

      const maskUtility = (mask: object) => (maskSize: string) => {
        return {
          mask: "var(--rounded-mask-tl,none), var(--rounded-mask-tr,none), var(--rounded-mask-bl,none), var(--rounded-mask-br,none)",
          ...mask,
          maskSize:
            "var(--rounded-mask-size-tl,auto), var(--rounded-mask-size-tr,auto), var(--rounded-mask-size-bl,auto), var(--rounded-mask-size-br,auto)",
          ...Object.fromEntries(
            Object.keys(mask).map((name) => [
              name.replace("mask", "mask-size"),
              maskSize,
            ]),
          ),
          maskRepeat: "no-repeat",
        };
      };

      matchUtilities<string>(
        {
          "rounded-mask": maskUtility({ ...l, ...r }),
          "rounded-mask-l": maskUtility(l),
          "rounded-mask-t": maskUtility(t),
          "rounded-mask-r": maskUtility(r),
          "rounded-mask-b": maskUtility(b),
          "rounded-mask-tl": maskUtility(tl),
          "rounded-mask-tr": maskUtility(tr),
          "rounded-mask-bl": maskUtility(bl),
          "rounded-mask-br": maskUtility(br),
        },
        {
          values: theme("roundedMask"),
        },
      );
    },
    {
      theme: {
        roundedMask: {
          ...defaultTheme.borderRadius,
          full: "auto",
        },
      },
    },
  );
}

使い方

これを tailwindcss.config.ts で読み込めばクラスが使用できるようになります。

tailwindcss.config.ts
import type { Config } from "tailwindcss";
import { roundedMask } from "./rounded-mask";

export default {
  // ...
  plugins: [
    // ...
    roundedMask(),
    // ...
  ],
} satisfies Config;
    <div class="rounded-xl">
      <div class="rounded-mask-full"></div>
    </div>
    <div class="rounded-t-xl">
      <div class="rounded-mask-t-full"></div>
    </div>
    <div class="rounded-b-xl">
      <div class="rounded-mask-b-full"></div>
    </div>
    <div class="rounded-l-xl">
      <div class="rounded-mask-l-full"></div>
    </div>
    <div class="rounded-r-xl">
      <div class="rounded-mask-r-full"></div>
    </div>
    <div class="rounded-tl-xl">
      <div class="rounded-mask-tl-full"></div>
    </div>
    <div class="rounded-tr-xl">
      <div class="rounded-mask-tr-full"></div>
    </div>
    <div class="rounded-bl-xl">
      <div class="rounded-mask-bl-full"></div>
    </div>
    <div class="rounded-br-xl">
      <div class="rounded-mask-br-full"></div>
    </div>

    <div class="rounded-t-xl rounded-bl-xl">
      <div class="rounded-mask-bl-full rounded-mask-t-full"></div>
    </div>

twMergeプラグイン

twMergeを使っている場合、 rounded-mask 同士がマージされてしまいます。
そこで rounded の設定を参考にグループとコンフリクトの設定を拡張するプラグインを実装します。

tailwind-merge-plugin.ts
import {
  type Config,
  fromTheme,
  mergeConfigs,
  validators,
} from "tailwind-merge";

// twMerge
export function roundedMaskTwMergePlugin() {
  return (config: Config<string, string>) => {
    return mergeConfigs(config, {
      extend: {
        classGroups: {
          ...Object.fromEntries(
            ["", "-l", "-r", "-t", "-b", "-tl", "-tr", "-bl", "-br"].map(
              (k) => [
                `rounded-mask${k}`,
                [
                  {
                    [`rounded-mask${k}`]: [
                      validators.isArbitraryLength,
                      fromTheme<"roundedMask">("roundedMask"),
                    ],
                  },
                ],
              ],
            ),
          ),
        },
        conflictingClassGroups: {
          "rounded-mask": [
            "-l",
            "-t",
            "-r",
            "-b",
            "-tl",
            "-tr",
            "-bl",
            "-br",
          ].map((s) => `rounded-mask${s}`),
          "rounded-mask-l": ["-tl", "-bl"].map((s) => `rounded-mask${s}`),
          "rounded-mask-r": ["-tr", "-br"].map((s) => `rounded-mask${s}`),
          "rounded-mask-t": ["-tl", "-tr"].map((s) => `rounded-mask${s}`),
          "rounded-mask-b": ["-bl", "-br"].map((s) => `rounded-mask${s}`),
        },
      },
    });
  };
}

extendTailwindMerge() で拡張したものを使います。

import { extendTailwindMerge } from "tailwind-merge";
import { roundedMaskTwMergePlugin } from "./tailwind-merge-plugin";

export const twMerge = extendTailwindMerge(
  // ...
  roundedMaskTwMergePlugin(),
  // ...
);
twMerge("rounded-mask", "rounded-mask-sm");
// rounded-mask-sm

twMerge("rounded-mask-t", "rounded-mask-bl");
// rounded-mask-t rounded-mask-bl

おわり

v4楽しみですね。プラグインそのまま動くのかな。

今回実装したものを Tailwind Play に保存しました。
https://play.tailwindcss.com/oppmeVoXO2

(追記)パディングに対応する

縁を残す必要が出てきたのでパディングを使って対応しました。

mask-origin: content-box を指定することでマスクをパディングの内側に配置することができるので、空いたパディング領域をさらに別の画像でマスクをかけます。

パディング用のSVGを追加

辺の長さに合わせて mask-size で調整するので、 preserveAspectRatio="none" を指定した正方形のSVGをひとつだけ追加します。
また、角のSVGには content-box を指定しておきます。

 addBase({
   ":root": {
-   "--rounded-mask-image-tl": `url('data:image/svg+xml;,<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 2 2"><path d="M1 0A1 1,1,0,0,0  1L0 0Z"/></svg>')`,
-   "--rounded-mask-image-tr": `url('data:image/svg+xml;,<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 2 2"><path d="M1 0A1 1,1,0,1,2  1L2 0Z"/></svg>') 100% 0`,
-   "--rounded-mask-image-bl": `url('data:image/svg+xml;,<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 2 2"><path d="M1 2A1 1,1,0,1,0  1L0 2Z"/></svg>') 0 100%`,
-   "--rounded-mask-image-br": `url('data:image/svg+xml;,<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 2 2"><path d="M2 1A1 1,1,0,1,1  2L2 2Z"/></svg>') 100% 100%`,
+   "--rounded-mask-image-tl": `url('data:image/svg+xml;,<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 2 2"><path d="M1 0A1 1,1,0,0,0  1L0 0Z"/></svg>') content-box`,
+   "--rounded-mask-image-tr": `url('data:image/svg+xml;,<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 2 2"><path d="M1 0A1 1,1,0,1,2  1L2 0Z"/></svg>') 100% 0 content-box`,
+   "--rounded-mask-image-bl": `url('data:image/svg+xml;,<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 2 2"><path d="M1 2A1 1,1,0,1,0  1L0 2Z"/></svg>') 0 100% content-box`,
+   "--rounded-mask-image-br": `url('data:image/svg+xml;,<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 2 2"><path d="M2 1A1 1,1,0,1,1  2L2 2Z"/></svg>') 100% 100% content-box`,
+   "--rounded-mask-image-px": `url('data:image/svg+xml;,<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1 1"  preserveAspectRatio="none"><rect width="1" height="1"/></svg>')`,
   },
 });

パディング用のマスクの定義を追加

 const maskUtility = (values: object) => (maskSize: string) => {
   return {
-    mask: "var(--rounded-mask-tl,none), var(--rounded-mask-tr,none), var(--rounded-mask-bl,none), var(--rounded-mask-br,none)",
+    mask: "var(--rounded-mask-tl,none),var(--rounded-mask-tr,none),var(--rounded-mask-bl,none),var(--rounded-mask-br,none),var(--rounded-mask-image-px),var(--rounded-mask-image-px),var(--rounded-mask-image-px) right 0,var(--rounded-mask-image-px) 0 bottom",
     maskSize:
-      "var(--rounded-mask-size-tl,auto), var(--rounded-mask-size-tr,auto), var(--rounded-mask-size-bl,auto), var(--rounded-mask-size-br,auto)",
+      "var(--rounded-mask-size-tl,auto),var(--rounded-mask-size-tr,auto),var(--rounded-mask-size-bl,auto),var(--rounded-mask-size-br,auto),var(--rounded-mask-padding-l,0) 100%,100% var(--rounded-mask-padding-t,0),var(--rounded-mask-padding-r,0) 100%,100% var(--rounded-mask-padding-b,0)",
     ...values,
     ...Object.fromEntries(
       Object.keys(values).map((name) => [
         name.replace("mask", "mask-size"),
         maskSize,
       ]),
     ),
     maskRepeat: "no-repeat",
   };
 };

パディングを指定するユーティリティクラスを追加

const paddingSides = {
  l: "paddingLeft",
  t: "paddingTop",
  r: "paddingRight",
  b: "paddingBottom",
} as const;

const paddingUtility =
  (...sides: (keyof typeof paddingSides)[]) =>
  (padding: string) =>
    Object.fromEntries(
      sides.flatMap((side) => [
        [paddingSides[side], padding],
        [`--rounded-mask-padding-${side}`, padding],
      ]),
    );

const px = paddingUtility("l", "r");
const py = paddingUtility("t", "b");
const pl = paddingUtility("l");
const pt = paddingUtility("t");
const pr = paddingUtility("r");
const pb = paddingUtility("b");

matchUtilities<string>(
  {
    "rounded-mask-p": (padding: string) => {
      const sides = padding.split(/\s+/);
      switch (sides.length) {
        case 1:
          return {
            ...py(padding),
            ...px(padding),
          };
        case 2:
          return {
            ...py(sides[0]),
            ...px(sides[1]),
          };
        case 3:
          return {
            ...pt(sides[0]),
            ...px(sides[1]),
            ...pb(sides[2]),
          };
        default:
          return {
            ...pt(sides[0]),
            ...pr(sides[1]),
            ...pb(sides[2]),
            ...pl(sides[3]),
          };
      }
    },
    "rounded-mask-px": px,
    "rounded-mask-py": py,
    "rounded-mask-pl": pl,
    "rounded-mask-pt": pt,
    "rounded-mask-pr": pr,
    "rounded-mask-pb": pb,
  },
  {
    values: theme("spacing"),
  },
);

これで完成です。

使い方

rounded-mask と一緒に指定することでパディング部分にもマスクがかかるようになりました。

    <div class="rounded-mask-full rounded-mask-p-1"></div>
    <!-- パディングと一緒に `rounded` を指定すると丸めた部分が削れてしまう(何故かわからない)のでラップする -->
    <div class="overflow-hidden rounded-l-full !bg-transparent">
      <div class="h-full w-full bg-sky-200 rounded-mask-full rounded-mask-p-1"></div>
    </div>

https://play.tailwindcss.com/4rjQKBFwuv

Discussion