この記事の概要
こういったレイアウト、地味に実装の仕方に悩みませんか?
まして、要件に「画面サイズと文章の折り返しにあわせて切り抜き位置が変化する」だった場合、なおさら。
少し幅が狭い画面 | もっと幅が狭い画面 |
---|---|
これらを、Reactにおいて、SVGのとCSSのclip-pathを使って実現しようと思います。
技術的なチャレンジという意味合いが強い記事です。コードが複雑にもなりますし、useEffectによる副作用も発生しているので、使い所はご注意ください。
完成形
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;
.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;
}
全体を大まかに説明すると、以下のステップで成り立っています。
- 背景画像と前面の文章に
ref
を設定し、幅と高さを取得する - 取得した幅と高さ+コンポーネント外で指定した角丸のパスを使って、切り抜きたい形のSVGのパスを定義する
- コンポーネント内でそのパスを使用し、idを付与し、CSSの
clip-path
で呼び出す - 画面のリサイズが発生した際は画像と文章のサイズを取得し直す
- 繰り返し
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は始点と終点といった意味で必要)。
そして、実際のコードを分解しながら説明します。
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
`;
改めて上記が全体像です。
const RADIUS = 16;
const ARC_PATH = `${RADIUS} ${RADIUS} 0 0 1`;
円弧のパスは何度も使うので、使い回しできるように切り出しています。
RADIUS
を変更すれば切り抜くときの角丸を一括で変更できます。
const newClipPath = `
M${RADIUS} 0
L${backgroundWidth - RADIUS} 0
A${ARC_PATH} ${backgroundWidth} ${RADIUS}
...
`
左上(0, 0)から半径分だけ移動し、右上まで直線を引きます。
そして、右上の角を円弧で丸めています。
const newClipPath = `
...
L${backgroundWidth} ${backgroundHeight - RADIUS}
A${ARC_PATH} ${backgroundWidth - RADIUS} ${backgroundHeight}
...
`
右側の縦線を引き、右下の角を丸めています。
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
と記載しています。
const newClipPath = `
...
L${RADIUS} ${backgroundHeight - paragraphHeight}
A${ARC_PATH} 0 ${backgroundHeight - paragraphHeight - RADIUS}
L0 ${RADIUS}
A${ARC_PATH} ${RADIUS} 0
Z
...
`
テキストの左上まで線を引き、スタートの座標へ戻り、パスをクローズしています。
Reactとしての実装
難しいことはしていませんが、次のような処理をしています。
-
useRef
を使用して画像とテキストのサイズを取得 -
useState
を使用してクリップパスを管理 -
useEffect
を使用して画面サイズの変更時にクリップパスを更新