BLK-80Blocks
構成ツリー・ノード図(節点を開いて確かめる階層)
サイトやシステムの構成を、節点と接続線の図で示すブロック。節点を押すと直下の項目が親の位置から生え、残りの節点は間隔を取り直して押し出され、接続線がその場で引き直される——図そのものが操作で組み替わる。開く枝は1本ずつで、別の枝を押すと前の枝は親へ吸い込まれて畳まれるので、節点の間隔が箱の高さを割り込まない。選んだ節点は根まで系統が灯り、右の明細に役割・直下の件数・配下のページ数・全体比が出るので、構成の提案や引き継ぎにそのまま使える。作図はSVGを viewBox 1000×実高さ+preserveAspectRatio:none で置き、縦はpx・横は千分率で持つので幅の実測が要らない。GSAPは素のオブジェクト配列だけを動かし、節点のtransformと接続線のd属性は毎フレーム自分で描くため線が節点から剥がれない。出現はScrollTriggerで根→枝→葉の順、狭い器では同じ配列を入れ子の開閉リストへ組み替える。画像素材ゼロ。
- 追加日:
- 2026-09-26
- 依存:
- gsap
- tags
- #diagram #tree #hierarchy #information-architecture #svg #gsap #scrolltrigger #interactive #container-query #section #brand #no-image #reduced-motion
プレビュー
どの面が何のために要るのか
節点をひらいて確かめる
- 総ページ数
- 24
- 末端の面
- 13
- サービス一覧領域の見取り図1P
- サービス詳細範囲と進め方4P
- 実績一覧事例の索引1P
- 実績詳細課題から結果まで6P
ページ数は公開時点の想定値です。節点を押すと直下の項目が開き、図は位置を取り直します(開く枝は1本ずつ)
構成案の作り方を見る"use client";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { Montserrat } from "next/font/google";
const montserrat = Montserrat({
weight: ["500", "600", "700"],
subsets: ["latin"],
display: "swap",
});
/**
* 構成ツリー・ノード図(節点を開いて確かめる階層)
* 既存の図解ブロックは配分(帯の太さ=量)を見せる固定レイアウトのサンキー型で、図の形は
* 最初から最後まで変わらない。本ブロックが示すのは量ではなく**関係と階層**で、節点を押すと
* 直下の項目が生え、残りの節点は押し出されて位置を取り直し、接続線がその場で引き直される
* ——図そのものが操作で組み替わるのが主張。選んだ節点は根まで系統が灯り、右の明細に
* 役割・直下の件数・配下のページ数が出るので、構成の提案や引き継ぎ資料にそのまま使える。
* 飽和idiomは英字キッカーと末尾の「→」リンクを外し、角丸も一切使わない(直線と罫線の作図に寄せる)。
* 掲載している構成・名称・ページ数はすべて架空のサンプル。画像素材ゼロ。
*
* 汎用技法メモ(web-design-playbook 還流用):
* - **縦はpx・横は‰で持つと、可変幅のノード図が測定なしで書ける**: SVGを
* `viewBox="0 0 1000 <実高さ>"` + `preserveAspectRatio="none"` で置き、高さだけ実px固定にすると、
* 縦倍率は常に1のままで横だけが器に追従する。y座標はHTML側の translateY(px) とそのまま共有でき、
* x座標は器の幅に対する千分率として書ける。ResizeObserverもgetBoundingClientRectも要らない。
* 線の太さが横倍率で潰れるのは `vector-effect="non-scaling-stroke"` で消える。
* - **アニメーションはDOMではなくプレーンなオブジェクトへ掛ける**: GSAPに要素を直接触らせると、
* 位置(transform)と表示(opacity)と接続線(d属性)の3系統を同期させる術がない。`{id,y,g}` の
* 素の配列をtweenし、onUpdateで「節点のtransform → 接続線のd」の順に**毎フレーム自分で描く**と、
* 線が節点から遅れて剥がれることが構造的に起きない(GSAPはDOMを一切知らないままでよい)。
* - **開閉は「生える/吸い込まれる」を1つの進捗で表す**: 節点ごとの g(0→1)を、透明度と
* 接続線の終端位置の両方へ掛ける。g=0 では線が親の位置に畳まれ、子も親に重なっているので、
* 閉じる操作が「親へ吸い込まれる」動きになる。線の描画にstroke-dasharrayは要らない。
* - **節点の間隔は葉の数から逆算する**: `slot = min(最大間隔, 使える高さ / (葉の数 - 1))` とすると、
* 畳んだ状態では余白いっぱいに広がり、開くと自動で詰まる。図の外枠の高さが跳ねないので、
* 開閉しても後続セクションが動かない(節点の増減でページが伸び縮みする図はレイアウトを壊す)。
* ただし**間隔が箱の高さを下回ると節点どうしが重なる**ので、同時に開く枝は1本に制限する
* (最大の葉数=開いた枝の子+畳んだ兄弟=7、470pxなら間隔68pxで箱51pxに収まる)。
* - **縮小プレビューの中ではScrollTriggerを作らない**: 祖先に transform: scale があると
* getBoundingClientRect が縮尺ぶん小さい値を返し、開始位置が実寸とずれる。
* `rect.width / offsetWidth` が1から外れていたら、出現アニメを飛ばして完成状態で静止させる。
*/
type Node = {
id: string;
name: string;
/** 節点の役割を1行で */
role: string;
/** 配下(葉なら自身)のページ数 */
pages: number;
children?: Node[];
};
/** 図に出す構成(架空のコーポレートサイト) */
const tree: Node = {
id: "root",
name: "コーポレートサイト",
role: "公開する全ページの親",
pages: 0,
children: [
{
id: "entry",
name: "入口",
role: "最初に着く面と、その先の行き先",
pages: 0,
children: [
{ id: "entry-top", name: "トップページ", role: "全体の索引を兼ねる", pages: 1 },
{ id: "entry-news", name: "お知らせ一覧", role: "更新の有無を示す", pages: 3 },
{ id: "entry-map", name: "サイトマップ", role: "全ページの一覧", pages: 1 },
],
},
{
id: "know",
name: "知ってもらう",
role: "何をどこまでやるかを説明する面",
pages: 0,
children: [
{ id: "know-service", name: "サービス一覧", role: "領域の見取り図", pages: 1 },
{ id: "know-detail", name: "サービス詳細", role: "範囲と進め方", pages: 4 },
{ id: "know-works", name: "実績一覧", role: "事例の索引", pages: 1 },
{ id: "know-case", name: "実績詳細", role: "課題から結果まで", pages: 6 },
],
},
{
id: "check",
name: "確かめる",
role: "迷いどころを先に潰す面",
pages: 0,
children: [
{ id: "check-price", name: "料金の目安", role: "幅と変動要因", pages: 1 },
{ id: "check-faq", name: "よくある質問", role: "問い合わせ前の疑問", pages: 1 },
{ id: "check-about", name: "会社概要・アクセス", role: "相手の実在を示す", pages: 2 },
],
},
{
id: "act",
name: "動いてもらう",
role: "相談を受け取る面",
pages: 0,
children: [
{ id: "act-contact", name: "お問い合わせ", role: "本命の受け口", pages: 1 },
{ id: "act-doc", name: "資料ダウンロード", role: "まだ話せない人向け", pages: 1 },
{ id: "act-thanks", name: "送信完了", role: "次の行き先を出す", pages: 1 },
],
},
],
};
type FlatNode = {
id: string;
name: string;
role: string;
depth: number;
parent: string | null;
childIds: string[];
/** 配下の合計ページ数 */
pages: number;
/** 兄弟の中での位置(出現の順番付けに使う) */
order: number;
};
/** ツリーを平たい配列へ。深さ優先なので配列の順=図の上から下の順になる */
function flatten(node: Node, depth: number, parent: string | null, order: number, out: FlatNode[]): number {
const entry: FlatNode = {
id: node.id,
name: node.name,
role: node.role,
depth,
parent,
childIds: (node.children ?? []).map((c) => c.id),
pages: node.pages,
order,
};
out.push(entry);
let sum = node.pages;
(node.children ?? []).forEach((child, i) => {
sum += flatten(child, depth + 1, node.id, i, out);
});
entry.pages = sum;
return sum;
}
const flat: FlatNode[] = [];
flatten(tree, 0, null, 0, flat);
const byId = new Map(flat.map((n) => [n.id, n]));
const INDEX_OF = new Map(flat.map((n, i) => [n.id, i]));
const TOTAL_PAGES = byId.get("root")!.pages;
const LEAF_COUNT = flat.filter((n) => n.childIds.length === 0).length;
/** 列の位置と幅(器の幅に対する千分率)。右端は 1000 で器の右端に接する */
const COLUMNS = [
{ left: 0, width: 280 },
{ left: 350, width: 270 },
{ left: 690, width: 310 },
];
const STAGE_H = 470;
const STAGE_PAD = 28;
/** 節点どうしの最大間隔。畳んだときに広がりすぎないための上限 */
const SLOT_MAX = 98;
type Placement = { y: number; open: boolean; visible: boolean };
/**
* 開いている節点から図の座標を作る。
* 葉を上から順に等間隔で並べ、親は子の平均へ置く(枝が子の重心に来る古典的な組み方)。
*/
function computeLayout(open: Record<string, boolean>): Map<string, Placement> {
const isOpen = (n: FlatNode) => n.childIds.length > 0 && (n.depth === 0 || open[n.id] === true);
const visible = new Set<string>(["root"]);
for (const n of flat) {
if (n.parent && visible.has(n.parent) && isOpen(byId.get(n.parent)!)) visible.add(n.id);
}
const leaves = flat.filter((n) => visible.has(n.id) && !isOpen(n));
const usable = STAGE_H - STAGE_PAD * 2;
const slot = leaves.length > 1 ? Math.min(SLOT_MAX, usable / (leaves.length - 1)) : 0;
const span = slot * Math.max(0, leaves.length - 1);
const top = (STAGE_H - span) / 2;
const y = new Map<string, number>();
leaves.forEach((n, i) => y.set(n.id, top + slot * i));
// 葉から根へ向かって畳む(配列は深さ優先なので、後ろから見れば子が先に決まっている)
for (let i = flat.length - 1; i >= 0; i -= 1) {
const n = flat[i];
if (!visible.has(n.id) || y.has(n.id)) continue;
const kids = n.childIds.filter((id) => y.has(id));
y.set(n.id, kids.reduce((s, id) => s + y.get(id)!, 0) / kids.length);
}
const out = new Map<string, Placement>();
for (const n of flat) {
if (visible.has(n.id)) {
out.set(n.id, { y: y.get(n.id)!, open: isOpen(n), visible: true });
} else {
// 見えない節点は親の位置に畳んでおく(開くときはそこから生えてくる)
const anchor = n.parent ? out.get(n.parent) : undefined;
out.set(n.id, { y: anchor ? anchor.y : STAGE_H / 2, open: false, visible: false });
}
}
return out;
}
/** 描画時の状態。GSAPはこの素のオブジェクトだけを動かす */
type Motion = { id: string; y: number; g: number };
const anchorOut = (depth: number) => COLUMNS[depth].left + COLUMNS[depth].width;
const anchorIn = (depth: number) => COLUMNS[depth].left;
const edgePath = (x0: number, y0: number, x1: number, y1: number) => {
const xm = (x0 + x1) / 2;
return `M ${x0.toFixed(1)},${y0.toFixed(1)} C ${xm.toFixed(1)},${y0.toFixed(1)} ${xm.toFixed(1)},${y1.toFixed(1)} ${x1.toFixed(1)},${y1.toFixed(1)}`;
};
const initialOpen: Record<string, boolean> = { know: true };
export default function StructureTreeDiagram() {
const headingId = useId();
const [open, setOpen] = useState<Record<string, boolean>>(initialOpen);
const [selected, setSelected] = useState<string>("know");
const sectionRef = useRef<HTMLElement | null>(null);
const nodeRefs = useRef<Record<string, HTMLDivElement | null>>({});
const edgeRefs = useRef<Record<string, SVGPathElement | null>>({});
const motionRef = useRef<Motion[] | null>(null);
const gsapRef = useRef<typeof import("gsap").gsap | null>(null);
const shownRef = useRef(false);
const killRef = useRef<(() => void) | null>(null);
/** 描画状態の配列。並びは flat と同じなので id からの引き当ては INDEX_OF で足りる */
const readMotion = useCallback(() => {
const current = motionRef.current;
if (current) return current;
const created = flat.map((n) => ({ id: n.id, y: STAGE_H / 2, g: 0 }));
motionRef.current = created;
return created;
}, []);
/** 節点のtransform → 接続線のd の順に、毎フレーム自分で描く */
const paint = useCallback(() => {
const motion = readMotion();
for (const m of motion) {
const el = nodeRefs.current[m.id];
if (!el) continue;
el.style.transform = `translate3d(${(1 - m.g) * -14}px, ${m.y.toFixed(2)}px, 0)`;
el.style.opacity = m.g.toFixed(3);
el.style.pointerEvents = m.g > 0.6 ? "auto" : "none";
}
for (const n of flat) {
if (!n.parent) continue;
const path = edgeRefs.current[n.id];
if (!path) continue;
const parent = motion[INDEX_OF.get(n.parent)!];
const self = motion[INDEX_OF.get(n.id)!];
const x0 = anchorOut(n.depth - 1);
const x1 = anchorIn(n.depth);
// g を終端位置へ掛けるので、閉じるときは線が親へ吸い込まれる
const ex = x0 + (x1 - x0) * self.g;
const ey = parent.y + (self.y - parent.y) * self.g;
path.setAttribute("d", edgePath(x0, parent.y, ex, ey));
path.style.opacity = (self.g * 0.9).toFixed(3);
}
}, [readMotion]);
const layout = useMemo(() => computeLayout(open), [open]);
/** 目標値へ寄せる。GSAPが無い/reduced-motion のときは即座に完成状態へ */
const applyLayout = useCallback(
(animate: boolean) => {
const targets = layout;
const motion = readMotion();
const gsap = gsapRef.current;
const shown = shownRef.current;
if (!animate || !gsap) {
for (const m of motion) {
const t = targets.get(m.id)!;
m.y = t.y;
m.g = shown && t.visible ? 1 : 0;
}
paint();
return;
}
gsap.to(motion, {
y: (_i: number, target: Motion) => targets.get(target.id)!.y,
g: (_i: number, target: Motion) => (targets.get(target.id)!.visible ? 1 : 0),
duration: 0.62,
ease: "power3.out",
overwrite: true,
onUpdate: paint,
});
},
[layout, paint, readMotion],
);
/** 出現: 根 → 第1階層 → 開いている第2階層 の順に生やす */
const reveal = useCallback(
(animate: boolean) => {
if (shownRef.current) return;
shownRef.current = true;
const gsap = gsapRef.current;
const targets = layout;
const motion = readMotion();
for (const m of motion) m.y = targets.get(m.id)!.y;
if (!animate || !gsap) {
applyLayout(false);
return;
}
const ordered = motion
.filter((m) => targets.get(m.id)!.visible)
.sort((a, b) => {
const na = byId.get(a.id)!;
const nb = byId.get(b.id)!;
return na.depth - nb.depth || na.order - nb.order;
});
paint();
gsap.to(ordered, {
g: 1,
duration: 0.5,
ease: "power2.out",
stagger: 0.055,
onUpdate: paint,
});
},
[applyLayout, layout, paint, readMotion],
);
// 出現の実体は open に依存して作り直されるので、ref 越しに最新版を呼ぶ(初期化effectは1回だけ走らせる)
const revealRef = useRef<(animate: boolean) => void>(() => {});
useEffect(() => {
revealRef.current = reveal;
}, [reveal]);
useEffect(() => {
const section = sectionRef.current;
if (!section) return;
const reduced =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// 縮小プレビュー(transform: scale した祖先)の中ではScrollTriggerを作らない
const scaled =
section.offsetWidth > 0 &&
Math.abs(section.getBoundingClientRect().width / section.offsetWidth - 1) > 0.02;
if (reduced || scaled) {
revealRef.current(false);
return;
}
let cancelled = false;
// ScrollTriggerが張れない入れ物に置かれても、図が消えたままにならないようにする
const safety = window.setTimeout(() => revealRef.current(true), 1400);
(async () => {
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
import("gsap"),
import("gsap/ScrollTrigger"),
]);
if (cancelled) return;
gsap.registerPlugin(ScrollTrigger);
gsapRef.current = gsap;
const st = ScrollTrigger.create({
trigger: section,
start: "top 88%",
once: true,
onEnter: () => revealRef.current(true),
});
killRef.current = () => {
st.kill();
const motion = motionRef.current;
if (motion) gsap.killTweensOf(motion);
};
})();
return () => {
cancelled = true;
window.clearTimeout(safety);
killRef.current?.();
killRef.current = null;
};
}, []);
useEffect(() => {
applyLayout(shownRef.current);
}, [applyLayout]);
const toggle = (id: string) => {
const node = byId.get(id)!;
setSelected(id);
if (node.childIds.length === 0 || node.depth === 0) return;
// 開くのは同時に1本だけ。節点の間隔は葉の数で決まるので、2本以上開くと箱どうしが重なる
setOpen((prev) => (prev[id] ? {} : { [id]: true }));
};
/** 選択中の節点から根までの系統(灯す対象) */
const lit = useMemo(() => {
const chain = new Set<string>();
let cursor: string | null = selected;
while (cursor) {
chain.add(cursor);
cursor = byId.get(cursor)!.parent;
}
return chain;
}, [selected]);
const current = byId.get(selected)!;
const trail = useMemo(() => {
const names: string[] = [];
let cursor: string | null = selected;
while (cursor) {
names.unshift(byId.get(cursor)!.name);
cursor = byId.get(cursor)!.parent;
}
return names;
}, [selected]);
const openCount = flat.filter((n) => n.depth === 1 && open[n.id]).length;
return (
<section
ref={sectionRef}
aria-labelledby={headingId}
className={`${montserrat.className} @container w-full max-w-6xl bg-white px-5 py-10 text-gray-900 sm:px-8 sm:py-14`}
>
<div className="flex flex-col gap-5 border-b border-gray-200 pb-6 @3xl:flex-row @3xl:items-end @3xl:justify-between">
<h2
id={headingId}
className="text-[clamp(1.35rem,3.2vw,1.9rem)] font-bold leading-[1.5] tracking-tight"
>
どの面が何のために要るのか
<br />
節点をひらいて確かめる
</h2>
<dl className="flex shrink-0 gap-8">
<div>
<dt className="text-[10px] font-bold tracking-wide text-gray-400">総ページ数</dt>
<dd className="text-[1.75rem] font-semibold tabular-nums leading-none">
{TOTAL_PAGES}
</dd>
</div>
<div>
<dt className="text-[10px] font-bold tracking-wide text-gray-400">末端の面</dt>
<dd className="text-[1.75rem] font-semibold tabular-nums leading-none">
{LEAF_COUNT}
</dd>
</div>
</dl>
</div>
{/* 図(広い器のみ): 左に作図・右に明細 */}
<div className="mt-8 hidden gap-8 @2xl:block @4xl:grid @4xl:grid-cols-[1fr_244px]">
<div className="relative" style={{ height: `${STAGE_H}px` }}>
<svg
className="absolute inset-0 h-full w-full"
viewBox={`0 0 1000 ${STAGE_H}`}
preserveAspectRatio="none"
aria-hidden="true"
focusable="false"
>
{flat
.filter((n) => n.parent)
.map((n) => (
<path
key={n.id}
ref={(el) => {
edgeRefs.current[n.id] = el;
}}
d=""
fill="none"
vectorEffect="non-scaling-stroke"
className="transition-colors duration-200 motion-reduce:transition-none"
stroke={lit.has(n.id) ? "#d97706" : "#d1d5db"}
strokeWidth={lit.has(n.id) ? 1.6 : 1}
style={{ opacity: 0 }}
/>
))}
</svg>
{flat.map((n) => {
const col = COLUMNS[n.depth];
const hasChildren = n.childIds.length > 0 && n.depth > 0;
const isOpen = open[n.id] === true;
const isLit = lit.has(n.id);
const isCurrent = selected === n.id;
return (
<div
key={n.id}
ref={(el) => {
nodeRefs.current[n.id] = el;
}}
className="absolute top-0 will-change-transform"
style={{
left: `${col.left / 10}%`,
width: `${col.width / 10}%`,
opacity: 0,
}}
>
<button
type="button"
onClick={() => toggle(n.id)}
aria-expanded={hasChildren ? isOpen : undefined}
className={`group/node block w-full -translate-y-1/2 border bg-white px-3 py-2 text-left transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none ${
isCurrent
? "border-amber-600 bg-amber-50/60"
: isLit
? "border-amber-600/60 hover:border-amber-600"
: "border-gray-200 hover:border-gray-400"
}`}
>
<span className="flex items-baseline gap-2">
<span
className={`flex-1 truncate font-bold leading-tight ${n.depth === 0 ? "text-[13px]" : "text-[12px]"}`}
>
{n.name}
</span>
<span
className={`text-[10px] font-semibold tabular-nums ${isCurrent ? "text-amber-700" : "text-gray-400"}`}
>
{n.pages}P
</span>
</span>
<span className="mt-1 flex items-center gap-2">
<span className="flex-1 truncate text-[10px] leading-tight text-gray-500">
{n.role}
</span>
{hasChildren ? (
<span
aria-hidden="true"
className={`relative block size-2.5 shrink-0 ${isLit ? "text-amber-600" : "text-gray-400 group-hover/node:text-gray-700"}`}
>
<span className="absolute top-1/2 left-0 block h-px w-full -translate-y-1/2 bg-current" />
<span
className={`absolute top-0 left-1/2 block h-full w-px -translate-x-1/2 bg-current transition-transform duration-300 motion-reduce:transition-none ${isOpen ? "scale-y-0" : "scale-y-100"}`}
/>
</span>
) : null}
</span>
</button>
</div>
);
})}
</div>
{/* 明細: 選んだ節点の中身 */}
<div className="mt-6 border-t border-gray-200 pt-6 @4xl:mt-0 @4xl:border-t-0 @4xl:border-l @4xl:pt-0 @4xl:pl-6">
<p className="text-[10px] font-bold tracking-wide text-gray-400">
第{current.depth + 1}階層
</p>
<p className="mt-2 text-[15px] font-bold leading-snug">{current.name}</p>
<p className="mt-2 text-[12px] leading-[1.9] text-gray-700">{current.role}</p>
<p className="mt-3 text-[10px] leading-relaxed text-gray-400">{trail.join(" / ")}</p>
<dl className="mt-5 border-t border-gray-200">
<div className="flex items-baseline justify-between border-b border-gray-100 py-2.5">
<dt className="text-[11px] text-gray-500">直下の項目</dt>
<dd className="text-[13px] font-semibold tabular-nums">
{current.childIds.length}
</dd>
</div>
<div className="flex items-baseline justify-between border-b border-gray-100 py-2.5">
<dt className="text-[11px] text-gray-500">配下のページ</dt>
<dd className="text-[13px] font-semibold tabular-nums">
{current.pages}
</dd>
</div>
<div className="flex items-baseline justify-between border-b border-gray-100 py-2.5">
<dt className="text-[11px] text-gray-500">全体に占める割合</dt>
<dd className="text-[13px] font-semibold tabular-nums">
{Math.round((current.pages / TOTAL_PAGES) * 100)}%
</dd>
</div>
</dl>
<p className="mt-5 text-[10px] leading-relaxed text-gray-400">
開いている枝 {openCount} / {flat.filter((n) => n.depth === 1).length}(同時に開くのは1本)
</p>
</div>
</div>
{/* 狭い器では同じ配列を入れ子リストへ組み替える(縮小した図は読めないため) */}
<ul className="mt-6 @2xl:hidden">
{flat
.filter((n) => n.depth === 1)
.map((n) => {
const isOpen = open[n.id] === true;
return (
<li key={n.id} className="border-b border-gray-200">
<button
type="button"
onClick={() => toggle(n.id)}
aria-expanded={isOpen}
className="flex w-full items-center gap-3 py-4 text-left focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
>
<span className="flex-1">
<span className="block text-[13px] font-bold leading-tight">{n.name}</span>
<span className="mt-1 block text-[11px] leading-tight text-gray-500">
{n.role}
</span>
</span>
<span className="text-[11px] font-semibold tabular-nums text-gray-400">
{n.pages}P
</span>
<span
aria-hidden="true"
className="relative block size-3 shrink-0 text-gray-400"
>
<span className="absolute top-1/2 left-0 block h-px w-full -translate-y-1/2 bg-current" />
<span
className={`absolute top-0 left-1/2 block h-full w-px -translate-x-1/2 bg-current transition-transform duration-300 motion-reduce:transition-none ${isOpen ? "scale-y-0" : "scale-y-100"}`}
/>
</span>
</button>
{isOpen ? (
<ul className="border-t border-gray-100 pb-3 pl-4">
{n.childIds.map((cid) => {
const child = byId.get(cid)!;
return (
<li
key={cid}
className="flex items-baseline gap-3 border-l border-amber-600/40 py-2 pl-4"
>
<span className="flex-1">
<span className="block text-[12px] font-bold leading-tight">
{child.name}
</span>
<span className="mt-0.5 block text-[10px] leading-tight text-gray-500">
{child.role}
</span>
</span>
<span className="text-[10px] font-semibold tabular-nums text-gray-400">
{child.pages}P
</span>
</li>
);
})}
</ul>
) : null}
</li>
);
})}
</ul>
<div className="mt-8 flex flex-col gap-3 border-t border-gray-200 pt-4 @3xl:flex-row @3xl:items-center @3xl:justify-between">
<p className="text-[10px] leading-relaxed text-gray-400">
ページ数は公開時点の想定値です。節点を押すと直下の項目が開き、図は位置を取り直します(開く枝は1本ずつ)
</p>
<a
href="#"
className="group/link relative shrink-0 self-start text-[12px] font-bold text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600"
>
構成案の作り方を見る
<span className="absolute -bottom-1 left-0 block h-px w-full origin-left scale-x-100 bg-gray-900 transition-transform duration-300 group-hover/link:scale-x-0 motion-reduce:transition-none" />
<span className="absolute -bottom-1 left-0 block h-px w-full origin-right scale-x-0 bg-amber-600 transition-transform delay-150 duration-300 group-hover/link:origin-left group-hover/link:scale-x-100 motion-reduce:transition-none" />
</a>
</div>
</section>
);
}
shadcn CLI でプロジェクトに追加
npx shadcn@latest add https://designs.first-ch.com/r/structure-tree-diagram.json