Items & Inputs

How an individual control inside the sidebar actually works — the input primitives, how they read/write the selected node's data, and how breakpoints

How an individual control inside the sidebar actually works — the input primitives, how they read/write the selected node's data, and how breakpoints

  • states (hover, sm, md, dark) are layered on top.

The two layers

PropertyDef (data)
    ↓
PropertyRenderer (dispatch on input.type)
    ↓
┌─────────────────┬─────────────────┬──────────────┬──────────────┐
│  ToolbarItem    │  UniversalInput │  ColorInput  │ TailwindInput│  ← input primitives
│  (text, select, │  (value-aware,  │  (palette +  │ (tailwind    │
│   slider,       │   token-aware)  │   custom)    │  options +   │
│   number, ...)  │                 │              │  prefix)     │
└─────────────────┴─────────────────┴──────────────┴──────────────┘
  • A MainTab typically calls <ToolbarItem> directly (it knows what control it wants).
  • A PropertySection (universal sections) goes through <PropertyRenderer> which dispatches the registered input.type to the right primitive.

Both ultimately call into changeProp / getPropFinalValue — the same prop read/write engine.

ToolbarItem — the workhorse

ToolbarItem.tsx is the input primitive used everywhere a single prop needs a control. One component, many shapes via the type prop:

<ToolbarItem
  propKey="placeholder" // which prop on the node
  propType="component" // "class" | "component" | "modifier" | ...
  type="text" // visual control flavor
  label="Placeholder"
  labelHide={true}
  // ...flavor-specific props
/>

type — visual control flavor

typeRenders
text, textareaPlain <input> / <textarea>
numberNumeric <input type="number">
sliderRange slider with min/max/step
selectDropdown (also accepts valueLabels for index-based selects)
radioHorizontal radio group
toggle, checkboxDaisyUI-styled toggle
toggleNextCycle-through button (click steps through options)
codemirrorLazy-loaded CodeMirror editor (HTML/CSS/JS)

propType — what kind of value gets written

This is the most important — and most misunderstood — prop on ToolbarItem.

propTypeWrites toUsed for
"class" (default)node.props.className Tailwind classLayout, design, spacing — anything style-driven
"component"node.props[propKey] directlyComponent-specific props (e.g. FormElement placeholder, Button text)
"modifier"node.props.modifiers[propKey]Modifier props (Background modifiers, etc.)
"style"node.props.style[propKey] inline styleInline CSS overrides
"action"node.props.action[propKey]Button/Link action sub-props
"animation"node.props.animation[propKey]Animation preset sub-props

Class-based props (propType: "class") are the default because most editor controls are Tailwind utilities. The class-routing logic uses propTag / propKey to identify which class to mutate (e.g. propTag: "pt" ⇒ swap pt-* classes).

propKey — the prop name

For propType: "class", this matches the TailwindStyles key (e.g. "backgroundColor", "padding"). For other propTypes, this matches the literal prop name on the node (e.g. "placeholder", "name").

Read/write flow

ToolbarItem.tsx:382-463:

// 1. Read the current value (view-aware)
const { value, viewValue } = getPropFinalValue(__props, view, nodeProps, classDark);

// 2. On change, dispatch to changeProp (handles class/component/modifier/etc)
const changed = (newValue) => {
  if (propType === "class") {
    // Apply to all currently selected views. Normal case: single view from ViewAtom.
    // Multi-scope case: user Alt-clicked chip cells → MultiScopeAtom is non-empty,
    // getEffectiveViews returns those bps instead.
    const effectiveViews = getEffectiveViews(modifiers, view, multiScope);
    effectiveViews.forEach(v => changeProp({ propKey, value: newValue, view: v, ... }));
  } else {
    changeProp({ propKey, value: newValue, view, ... });
  }
};

getPropFinalValue and changeProp live in packages/sdk/src/chrome/viewport/propSystem.ts (re-exported via viewportExports). They're the single read/write engine for every input.

View / breakpoint awareness

Every input is breakpoint-aware. The active view comes from ViewAtom ("mobile" | "sm" | "desktop" | "lg" | "xl" | "2xl" — see VIEW_BREAKPOINT_SCOPE_KEYS).

The canvas viewport switcher is the scope. Switching the canvas to md directs all class writes to md:. There is no separate breakpoint picker in the tab bar. Power users can opt into multi-scope writes by Alt-clicking cells in the BreakpointChip popover — that toggles bps into MultiScopeAtom and fans writes out to all selected bps.

Class-based props use Tailwind's responsive prefix scheme:

  • view: "desktop" → bare class (pt-4)
  • view: "sm"sm:pt-4
  • view: "lg"lg:pt-4

Plus a dark modifier from EditModifiersAtom.darkdark:pt-4.

propType: "component" and friends do not fan out — they're single-value props with no breakpoint scope.

Per-property BreakpointChip

Every input gets a single BreakpointChip next to its label (replacing the old always-visible 6-dot row). Silent-when-clean: chip only renders when there is an override to show (density=auto) or when density=always.

┌───────────────────────────────────┐
│ Padding              [●m]         │   ← chip: override at md
│ [pt-4 _____________________]      │
└───────────────────────────────────┘
  • Click chip = open popover with 6-cell grid (base + sm/md/lg/xl/2xl). Cell click switches canvas to that bp.
  • Right-click chip = context menu with Reset / Promote to base / Copy down / Copy up / Show source.
  • Alt-click a popover cell = toggle multi-scope writes to that bp.
  • Section-header dot = 4×4 px bg-primary dot when any prop in the section has any non-base override.

Full reference: responsive-editor.md.

Plumbing:

LayerFileWhat it does
Per-field chipBreakpointChip in breakpoint-chip/BreakpointChip.tsxActive-bp detection, dot state, click/right-click
Singleton popoverbreakpoint-chip/ChipPopover.tsxCell grid, value display, Alt-click multi-scope
Context menubreakpoint-chip/ChipContextMenu.tsxuseChipContextMenu hook + portal menu
Commandsbreakpoint-chip/commands.tscmdResetAt, cmdResetAll, cmdPromoteToBase, cmdCopyDown, cmdCopyUp
Section override dotbreakpoint-chip/useSectionHasAnyOverride.tsReturns true when any propKey has a non-base override

Skip rules: chip returns null when propType is "root" or "component" (those don't cascade across breakpoints).

State indices — hover, focus, etc.

ToolbarItem's index prop adds a state prefix:

  • index: "hover" → writes hover:bg-primary on a class change
  • index: "focus"focus:bg-primary
  • index: "active"active:bg-primary

Combined with view: index: "hover", view: "lg"lg:hover:bg-primary.

Some property defs in the registry set index per-property (e.g. the Hover section's color picker has index: "hover").

PropertyRenderer — registry → input

PropertyRenderer.tsx dispatches a PropertyDef.input to the right primitive. The cases:

switch (def.input.type) {
  case "tailwind-select": // ToolbarItem type="select" with TailwindStyles[key]
  case "tailwind-radio": // ToolbarItem type="radio" with TailwindStyles[key]
  case "universal": // <UniversalInput> — multi-mode input
  case "color": // <ColorInput prefix={...}>
  case "checkbox": // ToolbarItem type="toggle" with on/off classes
  case "text": // ToolbarItem type="text"
  case "select": // ToolbarItem type="select" with explicit options
  case "custom": // <def.input.component def={def} />
}

The dispatch normalizes propKey, propType, index, and inline from the PropertyDef onto the underlying input — so the same control gets the same behavior whether it's invoked declaratively (registry) or imperatively (MainTab).

UniversalInput — the smart one

universal-input/ is a value-aware multi-mode input. It lets the user type a Tailwind class (pt-4), a design var (var(--space-md)), an arbitrary value (pt-[37px]), or pick from the dropdown — all in the same field. Used for spacing, sizing, and any prop where users mix tokens with arbitraries.

Modes are configured via allowedTypes: ValueType[]:

  • "tailwind" — TailwindStyles options
  • "designVar" — design system variables
  • "arbitrary"pt-[37px] style values
  • "calc" — calc() expressions

tailwindKey or tailwindOptions populates the dropdown. showVarSelector adds a quick "pick a token" button.

Human-readable display

When the field is not focused and the value is a Tailwind class (selectedType === "tailwind", propType === "class"), UniversalInput shows a humanized label via formatTailwindDisplayLabel — same helper the dropdown uses. So p-space-sm reads as "Space Small", rounded-lg as "Large", bg-primary as "Primary", etc. On focus, the field swaps back to the raw class so the user can edit it; on blur it humanizes again. Numeric (px/em/rem/%/...) and var-mode values are not transformed — they're already readable. propType !== "class" (component / modifier / style props) is also skipped.

ColorInput — palette + custom

inputs/color/ColorInput.tsx is the color picker. prefix tells it which class to write (bg, text, border, divide). It auto-detects the current value's mode (palette token vs hex vs hsl) and shows the right picker UI.

TailwindInput — prefixed class set

inputs/advanced/TailwindInput.tsx is the basic "TailwindStyles dropdown" input. Used when tailwind-select has a propTag — i.e. when the prop is identified by a class prefix rather than by literal class. Handles prefix stripping on read.

Custom inputs

When a control needs more than the dispatch can express, register a custom input:

registerPropertyDef({
  id: "my-custom-prop",
  label: "Custom",
  section: "advanced",
  keywords: [...],
  input: {
    type: "custom",
    component: MyCustomInput,
  },
});

MyCustomInput receives { def, index }. It can use useNode() / useEditor() and changeProp directly — same APIs as ToolbarItem.

Wrapping & layout

Every sidebar row renders through a single <Chip> (Chip.tsx) — see primitives/chip.md. Chip owns the label gutter (with truncation tooltip + click-forward), the breakpoint-pill indicator (when propKey + propType are class-typed), the bordered frame, and the trailing clear/× slot.

Props that affect layout (passed through ToolbarItem to Chip):

  • labelHide — omit the label gutter (single-control row).
  • labelWidth — override the default w-20 gutter (rare).
  • wrap — escape hatch: when truthy, Chip renders children directly with no frame (used when a parent already provides the row shape).
  • append — extra content rendered at the right edge of the input

Common gotchas

  • propType: "component" does not fan out across views. If you need a per-breakpoint prop, it has to be class-based (Tailwind responsive prefixes). No way to have desktop:placeholder vs mobile:placeholder on a FormElement — that's one prop, one value.
  • propKey for class props matches TailwindStyles key, not the class. propKey: "backgroundColor" writes bg-* classes — the key resolves to a prefix internally.
  • getEffectiveViews only fires for class props. That's intentional — multi-view writing only makes sense for responsive Tailwind classes.
  • type: "select" with valueLabels. When valueLabels is an array, the stored value is the index, not the label. Use this for ordinal props (e.g. font weight presets); use options: [{label, value}] for string-keyed selects.
  • Custom inputs must call changeProp themselves. They don't auto-route values like ToolbarItem does. Mirror the propKey / propType / view / index handling.
  • Don't bypass the engine. useNode() + node.actions.setProp exists, but going around changeProp skips the view fan-out, modifier handling, and class-routing logic. Always go through the engine.