Section Divider Shape Picker (compare wave, slant, arc, steps and zigzag)
A block for comparing section dividers on the same pair of sections: wave, slant, arc, steps and zigzag. Sliders and checkboxes change the divider height, the number of crests or steps, vertical and horizontal flips, and the color of the lower section, and the SVG path is rebuilt on the spot. The chosen shape copies out as a self-contained SVG with its background color included, so you can drop it between two sections as is. The shape tabs switch with the arrow keys and everything works from the keyboard. Only switching shapes plays a short rise from the boundary line, and it stays still for visitors who prefer reduced motion. No dependencies or image assets, no rounded corners, no small English kickers or trailing arrow links, just hairline rules and whitespace.
- Added:
- 2026-10-14
- Dependencies:
- None
- tags
- #divider #section #svg #path #wave #shape #generator #copy #keyboard #tabs #no-image
Preview
BLK-98セクション区切り形 5 種
節の境目を5つの形で見比べて選ぶ
同じ2つの節の間に、波・斜め・弧・段差・ジグザグの区切りを当てて比べられます。高さや向きを変えるとその場で形が組み直され、決めた形は SVG のコードとしてそのまま貼り付けられます。
私たちの仕事
Webサイトの制作から公開後の更新まで、同じ担当者が続けて対応します。
ご相談の流れ
お問い合わせの内容を確認し、2営業日以内に担当者からご連絡します。
波 柔らかい印象になります。暮らし・飲食・子ども向けの話題の節に合います。
波の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>
);
}
Add to your project via shadcn CLI
npx shadcn@latest add https://designs.first-ch.com/r/section-divider-shapes.json