First CH Designs
BLK-61Blocks

Torn Paper Section Divider

A section divider whose subject is the boundary itself. A single clip-path polygon with 60-odd irregular vertices separates three color planes — paper white, unbleached warm, and ink — along a hand-torn edge. The tear is composed of three layers (a low-frequency swell from two sines, per-vertex fray, and an occasional deep bite) over unevenly spaced x steps, with a second line a few pixels inside painted lighter to read as paper fiber. The PRNG is seeded so server and client render the same shape, and the vertex count is fixed so re-tearing morphs one polygon into the next. No image assets, no dependencies.

Added:
2026-09-15
Dependencies:
None
tags
#divider #section #clip-path #polygon #texture #generative #no-image

Preview

BLK-61セクション区切り頂点 62

直線で割らず、紙を裂いて節を変える

境目を1本の直線や波線で引くと、どれだけ滑らかにしても図形に見える。頂点を30前後まで増やし、 うねり・ささくれ・食い込みの3層を重ねると、同じ clip-path ひとつで手で裂いた断面に近づく。

裂け目を作る3層

  1. 01

    うねり

    低周波の正弦を2本重ね、断面全体のゆるやかな高低を作る。ここだけでは波線と変わらない。

  2. 02

    ささくれ

    1頂点ごとの微小な乱れ。振幅を裂け幅の1/4に抑えるのが要点で、上げるとノコギリに見える。

  3. 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>
  );
}

Add to your project via shadcn CLI

npx shadcn@latest add https://designs.first-ch.com/r/torn-edge-divider.json