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営業日以内に担当者からご連絡します。
波 柔らかい印象になります。暮らし・飲食・子ども向けの話題の節に合います。
波の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