Tab Strip + Scroll Tracking

The 5-tab strip pinned at the top of the right toolbar, plus the IntersectionObserver-based logic that keeps the active pill in sync with the scroll position of the body.

The 5-tab strip pinned at the top of the right toolbar, plus the IntersectionObserver-based logic that keeps the active pill in sync with the scroll position of the body.

Fixed structure

RegistrySettings.tsx:45-53:

const FIXED_HEAD = [
  { title: "Component", icon: null },           // tab[0] — swaps per node
  { title: "Layout", icon: <TbBoxPadding /> },
  { title: "Design", icon: <BiPaint /> },
  { title: "Interactions", icon: <TbMouse /> },
  { title: "Advanced", icon: <TbSettings /> },
];
const TAB_IDS = ["component", "layout", "design", "interactions", "advanced"];

The strip is static — tabs and stacks render once, never unmount. Switching the selected node does not rebuild the tree; only content inside reacts via useNode() / useEditor() hooks. (See the file header on RegistrySettings.tsx.)

Tab #0 swaps per component

RegistrySettings.tsx:200-208:

headRef.current[0].title = displayName || "Component";
headRef.current[0].icon = <NodeIcon />;
// ...
STATIC_SECTIONS[0].title = displayName || "Component";
  • displayName comes from node.data.displayName || node.data.name.
  • Icon comes from toolbar.icon on the component's craft def, resolved by resolveToolboxIcon.
  • Title/icon are mutated via ref, not state. The tab strip is the same DOM node across selections — so React doesn't tear it down.

⚠️ Don't "fix" the ref mutation by lifting it to state. Doing so will remount the entire strip on every selection change.

The body of tab[0] is rendered by <ComponentSection>, which pulls toolbar.settings from the craft def and renders that component as-is. See main-tabs.md for the per-component MainTab system that fills it.

Click → scroll

A click on a tab calls setActiveTabFromClick(title, setTab) (InspectorTab.tsx:145-148) which does two things:

  1. Sets TabAtom to the clicked title.
  2. Sets clickLockUntil = Date.now() + 400 — a 400ms window during which scroll-driven updates are suppressed so the indicator doesn't fight the click.

Then scrollToSection(title, stackIndex) (InspectorTab.tsx:218-233) does the actual jump via section.scrollIntoView({ behavior: "instant", block: "start" }) and a -7px correction so the section header isn't flush with the container top.

Stack indices: Component=0, Layout=1, Design=2, Interactions=3, Advanced=4.

Scroll → tab pill (IntersectionObserver)

InspectorTab.tsx:80-114 creates one observer per <InspectorSection>:

  • Root: #toolbarContents (the scroll container).
  • Active condition: section is intersecting AND its top edge is within 60px of the container top AND its bottom is below that threshold (so a mostly-scrolled-away section doesn't count).
  • Thresholds: [0, 0.1, 0.25, 0.5] — the observer fires at multiple scroll positions so the active state updates smoothly.

When a stack becomes active, onVisibilityChange flips the entry in visibleSections, and <InspectorBody> finds the lowest-index visible stack and writes its title to TabAtom.

The Date.now() > clickLockUntil check (InspectorTab.tsx:163) is what keeps a click-driven tab change from being clobbered by an immediate scroll fire.

If you ever wrap the body in a different scroll container, copy the id #toolbarContents over — the observer silently no-ops without it.

The 70vh spacer

InspectorTab.tsx:204:

{
  /* Spacer so the last section can scroll fully to the top */
}
<div className="shrink-0" style={{ minHeight: "70vh" }} />;

Without this, the "Advanced" stack can never scroll its top edge to the container top → the Advanced tab pill could never reach the active state via scroll.

Scroll-restore on selection change

useScrollToActiveTab(activeTab, setActiveTab, id) (RegistrySettings.tsx:197) runs whenever the selected node changes. It scrolls back to the previously active tab's stack so users don't lose context when clicking between similar nodes.

Gotchas

  • #toolbarContents is load-bearing. It's the IntersectionObserver root and the scroll target for scrollToSection. Don't rename it without searching for all usages.
  • Click-lock window is 400ms. If you add a third way to change tabs (e.g. a keyboard shortcut), call setActiveTabFromClick so scroll tracking doesn't immediately overwrite it.
  • Top threshold is 60px. If section header heights change and a section becomes shorter than 60px, the active condition can flicker. Most sections are well above this.
  • Tab title is also the section header label. That's how the same string identifies both the tab pill and the stack panel — see STATIC_SECTIONS[0].title mutation on every render.