Per-Component MainTabs

The body of tab[0] (the "Component" tab) is supplied by the selected node — a React component called the MainTab. Each registered CraftJS component either ships its own MainTab in [packages/sdk/src/chrome/toolbar/inspector/mainTabs/](../…

The body of tab[0] (the "Component" tab) is supplied by the selected node — a React component called the MainTab. Each registered CraftJS component either ships its own MainTab in packages/sdk/src/chrome/toolbar/inspector/mainTabs/ or relies on the default rendering (no MainTab → "N/A" placeholders).

Wiring

A component declares its MainTab on its craft def:

// packages/sdk/src/components/FormElement/FormElement.craft.tsx
export const FormElementDef = defineComponent({
  // ...
  toolbar: {
    icon: "TbForms",
    settings: FormElementMainTab, // <— this is the MainTab
    advancedSettings: FormElementAdvSettings, // optional
    hide: ["typography"], // optional — universal sections to disable
  },
});

<ComponentSection> (RegistrySettings.tsx:73-80) reads toolbar.settings from the selected node's craft def and renders it. That's the entire wiring — no registry, no opt-in. The MainTab gets useNode() / useEditor() context for free because it's mounted inside the toolbar's CraftJS scope.

Slot system

MainTabs don't render ToolbarSections directly. They call renderComponentSlots(...) (helpers.tsx:91-117) which fills named slots in a guaranteed order:

const COMPONENT_SECTIONS = ["Content", "Type", "Marker"] as const;

export function renderComponentSlots(slots: Partial<Record<ComponentSlotName, React.ReactNode>>) {
  return COMPONENT_SECTIONS.map(title => slots[title] ?? renderNA(title));
}
  • Content — the main settings (always there for most components).
  • Type — type/variant picker (e.g. FormElement field type, Container type variant).
  • Marker — list-marker / icon-as-marker controls (e.g. List bullets).

Slots that aren't supplied render an "N/A" placeholder so the order is stable across components — users always see the same shape.

Example

FormElementMainTab.tsx:

return renderComponentSlots({
  Content: (
    <ToolbarSection title="Properties" help="...">
      <ToolbarItem propKey="placeholder" propType="component" type="text" .../>
      <ToolbarItem propKey="name" propType="component" type="text" .../>
    </ToolbarSection>
  ),
  Type: (
    <ToolbarSection title="Type">
      <ToolbarItem propKey="type" propType="component" type="select" .../>
    </ToolbarSection>
  ),
  // No Marker → renders N/A placeholder
});

⚠️ A MainTab MUST go through renderComponentSlots. Returning your own <ToolbarSection>s directly bypasses the slot order guarantee and the N/A placeholders, which means the toolbar shape becomes inconsistent across components.

Section icons

Section title → icon mapping lives in SECTION_ICONS (helpers.tsx:42-72). Pass icon={SECTION_ICONS["Content"]} on ToolbarSection to get the standard icon for that slot. Adding a new slot? Add it to both COMPONENT_SECTIONS and SECTION_ICONS.

Advanced slot

The Advanced tab gets a separate slot list:

export const ADVANCED_COMPONENT_SECTIONS = ["Properties"] as const;

renderAdvancedComponentSlots (helpers.tsx:122-149) is the equivalent of renderComponentSlots for the Advanced tab. The default fill for Properties is <PropertiesInput /> — the user-defined custom-props editor — so a component that doesn't supply its own Advanced UI still gets the standard custom-props panel.

A component overrides this via toolbar.advancedSettings:

toolbar: {
  settings: ContainerMainTab,
  advancedSettings: ContainerAdvSettings, // renders inside <AdvancedSettingsSection>
}

<AdvancedSettingsSection> (RegistrySettings.tsx:82-90) renders this without an outer ToolbarSection wrapper — advancedSettings components are expected to provide their own ToolbarSection chrome.

Hiding universal sections per-component

A component can disable specific universal sections via toolbar.hide:

toolbar: {
  settings: TextMainTab,
  hide: ["typography", "background"],
}

These hide-keys map to hideKey on SectionDef and PropertyDef entries in the registry. Sections with a hidden hideKey render in a disabled, collapsed state — they stay in the tree at stable positions, they just become non-interactive. See property-registry.md for the registry side.

Built-in MainTabs

mainTabs/ currently includes:

ComponentMainTabNotes
BackgroundBackgroundMainTabTheme + modifiers (lives at ROOT)
FormElementFormElementMainTabField type + name/placeholder + select options
Map / MapPointMapMainTab / MapPointMainTabCoords + zoom + markers
Table*TableMainTab etc.Table sections / cells / rows
TextTextMainTabTag, rich-text mode, content variables

Sub-files in this folder (e.g. NodeAiContextSection.tsx, ContainerScrollEffectSection.tsx) are partials that get composed into multiple MainTabs — same pattern as a normal React subcomponent, no special registration needed.

Adding a MainTab

  1. Create <X>MainTab.tsx in mainTabs/.
  2. Use renderComponentSlots(...) with at least the Content slot.
  3. Wire it on the component's craft def: toolbar.settings = <X>MainTab.
  4. (Optional) Set toolbar.icon so tab[0] shows the right icon.
  5. (Optional) Add toolbar.hide: [...] to disable universal sections.
  6. (Optional) Set toolbar.advancedSettings for a custom Advanced panel.

No registry changes needed — wiring is read off the craft def at selection time.

Gotchas

  • Don't useState the slot map. Build it inline each render. The MainTab itself unmounts on every selection change anyway — it lives inside <ComponentSection> which reads the new toolbar.settings per node.
  • MainTabs run inside CraftJS scope. useNode() returns the selected node, not the toolbar's root. That's how inputs read/write the right node.
  • N/A placeholders are intentional. Removing them to "tidy up" wrecks the consistent toolbar shape users rely on.