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).
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.
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));
}
FormElement field type, Container type variant).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.
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 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.
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.
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.
mainTabs/ currently includes:
| Component | MainTab | Notes |
|---|---|---|
Background | BackgroundMainTab | Theme + modifiers (lives at ROOT) |
FormElement | FormElementMainTab | Field type + name/placeholder + select options |
Map / MapPoint | MapMainTab / MapPointMainTab | Coords + zoom + markers |
Table* | TableMainTab etc. | Table sections / cells / rows |
Text | TextMainTab | Tag, 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.
<X>MainTab.tsx in mainTabs/.renderComponentSlots(...) with at least the Content slot.toolbar.settings = <X>MainTab.toolbar.icon so tab[0] shows the right icon.toolbar.hide: [...] to disable universal sections.toolbar.advancedSettings for a custom Advanced panel.No registry changes needed — wiring is read off the craft def at selection time.
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.useNode() returns the selected
node, not the toolbar's root. That's how inputs read/write the right node.