BLK-61Blocks
破れ紙風セクション区切り
節と節の境目そのものを主題にした区切りブロック。60頂点あまりの不規則ポリゴンを clip-path に与え、白・生成り・墨の3つの色面を手で裂いた断面で分ける。裂け目は「うねり(低周波の正弦2本)」「ささくれ(1頂点ごとの微小な乱れ)」「食い込み(十数点に1度の深い落ち込み)」の3層とx刻みの不等分で組み立て、数px内側にもう1本引いた線との隙間を明るく塗って紙の繊維に見せる。乱数はシード付きなのでサーバーとクライアントで同じ形が出て、頂点数を固定してあるため「もう一度裂く」でポリゴン同士が滑らかにモーフする。画像素材・依存ライブラリはゼロ。
- 追加日:
- 2026-09-15
- 依存:
- なし
- tags
- #divider #section #clip-path #polygon #texture #generative #no-image
プレビュー
BLK-61セクション区切り頂点 62
直線で割らず、紙を裂いて節を変える
境目を1本の直線や波線で引くと、どれだけ滑らかにしても図形に見える。頂点を30前後まで増やし、 うねり・ささくれ・食い込みの3層を重ねると、同じ clip-path ひとつで手で裂いた断面に近づく。
裂け目を作る3層
- 01
うねり
低周波の正弦を2本重ね、断面全体のゆるやかな高低を作る。ここだけでは波線と変わらない。
- 02
ささくれ
1頂点ごとの微小な乱れ。振幅を裂け幅の1/4に抑えるのが要点で、上げるとノコギリに見える。
- 03
食い込み
十数点に1度だけ深く落ちる点を混ぜる。この不規則さが「反復する模様」への収束を断つ。
区切りは節と節の関係そのものを決める。裂け目を浅くすれば続きもの、深くして色面を反転させれば 切断になる。同じ生成ロジックのまま、裂け幅と食い込みの確率だけで言い分けられる。
"use client";
import { useMemo, useState } from "react";
/**
* 破れ紙風セクション区切り(BLK-61)
*
* 差別化メモ: ギャラリーに「節と節の境目そのもの」を主題にしたブロックは無く、既存の clip-path 利用は
* 写真を覆うマスクと左右ドラッグの仕切りの2つだけ(どちらも矩形 inset で、境界線の形は主題ではない)。
* 本ブロックは中身をほぼ持たず、上下の色面と1本の多頂点ポリゴンだけで成立させる。
* 節の定型も意図的に外した: 英字キッカーなし / 末尾の矢印リンクなし / 角丸ゼロ / 出現アニメーションなし
* (紙の裂け目が勝手に動くと嘘になるので、動くのは「裂き直し」を押したときだけ)。
*
* 汎用技法メモ(web-design-playbook 還流用):
* - **直線・波線と破れ線を分けるのは頂点数ではなく"層"**: 頂点を増やしただけの乱数ギザギザは、目が
* すぐ「等間隔の模様」だと見抜く。裂け目に見せるには3層を重ねる。①うねり=低周波の正弦2本
* (断面全体のゆるやかな高低)②ささくれ=1頂点ごとの微小な乱れ(振幅は裂け幅の1/4程度に抑える。
* ここを上げると単なるノコギリになる)③食い込み=十数点に1度だけ深く落ちる点(反復を断つ役)。
* さらに **x の刻みを等分しない**(`0.28 + rand()**2 * 1.9` のように小さい間隔を多数・稀に長い直線区間)。
* 実際の紙は細かい欠けが密集する区間と、すっと切れる区間が交互に出る。
* - **裂け目は2本引く。1本だと"色の境目"にしかならない**: 裂け目の線(cut)の数px内側にもう1本(pulp)を
* 引き、その隙間だけを明るい色で塗ると、めくれた紙の内側=繊維に見える。隙間の幅が場所ごとに
* ばらつくことが効くので、2本目は**独立に生成せず1本目から必ず下へずらして作る**
* (独立生成だと交差して繊維が消える/裏返る箇所が出る)。
* - **影は clip-path の形に沿わない**: `box-shadow` は clip-path で切り落とされ、`filter` は仕様上
* clip-path より**前**に適用されるので同じ要素に付けても効かない。外側に filter 用のラッパを1枚足し、
* 内側に clip-path を当てる。上に乗る側の縁は「offsetなしの狭い drop-shadow(全周の縁)」+
* 「下方向の drop-shadow(落ち影)」の2本重ねにすると、上端は縁取り・下端は落ち影として両方読める。
* - **座標は x=%・y=px で混ぜる**: y まで % にすると画面幅で裂けの粗さが伸び縮みして紙に見えない。
* px 固定なら、幅が変わっても「手で裂いた粗さ」が一定に保たれる(横スクロールの原因にもならない)。
* - **clip-path は頂点数が同じときだけ補間する**: シードを変えても steps を固定しておくと、
* polygon 同士が滑らかにモーフして「裂き直し」が1回の transition で表現できる。頂点数が変わると
* その瞬間だけスナップするので、可変長にする設計にしない。`calc(100% - Npx)` 同士も補間される。
* - **乱数は seed 付きにする**: `Math.random()` で組むとサーバーとクライアントで別の形が出て
* hydration が壊れる。mulberry32 程度の 5 行の PRNG で足りる。
* - **重なり順は「後ろの兄弟が上」ではなく z-index で決める**: 下の帯を上へ乗せるのは負の margin で
* 済むが、上の帯を下へ覆いかぶせる向きは DOM 順と逆になるため、覆う側に z-10 を与える。
*/
/* ------------------------------------------------------------------ *
* 破れ線の生成
* ------------------------------------------------------------------ */
/** 32bit シード → [0,1) の決定的な擬似乱数(SSR とクライアントで同じ列が出る) */
function mulberry32(seed: number) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
type TearSpec = {
seed: number;
/** 区間数。頂点は steps+1 点。シードを変えても固定する(clip-path を補間させるため) */
steps: number;
/** 裂け幅(px)。この帯の中で線が上下する */
depth: number;
/** 食い込みの発生確率(0〜1) */
bite: number;
};
/** 裂け目(cut)と繊維の縁(pulp)の2本。x は 0〜100(%)、y は px */
type Line = { xs: number[]; cut: number[]; pulp: number[] };
function tearLine({ seed, steps, depth, bite }: TearSpec): Line {
const rand = mulberry32(seed);
const round = (v: number) => Math.round(v * 100) / 100;
// ① x の刻みを不揃いにする。rand()**2 で小刻みが多く、稀に長い直線区間が混じる
const gaps = Array.from({ length: steps }, () => 0.28 + rand() ** 2 * 1.9);
const span = gaps.reduce((a, b) => a + b, 0);
// ② うねり(低周波)の周波数と位相もシードから引く=区切りごとに違う「裂け癖」になる
const f1 = 1.1 + rand() * 0.9;
const f2 = 2.7 + rand() * 1.8;
const p1 = rand() * Math.PI * 2;
const p2 = rand() * Math.PI * 2;
const xs: number[] = [];
const cut: number[] = [];
const pulp: number[] = [];
let x = 0;
for (let i = 0; i <= steps; i++) {
const t = x / 100;
const swell =
Math.sin(t * Math.PI * 2 * f1 + p1) * 0.62 + Math.sin(t * Math.PI * 2 * f2 + p2) * 0.38;
const fray = rand() * 2 - 1; // ③ ささくれ(高周波)
let y = depth * 0.46 + swell * depth * 0.3 + fray * depth * 0.26;
if (rand() < bite) y += (0.32 + rand() * 0.5) * depth * 0.5; // ④ 食い込み
y = Math.min(depth - 1, Math.max(1.5, y));
xs.push(i === 0 ? 0 : i === steps ? 100 : Math.min(99.4, round(x)));
cut.push(round(y));
// ⑤ 繊維の縁は独立に生成せず、必ず裂け目から下へずらして作る(交差=繊維の消失を防ぐ)
pulp.push(round(y + 2.2 + rand() * 6.4));
x += ((gaps[i] ?? 0) / span) * 100;
}
return { xs, cut, pulp };
}
/** 帯そのもののクリップ。上端・下端それぞれ、裂け線があればそれで切り、無ければ直線で閉じる */
function bandClip(top: Line | null, bottom: Line | null): string {
const parts: string[] = [];
if (top) {
for (let i = 0; i < top.xs.length; i++) parts.push(`${top.xs[i]}% ${top.cut[i]}px`);
} else {
parts.push("0% 0%", "100% 0%");
}
if (bottom) {
for (let i = bottom.xs.length - 1; i >= 0; i--) {
parts.push(`${bottom.xs[i]}% calc(100% - ${bottom.cut[i]}px)`);
}
} else {
parts.push("100% 100%", "0% 100%");
}
return `polygon(${parts.join(", ")})`;
}
/** 上端の繊維片: 繊維線より上を残す。親の裂け目クリップとの差分が「めくれ」として見える */
function pulpClipTop(line: Line): string {
const parts = ["0% 0%", "100% 0%"];
for (let i = line.xs.length - 1; i >= 0; i--) parts.push(`${line.xs[i]}% ${line.pulp[i]}px`);
return `polygon(${parts.join(", ")})`;
}
/** 下端の繊維片: 繊維線より下を残す */
function pulpClipBottom(line: Line): string {
const parts: string[] = [];
for (let i = 0; i < line.xs.length; i++) {
parts.push(`${line.xs[i]}% calc(100% - ${line.pulp[i]}px)`);
}
parts.push("100% 100%", "0% 100%");
return `polygon(${parts.join(", ")})`;
}
/* ------------------------------------------------------------------ *
* 内容
* ------------------------------------------------------------------ */
/** 上の帯へ乗り上げる裂け目(浅い・細かい) */
const EDGE_TOP: Omit<TearSpec, "seed"> = { steps: 34, depth: 30, bite: 0.12 };
/** 下の帯へ覆いかぶさる裂け目(深い・粗い) */
const EDGE_BOTTOM: Omit<TearSpec, "seed"> = { steps: 26, depth: 46, bite: 0.26 };
const LAYERS: { term: string; body: string; param: string }[] = [
{
term: "うねり",
body: "低周波の正弦を2本重ね、断面全体のゆるやかな高低を作る。ここだけでは波線と変わらない。",
param: "f 1.1–4.5",
},
{
term: "ささくれ",
body: "1頂点ごとの微小な乱れ。振幅を裂け幅の1/4に抑えるのが要点で、上げるとノコギリに見える。",
param: "±26%",
},
{
term: "食い込み",
body: "十数点に1度だけ深く落ちる点を混ぜる。この不規則さが「反復する模様」への収束を断つ。",
param: "p 0.12–0.26",
},
];
export default function TornEdgeDivider() {
const [seed, setSeed] = useState(1042);
const { topLine, bottomLine } = useMemo(
() => ({
topLine: tearLine({ ...EDGE_TOP, seed: seed * 733 + 17 }),
bottomLine: tearLine({ ...EDGE_BOTTOM, seed: seed * 1097 + 251 }),
}),
[seed],
);
const vertices = EDGE_TOP.steps + EDGE_BOTTOM.steps + 2;
return (
<section aria-labelledby="ted-heading" className="ted w-full max-w-4xl bg-white">
<style>{`
.ted-sheet { transition: clip-path 640ms cubic-bezier(0.22, 0.61, 0.36, 1); }
@media (prefers-reduced-motion: reduce) { .ted-sheet { transition: none; } }
`}</style>
{/* ── 帯1: 紙(白) ────────────────────────────── */}
<div className="px-6 pt-7 pb-16 sm:px-10 sm:pt-8 sm:pb-20">
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-3 border-b border-line pb-3">
<p className="font-display text-[11px] font-semibold tracking-wide text-ink-soft">
BLK-61
<span aria-hidden className="px-2 text-line">
/
</span>
<span className="font-sans">セクション区切り</span>
<span aria-hidden className="px-2 text-line">
/
</span>
<span className="font-sans">頂点 </span>
<span className="tabular-nums">{vertices}</span>
</p>
<button
type="button"
onClick={() => setSeed((s) => (s * 1103515245 + 12345) % 2147483647)}
className="group inline-flex items-center gap-2 border border-line px-3 py-1.5 text-[11px] font-semibold text-ink-soft transition-colors hover:border-brand hover:text-brand-deep focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
>
もう一度裂く
<span className="font-display tabular-nums text-ink-soft/60 transition-colors group-hover:text-brand">
#{String(seed).padStart(4, "0").slice(-4)}
</span>
</button>
</div>
<h2
id="ted-heading"
className="mt-8 max-w-2xl text-[clamp(1.6rem,3.8vw,2.3rem)] leading-[1.34] font-bold tracking-tight text-ink"
>
直線で割らず、
<span className="relative whitespace-nowrap">
紙を裂いて
<span
aria-hidden
className="absolute -bottom-1 left-0 h-[3px] w-full bg-brand/60"
/>
</span>
節を変える
</h2>
<p className="mt-6 max-w-xl text-[15px] leading-[1.95] font-medium text-ink-soft">
境目を1本の直線や波線で引くと、どれだけ滑らかにしても図形に見える。頂点を30前後まで増やし、
うねり・ささくれ・食い込みの3層を重ねると、同じ clip-path
ひとつで手で裂いた断面に近づく。
</p>
</div>
{/* ── 帯2: 生成り。上端は乗り上げ、下端は覆いかぶさる ── */}
<div
className="relative z-10 [filter:drop-shadow(0_0_1px_rgba(34,30,25,0.13))_drop-shadow(0_4px_5px_rgba(34,30,25,0.10))]"
style={{ marginTop: -EDGE_TOP.depth }}
>
<div
className="ted-sheet relative bg-[#f2ece0] px-6 sm:px-10"
style={{
clipPath: bandClip(topLine, bottomLine),
paddingTop: EDGE_TOP.depth + 44,
paddingBottom: EDGE_BOTTOM.depth + 44,
}}
>
{/* 繊維の縁(めくれた紙の内側)。親の裂け目クリップとの差分だけが見える */}
<span
aria-hidden
className="ted-sheet pointer-events-none absolute inset-x-0 top-0 block bg-[#fffdf8]"
style={{ height: EDGE_TOP.depth + 16, clipPath: pulpClipTop(topLine) }}
/>
<span
aria-hidden
className="ted-sheet pointer-events-none absolute inset-x-0 bottom-0 block bg-[#fffdf8]"
style={{ height: EDGE_BOTTOM.depth + 16, clipPath: pulpClipBottom(bottomLine) }}
/>
{/* 紙の目 */}
<span
aria-hidden
className="pointer-events-none absolute inset-0 block bg-[repeating-linear-gradient(48deg,rgba(34,30,25,0.035)_0_1px,transparent_1px_5px)]"
/>
<div className="relative">
<h3 className="text-[13px] font-bold tracking-wide text-ink">裂け目を作る3層</h3>
<ol className="mt-5 list-none">
{LAYERS.map((l, i) => (
<li
key={l.term}
className="grid grid-cols-[auto_1fr] items-baseline gap-x-4 border-t border-[#ddd3c3] py-5 sm:grid-cols-[auto_7rem_1fr_auto] sm:gap-x-6"
>
<span className="font-display text-[11px] font-bold tabular-nums text-brand-deep">
0{i + 1}
</span>
<h4 className="text-[15px] font-bold tracking-tight text-ink">{l.term}</h4>
<p className="col-span-2 mt-2 text-[13px] leading-[1.95] text-ink-soft sm:col-span-1 sm:mt-0">
{l.body}
</p>
<span
aria-hidden
className="col-span-2 mt-2 font-display text-[11px] tabular-nums text-ink-soft/70 sm:col-span-1 sm:mt-0 sm:text-right"
>
{l.param}
</span>
</li>
))}
</ol>
</div>
</div>
</div>
{/* ── 帯3: 墨。帯2の裂け目の下へ潜り込む ────────── */}
<div
className="relative z-0 bg-ink px-6 sm:px-10"
style={{ marginTop: -EDGE_BOTTOM.depth, paddingTop: EDGE_BOTTOM.depth + 44 }}
>
<span
aria-hidden
className="pointer-events-none absolute inset-0 block bg-[repeating-linear-gradient(48deg,rgba(255,255,255,0.035)_0_1px,transparent_1px_5px)]"
/>
<div className="relative pb-14 sm:pb-16">
<p className="max-w-xl text-[15px] leading-[1.95] font-medium text-white/72">
区切りは節と節の関係そのものを決める。裂け目を浅くすれば続きもの、深くして色面を反転させれば
切断になる。同じ生成ロジックのまま、裂け幅と食い込みの確率だけで言い分けられる。
</p>
<p className="mt-7 border-t border-white/12 pt-6">
<a
href="#"
className="text-[13px] font-semibold text-white underline decoration-brand decoration-2 underline-offset-[6px] transition-colors hover:text-brand focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-brand"
>
裂け幅と頂点数の調整メモ
</a>
</p>
</div>
</div>
</section>
);
}
shadcn CLI でプロジェクトに追加
npx shadcn@latest add https://designs.first-ch.com/r/torn-edge-divider.json