BLK-98Blocks

セクション区切りの形見本(波・斜め・弧・段差を切り替えて比べる)

同じ2つの節の境目に、波・斜め・弧・段差・ジグザグの5形状のセクション区切りを当てて見比べるブロックです。境目の高さ・山や段の数・上下と左右の反転・下の節の色をスライダーやチェックボックスで変えると、SVG の path がその場で組み直されます。決めた形は背景色まで書き込んだ SVG としてコピーでき、2つの節の間に1枚挟むだけで使えます。形のタブは矢印キーで切り替えられ、操作はキーボードだけで完結します。形を切り替えたときだけ境目から立ち上がる動きを付け、prefers-reduced-motion では止めます。依存ライブラリと画像素材はありません。角丸・英字の小見出し・末尾の矢印リンクは使わず、hairline 罫線と余白で組んでいます。

追加日:
2026-10-14
依存:
なし
tags
#divider #section #svg #path #wave #shape #generator #copy #keyboard #tabs #no-image

プレビュー

BLK-98セクション区切り形 5 種

節の境目を5つの形で見比べて選ぶ

同じ2つの節の間に、波・斜め・弧・段差・ジグザグの区切りを当てて比べられます。高さや向きを変えるとその場で形が組み直され、決めた形は SVG のコードとしてそのまま貼り付けられます。

私たちの仕事

Webサイトの制作から公開後の更新まで、同じ担当者が続けて対応します。

ご相談の流れ

お問い合わせの内容を確認し、2営業日以内に担当者からご連絡します。

波 柔らかい印象になります。暮らし・飲食・子ども向けの話題の節に合います。

80px
3山
向き

下の節が上へせり上がる形です。

下の節の色

波のSVG311 bytes

<svg viewBox="0 0 1200 80" preserveAspectRatio="none" width="100%" height="80" aria-hidden="true" style="display:block;background:#ffffff">
  <path d="M0 80C100 80 100 0 200 0C300 0 300 80 400 80C500 80 500 0 600 0C700 0 700 80 800 80C900 80 900 0 1000 0C1100 0 1100 80 1200 80L1200 80Z" fill="#fbeedd"/>
</svg>

2つの節の間にこの1枚を挟むと区切りになります。背景色も svg に書いてあるので、外側の指定は要りません。

"use client";

import { useId, useMemo, useRef, useState, type KeyboardEvent } from "react";

/**
 * セクション区切りの形見本(BLK-98)
 *
 * 差別化: torn-edge-divider は破れ紙1種の質感を見せる作品で、本ブロックは実務でよく使う幾何形状5種を
 * 同じ2つの節の間で切り替えて比べ、高さ・数・向き・色を決めて SVG を持ち帰る「選ぶ道具」。
 * 英字の小見出し・句点止めの見出し・末尾の矢印リンク・角丸・スクロールでの出現アニメは置かない。
 *
 * 汎用技法メモ:
 * 1. **形は命令の配列で組み、最後に座標変換して文字列にする**。["M",x,y] / ["L",x,y] / ["C",x1,y1,x2,y2,x,y] / ["Q",…] / ["Z"]
 *    の配列を作り、左右反転は x→W−x、上下反転は y→H−y を全座標へ掛けるだけで済む。閉じる角 (0,H)(W,H) も一緒に
 *    (0,0)(W,0) へ移るので、反転すると「下の節がせり上がる形」が「上の節が垂れ下がる形」に入れ替わる。
 * 2. **viewBox の横幅は 1200 固定、縦は高さ(px)そのまま**にして preserveAspectRatio="none" と height="<px>" で置く。
 *    画面幅が変わっても境目の高さは一定のまま横だけ伸び縮みする。
 * 3. **波は山と谷を交互に通る3次ベジェ**。区間の両端で接線を水平にする制御点(区間幅の半分ずつ内側)で sin に近い形になる。
 *    弧は2次ベジェの制御点を y=−H に置くと頂点がちょうど y=0 に来る((H + 2c + H) / 4 = 0)。
 * 4. **持ち帰る SVG は背景色まで自己完結させる**。svg 要素の style に display:block と上の節の色(反転時は下の節の色)を
 *    書いておけば、節と節の間に1枚挟むだけで区切りになる。
 * 5. 形を切り替えたときだけ、新しい区切りを境目の線から scaleY と opacity で立ち上げる(key を替えて再生)。
 *    prefers-reduced-motion では再生しない。スライダーの変更は即座に組み直す。
 *
 * 文言・配色はすべて First CH のオリジナル。
 */

type ShapeId = "wave" | "slant" | "arc" | "step" | "zigzag";

type Cmd =
  | ["M" | "L", number, number]
  | ["Q", number, number, number, number]
  | ["C", number, number, number, number, number, number]
  | ["Z"];

const VW = 1200;

const SHAPES: {
  id: ShapeId;
  label: string;
  count?: { min: number; max: number; init: number; unit: string };
  mirror: boolean;
  note: string;
}[] = [
  {
    id: "wave",
    label: "波",
    count: { min: 1, max: 8, init: 3, unit: "山" },
    mirror: true,
    note: "柔らかい印象になります。暮らし・飲食・子ども向けの話題の節に合います。",
  },
  {
    id: "slant",
    label: "斜め",
    mirror: true,
    note: "流れと勢いが出ます。サービス紹介や実績の数字に入る前の節に向いています。",
  },
  {
    id: "arc",
    label: "弧",
    mirror: false,
    note: "上の節をひとまとまりに見せます。ファーストビューの下端によく使われます。",
  },
  {
    id: "step",
    label: "段差",
    count: { min: 2, max: 8, init: 4, unit: "段" },
    mirror: true,
    note: "順序や積み上げを感じさせます。制作の流れや料金の節の前に置くと内容とそろいます。",
  },
  {
    id: "zigzag",
    label: "ジグザグ",
    count: { min: 2, max: 24, init: 10, unit: "山" },
    mirror: true,
    note: "にぎやかで気軽な印象です。お知らせやキャンペーンの帯に向いています。",
  },
];

const COLORS = [
  { id: "cream", label: "生成り", hex: "#f2ece0", ink: "#221e19" },
  { id: "amber", label: "淡いアンバー", hex: "#fbeedd", ink: "#221e19" },
  { id: "ink", label: "墨", hex: "#221e19", ink: "#ffffff" },
] as const;

const UPPER = "#ffffff";

/** 下の節の色の領域(境目より下)を、反転前の向きで組む */
function shapeCmds(id: ShapeId, w: number, h: number, n: number): Cmd[] {
  switch (id) {
    case "wave": {
      const seg = w / (n * 2);
      const out: Cmd[] = [["M", 0, h]];
      for (let i = 0; i < n * 2; i++) {
        const x0 = i * seg;
        const x1 = x0 + seg;
        const y0 = i % 2 === 0 ? h : 0;
        const y1 = i % 2 === 0 ? 0 : h;
        out.push(["C", x0 + seg / 2, y0, x1 - seg / 2, y1, x1, y1]);
      }
      out.push(["L", w, h], ["Z"]);
      return out;
    }
    case "slant":
      return [["M", 0, h], ["L", w, 0], ["L", w, h], ["Z"]];
    case "arc":
      return [["M", 0, h], ["Q", w / 2, -h, w, h], ["Z"]];
    case "step": {
      const out: Cmd[] = [["M", 0, h]];
      for (let k = 0; k < n; k++) {
        const y = h - ((k + 1) * h) / n;
        out.push(["L", (k * w) / n, y], ["L", ((k + 1) * w) / n, y]);
      }
      out.push(["L", w, h], ["Z"]);
      return out;
    }
    case "zigzag": {
      const seg = w / (n * 2);
      const out: Cmd[] = [["M", 0, h]];
      for (let i = 0; i <= n * 2; i++)
        out.push(["L", i * seg, i % 2 === 0 ? h : 0]);
      out.push(["Z"]);
      return out;
    }
  }
}

const num = (v: number) => String(Math.round(v * 10) / 10);

function toPath(
  cmds: Cmd[],
  w: number,
  h: number,
  mirror: boolean,
  flip: boolean,
): string {
  const tx = (x: number) => (mirror ? w - x : x);
  const ty = (y: number) => (flip ? h - y : y);
  return cmds
    .map((c) => {
      if (c[0] === "Z") return "Z";
      const [op, ...v] = c;
      const pts: string[] = [];
      for (let i = 0; i < v.length; i += 2)
        pts.push(`${num(tx(v[i]))} ${num(ty(v[i + 1]))}`);
      return `${op}${pts.join(" ")}`;
    })
    .join("");
}

const focusRing =
  "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand";

export default function SectionDividerShapes() {
  const uid = useId();
  const [shape, setShape] = useState<ShapeId>("wave");
  const [height, setHeight] = useState(80);
  const [counts, setCounts] = useState<Record<string, number>>(() =>
    Object.fromEntries(
      SHAPES.filter((s) => s.count).map((s) => [s.id, s.count!.init]),
    ),
  );
  const [mirror, setMirror] = useState(false);
  const [flip, setFlip] = useState(false);
  const [colorId, setColorId] =
    useState<(typeof COLORS)[number]["id"]>("amber");
  const [copied, setCopied] = useState<"" | "ok" | "select">("");
  const [play, setPlay] = useState(0);
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const codeRef = useRef<HTMLPreElement>(null);

  const spec = SHAPES.find((s) => s.id === shape)!;
  const color = COLORS.find((c) => c.id === colorId)!;
  const n = spec.count ? counts[shape] : 1;
  const useMirror = spec.mirror && mirror;

  const d = useMemo(
    () => toPath(shapeCmds(shape, VW, height, n), VW, height, useMirror, flip),
    [shape, height, n, useMirror, flip],
  );
  // 反転時は上の節が垂れ下がる: 帯の地は下の節の色、形は上の節の色
  const bandBg = flip ? color.hex : UPPER;
  const fill = flip ? UPPER : color.hex;
  const code = `<svg viewBox="0 0 ${VW} ${height}" preserveAspectRatio="none" width="100%" height="${height}" aria-hidden="true" style="display:block;background:${bandBg}">\n  <path d="${d}" fill="${fill}"/>\n</svg>`;
  const bytes = new TextEncoder().encode(code).length;

  const pick = (i: number) => {
    const next = SHAPES[(i + SHAPES.length) % SHAPES.length];
    setShape(next.id);
    setPlay((p) => p + 1);
    setCopied("");
    return (i + SHAPES.length) % SHAPES.length;
  };

  const onTabKey = (e: KeyboardEvent<HTMLButtonElement>, i: number) => {
    let to: number | null = null;
    if (e.key === "ArrowRight" || e.key === "ArrowDown") to = i + 1;
    else if (e.key === "ArrowLeft" || e.key === "ArrowUp") to = i - 1;
    else if (e.key === "Home") to = 0;
    else if (e.key === "End") to = SHAPES.length - 1;
    if (to === null) return;
    e.preventDefault();
    const at = pick(to);
    tabRefs.current[at]?.focus();
  };

  const copy = async () => {
    try {
      await navigator.clipboard.writeText(code);
      setCopied("ok");
    } catch {
      // クリップボードが使えない環境ではコードを選択状態にして手動コピーに任せる
      const el = codeRef.current;
      const sel = window.getSelection();
      if (el && sel) {
        const r = document.createRange();
        r.selectNodeContents(el);
        sel.removeAllRanges();
        sel.addRange(r);
      }
      setCopied("select");
    }
  };

  const change = () => setCopied("");

  return (
    <section
      aria-labelledby={`${uid}-h`}
      className="@container w-full bg-white font-sans text-ink"
    >
      <style>{`
        @keyframes sds-rise { from { transform: scaleY(0.15); opacity: 0; } to { transform: scaleY(1); opacity: 1; } }
        .sds-rise { animation: sds-rise 420ms cubic-bezier(0.22, 0.61, 0.36, 1) both; }
        @media (prefers-reduced-motion: reduce) { .sds-rise { animation: none; } }
      `}</style>

      <div className="px-4 pt-6 pb-5 @2xl:px-10 @2xl:pt-8">
        <p className="flex flex-wrap items-center gap-x-2 border-b border-line pb-3 font-display text-[11px] font-semibold tracking-wide text-ink-soft">
          <span>BLK-98</span>
          <span aria-hidden="true" className="text-line">
            /
          </span>
          <span className="font-sans">セクション区切り</span>
          <span aria-hidden="true" className="text-line">
            /
          </span>
          <span className="font-sans">
            形 <span className="font-display tabular-nums">5</span> 種
          </span>
        </p>
        <h2
          id={`${uid}-h`}
          className="mt-6 text-[clamp(1.35rem,3.6vw,2.1rem)] leading-[1.4] font-bold tracking-tight"
        >
          節の境目を
          <span className="relative whitespace-nowrap">
            5つの形
            <span
              aria-hidden="true"
              className="absolute -bottom-0.5 left-0 h-[3px] w-full bg-brand/60"
            />
          </span>
          <span className="whitespace-nowrap">で見比べて選ぶ</span>
        </h2>
        <p className="mt-4 max-w-2xl text-[14px] leading-[1.9] font-medium text-ink-soft @2xl:text-[15px]">
          同じ2つの節の間に、波・斜め・弧・段差・ジグザグの区切りを当てて比べられます。高さや向きを変えるとその場で形が組み直され、決めた形は
          SVG のコードとしてそのまま貼り付けられます。
        </p>
      </div>

      {/* 形の切り替え */}
      <div
        role="tablist"
        aria-label="区切りの形"
        className="grid grid-cols-5 border-y border-line px-4 @2xl:px-10"
      >
        {SHAPES.map((s, i) => {
          const on = s.id === shape;
          const icon = toPath(
            shapeCmds(
              s.id,
              48,
              14,
              s.id === "zigzag" ? 4 : s.id === "wave" ? 2 : 3,
            ),
            48,
            14,
            false,
            false,
          );
          return (
            <button
              key={s.id}
              ref={(el) => {
                tabRefs.current[i] = el;
              }}
              type="button"
              role="tab"
              id={`${uid}-tab-${s.id}`}
              aria-selected={on}
              aria-controls={`${uid}-panel`}
              tabIndex={on ? 0 : -1}
              onClick={() => pick(i)}
              onKeyDown={(e) => onTabKey(e, i)}
              className={`group relative flex flex-col items-center gap-1.5 py-3 text-[12px] font-bold transition-colors @md:text-[13px] ${on ? "text-ink" : "text-ink-soft hover:text-ink"} focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-brand`}
            >
              <svg
                viewBox="0 -1 48 16"
                className="h-3.5 w-10 @md:w-12"
                aria-hidden="true"
              >
                <path
                  d={icon}
                  fill={on ? "#d97706" : "#d6cfc4"}
                  className="transition-colors"
                />
              </svg>
              <span>{s.label}</span>
              <span
                aria-hidden="true"
                className={`absolute inset-x-2 -bottom-px h-[2px] origin-center bg-brand transition-transform duration-200 motion-reduce:transition-none ${on ? "scale-x-100" : "scale-x-0"}`}
              />
            </button>
          );
        })}
      </div>

      <div className="@3xl:grid @3xl:grid-cols-[minmax(0,1fr)_17rem]">
        {/* 見本: 同じ2つの節の間に区切りを当てる */}
        <div
          role="tabpanel"
          id={`${uid}-panel`}
          aria-labelledby={`${uid}-tab-${shape}`}
          className="border-b border-line bg-stage px-4 py-5 @2xl:px-10 @3xl:border-r @3xl:border-b-0"
        >
          <div className="border border-line">
            <div className="bg-white px-4 pt-5 pb-6 @md:px-7">
              <p className="text-[15px] font-bold tracking-tight">
                私たちの仕事
              </p>
              <p className="mt-2 text-[13px] leading-[1.85] text-ink-soft">
                Webサイトの制作から公開後の更新まで、同じ担当者が続けて対応します。
              </p>
            </div>
            <div
              aria-hidden="true"
              className="relative"
              style={{ height, background: bandBg }}
            >
              <svg
                key={play}
                viewBox={`0 0 ${VW} ${height}`}
                preserveAspectRatio="none"
                className={`absolute inset-0 block h-full w-full ${play ? "sds-rise" : ""}`}
                style={{ transformOrigin: flip ? "50% 0%" : "50% 100%" }}
              >
                <path d={d} fill={fill} />
              </svg>
              <span className="absolute top-0 right-2 bottom-0 flex items-center gap-1.5">
                <span
                  className="font-display text-[10px] font-semibold tabular-nums"
                  style={{ color: flip ? color.ink : "#756e63" }}
                >
                  {height}px
                </span>
                <span className="relative h-full w-px bg-brand">
                  <span className="absolute -left-1 top-0 h-px w-[9px] bg-brand" />
                  <span className="absolute -left-1 bottom-0 h-px w-[9px] bg-brand" />
                </span>
              </span>
            </div>
            <div
              className="px-4 pt-5 pb-6 @md:px-7"
              style={{ background: color.hex, color: color.ink }}
            >
              <p className="text-[15px] font-bold tracking-tight">
                ご相談の流れ
              </p>
              <p className="mt-2 text-[13px] leading-[1.85] opacity-75">
                お問い合わせの内容を確認し、2営業日以内に担当者からご連絡します。
              </p>
            </div>
          </div>
          <p
            className="mt-3 text-[12.5px] leading-[1.8] text-ink-soft"
            aria-live="polite"
          >
            <span className="font-bold text-ink">{spec.label}</span> 
            {spec.note}
          </p>
        </div>

        {/* 調整 */}
        <div className="divide-y divide-line border-b border-line px-4 @2xl:px-10 @3xl:border-b-0 @3xl:px-6">
          <div className="py-4">
            <div className="flex items-baseline justify-between">
              <label
                htmlFor={`${uid}-h-range`}
                className="text-[12.5px] font-bold"
              >
                境目の高さ
              </label>
              <output
                htmlFor={`${uid}-h-range`}
                className="font-display text-[12px] font-semibold tabular-nums text-brand-deep"
              >
                {height}px
              </output>
            </div>
            <input
              id={`${uid}-h-range`}
              type="range"
              min={16}
              max={160}
              step={4}
              value={height}
              onChange={(e) => {
                setHeight(Number(e.target.value));
                change();
              }}
              className={`mt-2 w-full accent-brand ${focusRing}`}
            />
          </div>

          <div className="py-4">
            <div className="flex items-baseline justify-between">
              <label
                htmlFor={`${uid}-n-range`}
                className={`text-[12.5px] font-bold ${spec.count ? "" : "text-ink-soft"}`}
              >
                {spec.count?.unit === "段" ? "段の数" : "山の数"}
              </label>
              <output
                htmlFor={`${uid}-n-range`}
                className="font-display text-[12px] font-semibold tabular-nums text-brand-deep"
              >
                {spec.count ? (
                  <>
                    {n}
                    <span className="font-sans">{spec.count.unit}</span>
                  </>
                ) : (
                  <span className="font-sans text-ink-soft">
                    この形にはありません
                  </span>
                )}
              </output>
            </div>
            <input
              id={`${uid}-n-range`}
              type="range"
              min={spec.count?.min ?? 1}
              max={spec.count?.max ?? 1}
              step={1}
              value={n}
              disabled={!spec.count}
              onChange={(e) => {
                setCounts((c) => ({ ...c, [shape]: Number(e.target.value) }));
                change();
              }}
              className={`mt-2 w-full accent-brand disabled:opacity-40 ${focusRing}`}
            />
          </div>

          <div className="py-4">
            <fieldset>
              <legend className="text-[12.5px] font-bold">向き</legend>
              <div className="mt-2.5 flex flex-wrap gap-x-5 gap-y-2 text-[12.5px]">
                <label className="flex items-center gap-2">
                  <input
                    type="checkbox"
                    checked={flip}
                    onChange={(e) => {
                      setFlip(e.target.checked);
                      change();
                    }}
                    className={`h-4 w-4 accent-brand ${focusRing}`}
                  />
                  上下反転
                </label>
                <label
                  className={`flex items-center gap-2 ${spec.mirror ? "" : "text-ink-soft/60"}`}
                >
                  <input
                    type="checkbox"
                    checked={useMirror}
                    disabled={!spec.mirror}
                    onChange={(e) => {
                      setMirror(e.target.checked);
                      change();
                    }}
                    className={`h-4 w-4 accent-brand ${focusRing}`}
                  />
                  左右反転
                </label>
              </div>
              <p className="mt-2 text-[11.5px] leading-[1.7] text-ink-soft">
                {flip
                  ? "上の節が下へ垂れ下がる形です。"
                  : "下の節が上へせり上がる形です。"}
              </p>
            </fieldset>
          </div>

          <div className="py-4">
            <fieldset>
              <legend className="text-[12.5px] font-bold">下の節の色</legend>
              <div className="mt-2.5 flex flex-wrap gap-x-4 gap-y-2 text-[12.5px]">
                {COLORS.map((c) => (
                  <label key={c.id} className="flex items-center gap-2">
                    <input
                      type="radio"
                      name={`${uid}-color`}
                      value={c.id}
                      checked={c.id === colorId}
                      onChange={() => {
                        setColorId(c.id);
                        change();
                      }}
                      className={`h-4 w-4 accent-brand ${focusRing}`}
                    />
                    <span
                      aria-hidden="true"
                      className="h-3 w-3 border border-line"
                      style={{ background: c.hex }}
                    />
                    {c.label}
                  </label>
                ))}
              </div>
            </fieldset>
          </div>
        </div>
      </div>

      {/* 持ち帰るコード */}
      <div className="px-4 py-5 @2xl:px-10">
        <div className="flex flex-wrap items-center justify-between gap-3">
          <p className="text-[12.5px] font-bold">
            {spec.label}のSVG
            <span className="ml-3 font-display text-[11px] font-semibold tabular-nums text-ink-soft">
              {bytes} bytes
            </span>
          </p>
          <div className="flex items-center gap-3">
            <span role="status" className="text-[12px] text-brand-deep">
              {copied === "ok"
                ? "コピーしました"
                : copied === "select"
                  ? "コードを選択しました。手動でコピーしてください"
                  : ""}
            </span>
            <button
              type="button"
              onClick={copy}
              className={`border border-line bg-white px-3.5 py-1.5 text-[12px] font-bold text-ink transition-colors hover:border-brand hover:text-brand-deep ${focusRing}`}
            >
              SVGをコピー
            </button>
          </div>
        </div>
        <pre
          ref={codeRef}
          tabIndex={0}
          aria-label={`${spec.label}の区切りのSVGコード`}
          className={`mt-3 max-h-40 overflow-auto bg-code-bg px-4 py-3 font-mono text-[11.5px] leading-[1.7] whitespace-pre-wrap break-all text-[#f3efe8] ${focusRing}`}
        >
          {code}
        </pre>
        <p className="mt-2 text-[11.5px] leading-[1.7] text-ink-soft">
          2つの節の間にこの1枚を挟むと区切りになります。背景色も svg
          に書いてあるので、外側の指定は要りません。
        </p>
      </div>
    </section>
  );
}

shadcn CLI でプロジェクトに追加

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