Reactで画像をテキストに合わせて切り抜く（SVGとCSSのclip-pathを使用）

## この記事の概要

こういったレイアウト、地味に実装の仕方に悩みませんか？

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214677/fb2f1f28-133b-f55d-ce97-09d1b8bae17e.png)

まして、要件に「画面サイズと文章の折り返しにあわせて切り抜き位置が変化する」だった場合、なおさら。

| 少し幅が狭い画面 | もっと幅が狭い画面 |
| --- | --- |
| ![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214677/7a2e6b03-26a3-ac25-7e7c-b54bb8426bea.png) | ![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214677/b4094d3e-a055-3c36-6661-349a80511d63.png) |

これらを、Reactにおいて、SVGのとCSSのclip-pathを使って実現しようと思います。

:::note warn
技術的なチャレンジという意味合いが強い記事です。コードが複雑にもなりますし、useEffectによる副作用も発生しているので、使い所はご注意ください。
:::

## 完成形

```tsx
import { useEffect, useRef, useState, useCallback } from "react";
import "./App.css";

const RADIUS = 16;
const ARC_PATH = `${RADIUS} ${RADIUS} 0 0 1`;

function App() {
  const backgroundRef = useRef<HTMLImageElement | null>(null);
  const paragraphRef = useRef<HTMLParagraphElement | null>(null);
  const [clipPath, setClipPath] = useState("");

  const updateDimensions = useCallback(() => {
    const backroundCurrent = backgroundRef.current;
    const paragraphCurrent = paragraphRef.current;
    if (backroundCurrent && paragraphCurrent) {
      const backgroundWidth = backroundCurrent.offsetWidth;
      const backgroundHeight = backroundCurrent.offsetHeight;
      const paragraphWidth = paragraphCurrent.offsetWidth;
      const paragraphHeight = paragraphCurrent.offsetHeight;
      const newClipPath = `
        M${RADIUS} 0
        L${backgroundWidth - RADIUS} 0
        A${ARC_PATH} ${backgroundWidth} ${RADIUS}
        L${backgroundWidth} ${backgroundHeight - RADIUS}
        A${ARC_PATH} ${backgroundWidth - RADIUS} ${backgroundHeight}
        L${paragraphWidth + RADIUS} ${backgroundHeight}
        A${ARC_PATH} ${paragraphWidth} ${backgroundHeight - RADIUS}
        L${paragraphWidth} ${backgroundHeight - paragraphHeight + RADIUS}
        A${RADIUS} ${RADIUS} 0 0 0 ${paragraphWidth - RADIUS} ${backgroundHeight - paragraphHeight}
        L${RADIUS} ${backgroundHeight - paragraphHeight}
        A${ARC_PATH} 0 ${backgroundHeight - paragraphHeight - RADIUS}
        L0 ${RADIUS}
        A${ARC_PATH} ${RADIUS} 0
        Z
      `;
      setClipPath(newClipPath);
    }
  }, []);

  useEffect(() => {
    updateDimensions();
    window.addEventListener("resize", updateDimensions);
    return () => {
      window.removeEventListener("resize", updateDimensions);
    };
  }, [updateDimensions]);

  return (
    <>
      <svg width="0" height="0">
        <clipPath id="clip">
          <path d={clipPath} />
        </clipPath>
      </svg>
      <div className="container">
        <div className="graphic">
          <img
            alt=""
            className="background"
            onLoad={updateDimensions}
            ref={backgroundRef}
            src="https://picsum.photos/id/681/1920/1080"
            width={1920}
            height={1080}
          />
          <p className="paragraph" ref={paragraphRef}>
            The quick brown fox jumps over the lazy dog
          </p>
        </div>
      </div>
    </>
  );
}

export default App;
```

```css
.container {
  width: min(100%, 960px);
}

.graphic {
  position: relative;
}

.background {
  clip-path: url(#clip);
  height: auto;
  max-width: 100%;
}

.paragraph {
  bottom: 0;
  font-size: 2rem;
  max-width: 60%;
  padding: 1rem 1rem 0;
  position: absolute;
}
```

全体を大まかに説明すると、以下のステップで成り立っています。

1. 背景画像と前面の文章に`ref`を設定し、幅と高さを取得する
1. 取得した幅と高さ＋コンポーネント外で指定した角丸のパスを使って、切り抜きたい形のSVGのパスを定義する
1. コンポーネント内でそのパスを使用し、idを付与し、CSSの`clip-path`で呼び出す
1. 画面のリサイズが発生した際は画像と文章のサイズを取得し直す
1. 繰り返し

## SVGのパスの書き方

SVGのパスは以下のようなコマンドで構成されています。

- M x y - Move to（指定した座標に移動）
- L x y - Line to（現在の位置から指定座標まで直線を引く）
- A rx ry x-axis-rotation large-arc-flag sweep-flag x y - Arc（円弧を描く）
- Z - Close path（パスを閉じる）

今回のコードも、色々と書いてありますが実質L, Aの組み合わせだけで作られています（M, Zは始点と終点といった意味で必要）。

そして、実際のコードを分解しながら説明します。

```tsx
const RADIUS = 16;
const ARC_PATH = `${RADIUS} ${RADIUS} 0 0 1`;

const newClipPath = `
  M${RADIUS} 0
  L${backgroundWidth - RADIUS} 0
  A${ARC_PATH} ${backgroundWidth} ${RADIUS}
  L${backgroundWidth} ${backgroundHeight - RADIUS}
  A${ARC_PATH} ${backgroundWidth - RADIUS} ${backgroundHeight}
  L${paragraphWidth + RADIUS} ${backgroundHeight}
  A${ARC_PATH} ${paragraphWidth} ${backgroundHeight - RADIUS}
  L${paragraphWidth} ${backgroundHeight - paragraphHeight + RADIUS}
  A${RADIUS} ${RADIUS} 0 0 0 ${paragraphWidth - RADIUS} ${backgroundHeight - paragraphHeight}
  L${RADIUS} ${backgroundHeight - paragraphHeight}
  A${ARC_PATH} 0 ${backgroundHeight - paragraphHeight - RADIUS}
  L0 ${RADIUS}
  A${ARC_PATH} ${RADIUS} 0
  Z
`;
```

改めて上記が全体像です。

```tsx
const RADIUS = 16;
const ARC_PATH = `${RADIUS} ${RADIUS} 0 0 1`;
```

円弧のパスは何度も使うので、使い回しできるように切り出しています。
`RADIUS`を変更すれば切り抜くときの角丸を一括で変更できます。

```tsx
const newClipPath = `
  M${RADIUS} 0
  L${backgroundWidth - RADIUS} 0
  A${ARC_PATH} ${backgroundWidth} ${RADIUS}
  ...
`
```

左上(0, 0)から半径分だけ移動し、右上まで直線を引きます。
そして、右上の角を円弧で丸めています。

```tsx
const newClipPath = `
  ...
  L${backgroundWidth} ${backgroundHeight - RADIUS}
  A${ARC_PATH} ${backgroundWidth - RADIUS} ${backgroundHeight}
  ...
`
```

右側の縦線を引き、右下の角を丸めています。

```tsx
const newClipPath = `
  ...
  L${paragraphWidth + RADIUS} ${backgroundHeight}
  A${ARC_PATH} ${paragraphWidth} ${backgroundHeight - RADIUS}
  L${paragraphWidth} ${backgroundHeight - paragraphHeight + RADIUS}
  A${RADIUS} ${RADIUS} 0 0 0 ${paragraphWidth - RADIUS} ${backgroundHeight - paragraphHeight}
  ...
`
```

テキストの右下まで線を引き、テキストの右上に線を引いています。
右下、右上ともに角を丸めています。

ここだけはパスの重なりというか切り抜き方というか、そういった関係上`ARC_PATH`が使えず`${RADIUS} ${RADIUS} 0 0 0`と記載しています。

```tsx
const newClipPath = `
  ...
  L${RADIUS} ${backgroundHeight - paragraphHeight}
  A${ARC_PATH} 0 ${backgroundHeight - paragraphHeight - RADIUS}
  L0 ${RADIUS}
  A${ARC_PATH} ${RADIUS} 0
  Z
  ...
`
```

テキストの左上まで線を引き、スタートの座標へ戻り、パスをクローズしています。

## Reactとしての実装

難しいことはしていませんが、次のような処理をしています。

1. `useRef`を使用して画像とテキストのサイズを取得
1. `useState`を使用してクリップパスを管理
1. `useEffect`を使用して画面サイズの変更時にクリップパスを更新