Skip to main content

Custom Palette & Panel

Live.Dnd ships a complete editor — a 3-pane Splitter on desktop, a stacked canvas with Drawers on mobile — and renders it whenever you don't say otherwise. Everything behind that layout is also reachable as plain data through three hooks (useDndPalette(), useDndPanel(), useDndLayout()), so replacing the palette, the property panel, or the arrangement of the whole thing is a matter of writing an ordinary component and rendering it inside Live.Dnd. Drag-and-drop and field editing keep working exactly as before.

There are two ways in, and they compose:

  • Keep the built-in layout, swap a region. Render <Live.Dnd.Layout palette={...} panel={...} /> and fill only the slot you care about. The Splitter, the mobile FAB, and both Drawers stay.
  • Replace the layout. Pass your own children and place Live.Dnd.Canvas (plus whatever you want around it) wherever you like.

Drag in the demo's Hero item and select it to see its "Background Style" field — an object-shaped binding with no declared type at all — rendered as one input per key instead of an opaque string, by inferring structure from the parsed value rather than requiring it upfront. Try Stats or FAQ too: their items bindings are arrays of objects whose own children/label fields are further, separately data-bound JSX elements — each array entry gets its own group of fields instead of one opaque blob for the whole array.

Custom layout

By default Live.Dnd renders a 3-pane Splitter (palette / canvas / panel) on desktop, and on mobile a full-width canvas with the palette and property panel in Drawers. Pass children to design that layout yourself, composing the three regions wherever you want them:

import { Splitter } from '@jbpark/ui-kit';

<Live.Dnd value={value} onChange={setValue}>
<Splitter withHandle orientation="horizontal">
<Splitter.Panel defaultSize="15%">
<Live.Dnd.Palette />
</Splitter.Panel>
<Splitter.Panel defaultSize="55%">
<Live.Dnd.Canvas />
</Splitter.Panel>
<Splitter.Panel defaultSize="30%">
<Live.Dnd.Panel />
</Splitter.Panel>
</Splitter>
</Live.Dnd>;

Nothing about the layout is prescribed — a vertical stack, tabs, a collapsible sidebar of your own, panels in a modal, three CSS grid areas — as long as each region renders somewhere inside Live.Dnd, which owns the drag context they share.

RegionRenders
Live.Dnd.PaletteThe built-in palette draggables, in a scroll container.
Live.Dnd.CanvasThe drop target and the sortable list of sections.
Live.Dnd.PanelThe built-in property panel, in a scroll container.
Live.Dnd.LayoutThe built-in arrangement, so you can wrap it (a toolbar above it, say) rather than rebuild it, and replace one region through its palette/panel slots. Also how to get the built-in Splitter layout without importing @jbpark/ui-kit yourself.

Each takes the props of a div (className is merged over the region's own defaults, so overriding one wins rather than fighting), and each should be rendered at most once — Canvas in particular carries the scroll container the section iframes size themselves against, plus fixed drag ids that a second copy would duplicate.

Two things to know:

  • Canvas needs a height-constrained parent. It fills one (h-full), so a parent with no height of its own collapses it.
  • children replaces the mobile chrome too — the floating palette button and both Drawers are part of the default layout, not of Live.Dnd. The useDndLayout() hook returns what drove them (isMobile, selectedId, clearSelection, paletteOpen, setPaletteOpen) so a custom layout can build its own:
import { useDndLayout } from '@jbpark/live-editor/dnd';

const MyLayout = () => {
const { isMobile, selectedId, clearSelection } = useDndLayout();

return isMobile ? <Live.Dnd.Canvas /> : /* ... */;
};

Keeping the built-in layout and swapping one region

Replacing the palette or the panel usually doesn't mean you want a different arrangement. Live.Dnd.Layout is the built-in one, exported with two slots so you can hand it your own component and keep everything else:

<Live.Dnd value={value} onChange={setValue}>
<Live.Dnd.Layout panel={<MyPanel />} />
</Live.Dnd>

That keeps the desktop Splitter, the mobile FAB, and both Drawers — your panel is placed in the right-hand pane on desktop and in the properties Drawer on mobile. A slot goes in raw, so it owns its own container (the built-in regions bring theirs); palette works the same way.

Custom palette

useDndPalette() returns what drives the palette:

FieldTypeDescription
itemsSection[]The palette's available sections.
onAdd(item: Section) => voidAdd a section to the canvas.

Wrap your own markup in the exported Live.Dnd.DraggableItem so items stay draggable — it owns the dnd-kit wiring and hands back ref / dragProps / isDragging. For "tap to add", take isMobile from useDndLayout(): at a mobile breakpoint a tap can't be read as a failed drag attempt (in the built-in layout the palette sits in a Drawer with the canvas stacked behind it, so there's nothing to drag onto) and native dblclick synthesis from double-tap is unreliable anyway. Treat a single click/tap as "add" there instead of relying on onDoubleClick, as below.

import { useDndLayout, useDndPalette } from '@jbpark/live-editor/dnd';

const MyPalette = () => {
const { items, onAdd } = useDndPalette();
const { isMobile } = useDndLayout();

return (
<div>
{items.map(item => (
<Live.Dnd.DraggableItem key={item.id} item={item}>
{({ ref, dragProps, isDragging }) => (
<div
ref={ref}
{...dragProps}
onClick={isMobile ? () => onAdd(item) : undefined}
onDoubleClick={() => onAdd(item)}
style={{ opacity: isDragging ? 0.5 : 1 }}
>
{item.name}
</div>
)}
</Live.Dnd.DraggableItem>
))}
</div>
);
};

<Live.Dnd value={value} onChange={setValue}>
<Live.Dnd.Layout palette={<MyPalette />} />
</Live.Dnd>;

Custom panel

useDndPanel() returns what drives the property panel:

FieldTypeDescription
itemSection?The currently selected section, or undefined if none is selected.
bindingsPanelBinding[]The selected section's editable data-binding fields, flattened to one entry per bound property. See the table below.
onChange(next: Partial<Section>) => voidUpdate fields of the selected section directly (not through a binding).
onDelete(id: string) => voidDelete a section by id.
onMoveUp() => voidMove the selected section up. Alternative to drag-reordering — needed because the canvas sits behind the mobile Drawer this panel renders in, so there's nothing visible to drag onto there.
onMoveDown() => voidMove the selected section down, same rationale as onMoveUp.
canMoveUpbooleanWhether onMoveUp would do anything right now — disable your move-up control when false.
canMoveDownbooleanWhether onMoveDown would do anything right now — disable your move-down control when false.
onNodeChange(params: { id: string; label: string; property: string; value: unknown }) => voidCommit an edit to an element addressed by its own data-id, rather than through a binding you were handed. Needed for the nested elements bindings can't reach — pass it to Live.Dnd.Field along with the binding. See below.

bindings carries each field's type, current value, options (when present), and an onChange wired straight into the same AST-update pipeline the built-in panel uses. Switch on binding.type to render your own control:

import { useDndPanel } from '@jbpark/live-editor/dnd';

const MyPanel = () => {
const { item, bindings, onDelete, onMoveUp, onMoveDown, canMoveUp, canMoveDown } =
useDndPanel();

return (
<div>
<header>
{item?.name}
<button disabled={!canMoveUp} onClick={onMoveUp}>
Up
</button>
<button disabled={!canMoveDown} onClick={onMoveDown}>
Down
</button>
{item && <button onClick={() => onDelete(item.id)}>Delete</button>}
</header>
{bindings.map(binding => (
<label key={`${binding.id}-${binding.property}`}>
<span>{binding.label}</span>
{binding.options ? (
<select
value={binding.rawValue}
onChange={e => binding.onChange(e.target.value)}
>
{binding.options.map(o => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
) : binding.type === 'jsx' || binding.type === 'richtext' ? (
<textarea
defaultValue={binding.rawValue}
onBlur={e => binding.onChange(e.target.value)}
/>
) : (
<input
defaultValue={binding.rawValue}
onBlur={e => binding.onChange(e.target.value)}
/>
)}
</label>
))}
</div>
);
};

Being an ordinary component rather than a callback is the point: a panel can hold its own state — a validation message, an open/closed group, a multi-select — without hoisting any of it into the component that renders Live.Dnd.

Wrapping the built-in panel instead of starting from zero

You don't have to replace the panel to add to it. Live.Dnd.Panel is the built-in one, so keep it and put your own chrome around it:

const MyPanel = () => (
<div>
<MyCustomHeader />
<Live.Dnd.Panel className="h-auto" />
</div>
);

It reads useDndPanel() itself, so wrapping it can't drop a callback on the way through — onNodeChange included. (className is merged over the region's defaults, so h-auto overrides its own h-full and the header above it doesn't push it out of view.)

onNodeChange matters more than it looks. bindings covers the elements extract() finds by walking the section's markup, which does not include JSX nested inside an attribute value — the elements held in an items or children binding. The built-in array/children editors discover those by re-extracting that value themselves, and commit them through onNodeChange, addressing each by its own data-id. No single PanelBinding.onChange can stand in for it, because each one is bound to a fixed element.

Most shipped sections are affected: in stats, 9 of the section's 10 editable elements are only reachable this way. Drop onNodeChange and those edits are silent no-ops — the fields still render and accept typing, but nothing commits.

Reusing the built-in control for one binding

Keeping Live.Dnd.Panel is all-or-nothing: you get the built-in panel for every field. When you want your own controls for most bindings but not all of them, Live.Dnd.Field renders the built-in control for a single binding:

const MyPanel = () => {
const { bindings, onNodeChange } = useDndPanel();

return (
<div>
{bindings.map(binding => (
<label key={`${binding.id}-${binding.property}`}>
<span>{binding.label}</span>
{binding.type === 'array' || binding.property === 'children' ? (
<Live.Dnd.Field binding={binding} onNodeChange={onNodeChange} />
) : (
<MyOwnControl binding={binding} />
)}
</label>
))}
</div>
);
};

Field takes nothing but a PanelBinding out of bindings and the same onNodeChange — both already part of what useDndPanel() returns, so no internal wiring is involved.

It renders the control only. The built-in panel's Label (property) heading comes from the row around it, not from Field, so supply your own as above.

This is worth the most on items/children/array bindings. Those hold nested data-bound JSX that bindings can't reach, and reproducing the editor means re-parsing the value's JSX, walking it for data-id / data-binding pairs, and translating item positions back to array element positions before every edit. Field already does that. For a plain string, number or option binding there's little reason to reach for it — a bare <input> wired to binding.onChange is the simpler answer.

Your own markup for an array binding: useItemsEditor

Field gives you the built-in control. When you want the same engine but a different layout, useItemsEditor is that engine on its own:

import { useItemsEditor } from '@jbpark/live-editor/dnd';

const MyItems = ({ binding, onNodeChange }) => {
const { items, selection, actions } = useItemsEditor(binding.rawValue, {
render: binding.render,
onChange: binding.onChange,
onNodeChange,
});

return (
<div>
<button onClick={actions.add}>Add</button>
{items.map(item => (
<details key={item.id}>
<summary>
#{item.index + 1}
<button onClick={() => actions.remove(item.elementIndex)}>x</button>
</summary>

{/* plain properties of an object item */}
{item.properties.map(prop => (
<input
key={prop.label}
defaultValue={prop.rawValue}
onBlur={e => prop.onChange(e.target.value)}
/>
))}

{/* the nested elements `bindings` can't reach */}
{item.nested.map(group =>
group.elements.map(el =>
el.bindings.map(b => (
<Live.Dnd.Field key={b.label} binding={b} onNodeChange={onNodeChange} />
)),
),
)}
</details>
))}
</div>
);
};

Everything it returns is a PanelBinding, so it composes with Field — render your own layout and still hand individual bindings to the built-in control where that's good enough, as above.

What you don't have to reimplement:

  • Re-parsing each item's JSX to find nested data-bound elements, and resolving the binding render map onto each property.
  • Translating a visible item position (index) to its position in the array's elements (elementIndex), which is what every edit addresses. They differ whenever the array mixes objects and primitives.
  • Reconciling the selection after a move or delete. Positions shift while the count stays the same, so the selection has to be cleared or replaced or a later bulk action hits the wrong elements.
FieldTypeDescription
kind'object' | 'primitive'Which kind the array is being edited as. An array holding both is treated as objects; the primitives are left untouched rather than dropped.
itemsItemsEditorItem[]One entry per item. properties holds an object item's plain fields, nested its JSX-valued ones, value a primitive item's own binding.
selectionmulti-select stateselected, toggle, isSelected, clear, replace. Indices are item index values; the actions translate.
actionsItemsEditorActionsadd, move, remove, plus duplicateSelected / moveSelected / removeSelected.
parseErrorbooleanThe source didn't parse as an array. items is empty.

The built-in Items panel is written against exactly this hook, so anything it can do is reachable here.

Array source preservation and limits

Value edits replace only the selected value's source span. Original array indices are retained: holes and spread entries are omitted from the visible list, but they remain in the source. For example, changing A in [, {label:'A'}, {label:'B'}] leaves the leading hole and B untouched.

OperationDense arraysArrays with holes or spreads
Edit a supported literal or object propertySupportedSupported at its original syntax position
Move, remove, duplicate, appendSupportedRefused; edit the source code instead
Edit a spread entry itselfNot applicableRefused

Structural edits preserve raw element text, comments, whitespace and trailing commas. Gaps between elements stay in their original positions during moves; deleting an element removes its expression and the necessary separator while leaving surrounding comments. Copies retain expressions and nested JSX, with fresh static key and data-id values. Dynamic/ambiguous copy identities, colliding generated IDs, and parenthesized top-level elements are refused. The existing last-item guard and empty-array Add limitation remain in place.

A refused operation returns null from the AST helper, leaves source and selection unchanged, and displays an error through the built-in hook. Public helper signatures are unchanged. Formatting of returned code now follows the original source rather than Babel's whole-array formatting.

Your own controls for a children binding: useChildrenEditor

Pass the structured binding.value to useChildrenEditor. It sends structural commands through the existing binding.onChange callback:

import { useChildrenEditor } from '@jbpark/live-editor/dnd';

const MyChildren = ({ binding }) => {
const { items, selection, actions } = useChildrenEditor(binding.value, {
onChange: binding.onChange,
});

return (
<div>
<button onClick={actions.add}>Add child</button>
<button onClick={actions.duplicateSelected}>Duplicate selected</button>
<button onClick={actions.removeSelected}>Delete selected</button>
{items.map((item, index) => (
<div key={item.id ?? index}>
<input
type="checkbox"
checked={selection.isSelected(index)}
onChange={() => selection.toggle(index)}
/>
Child {index + 1}
<button disabled={index === 0} onClick={() => actions.move(index, index - 1)}>
Move up
</button>
<button onClick={() => actions.remove(index)}>Delete</button>
</div>
))}
</div>
);
};

items contains the extracted DataAttrNode children. selection exposes selected, toggle, isSelected, clear, and replace. Actions are add, move(from, to), remove(index), duplicateSelected, moveSelected('up' | 'down'), and removeSelected. All indices refer to the current extracted list. For nested field controls, use Live.Dnd.Field with onNodeChange as shown above.

Structural edits preserve each subtree's original source. Text, comments, and expressions between children stay in their original slots. Copies are appended before the closing tag, with fresh data-id values on copied JSX elements. Add copies the first child, or inserts an empty div when the list is empty. Selection clears when the host supplies changed source, including undo and external edits; a rejected edit leaves the selection intact.

The AST layer refuses stale commands, invalid indices, unmodeled empty or expression-only fragments, and copies whose dynamic IDs or trailing spreads make identity ambiguous. A refused edit returns success: false and keeps the original code; the built-in panel displays an error. Self-closing parents must be expanded before adding children.

Existing JSON DataAttrNode[] inputs to update() remain supported for reorder and removal. Unchanged children reuse their original source. Arbitrary modeled replacements are accepted only when the old children can be represented without source loss and the result introduces no duplicate static IDs. Prefer the hook for structural operations; retain the extracted source metadata for stale-edit checks. Low-level callers can use ChildrenEdit and getChildrenSignatures from @jbpark/live-editor/utils/ast.

PanelBinding

FieldTypeDescription
idstringdata-id of the owning element (stable across edits).
labelstringHuman-readable label from the binding definition.
propertystringThe bound prop/attribute name.
typeBindingType?The declared data kind — what the value is (string, number, boolean, ...). undefined means a plain string binding. See the full list below.
widgetstring?The declared presentation — how to render it. An open string, not BindingType: the library can't enumerate controls it doesn't implement, so a custom panel is free to declare and switch on any value it wants (e.g. 'slider'). The built-in panel only recognizes 'icon-picker'/'asset-picker'.
optionsBindingOption[]?Present when the binding defines a fixed option set (render a <select>).
renderBindingRenderMap?Present on object/array bindings whose nested keys/items declare their own types — walk it to type each nested field instead of treating the value as an opaque string.
minnumber?Minimum value for a number binding. Compared against the real number value delivers — pass it straight to validateBindingValue.
maxnumber?Maximum value for a number binding.
patternstring?A regex source the value must match.
requiredboolean?Whether an empty value should fail validation.
metaRecord<string, unknown>?Any authored key that isn't one of the fields above (a step increment, a unit suffix, a group name, ...) — see below. undefined when nothing extra was authored, not an empty object.
valueunknownCurrent value as its real JS type — a number for a number binding, a boolean, an object/array, a string otherwise. Switch on it without re-parsing.
rawValuestringThe exact source text behind value, for cases that can't round-trip through a JS value (jsx/richtext, or an expression attribute you want to edit as text). Use it for an <input> defaultValue and for flattenEditableValue/setEditableValue.
onChange(value: unknown) => voidCommit an edit through the same AST-update pipeline (including the error toast on a bad edit). Pass the value as its real type — it's serialized once, at the AST boundary, where the declared type is known, so no string quoting on your side.

BindingType is a closed union of 12 values: array, object, string, number, boolean, color, jsx, richtext, date, url, icon-picker, asset-picker. Switching on it alone is enough for the scalar types; for object/array values, see flattenEditableValue below rather than treating them as plain text.

An authored type this library doesn't recognize (a typo, or a future value) doesn't reject the binding — it's dropped, so binding.type is simply undefined rather than the string you wrote. The same degrade, not delete, behavior applies one level down: an unrecognized type inside a render map leaves that leaf's own type undefined instead of removing the leaf entirely.

Any other key you author on a binding beyond the ones in the table above — one this library doesn't declare at all, like a step increment or a unit suffix — isn't dropped either; it survives under binding.meta, namespaced there instead of spread onto the binding itself so it can't collide with a future first-class field:

// data-binding={[{ label: 'Spacing', property: 'size', type: 'number', widget: 'slider', min: 0, max: 40, step: 4, unit: 'px' }]}

bindings.map(binding => {
const step = typeof binding.meta?.step === 'number' ? binding.meta.step : 1;
const unit = typeof binding.meta?.unit === 'string' ? binding.meta.unit : '';

return binding.widget === 'slider' ? (
<input
type="range"
min={binding.min}
max={binding.max}
step={step}
defaultValue={binding.rawValue}
onChange={e => binding.onChange(e.target.value)}
/>
) : (
// ...
null
);
});

meta's values are typed unknown — the library can't know what shape a consumer's own metadata takes — so narrow them, as above, before use. The shipped Custom Palette & Panel demo's "Content Spacing" field does exactly this; drag it in the embedded demo above to see it live.

icon-picker and asset-picker are kept in BindingType only so existing authored content (data-binding={[{ type: 'icon-picker', ... }]}) keeps parsing — they describe a control, not a data kind, so parseBinding normalizes them into { type: 'string', widget: 'icon-picker' } / { type: 'string', widget: 'asset-picker' } rather than passing them through as-is. A custom panel should switch on binding.widget for these, not binding.type. The built-in panel's icon set is exported as ICON_MAP (name -> lucide-react component), and the same label/value pairs it feeds its own <select> as ICON_OPTIONS, if you want to reuse either instead of building your own:

import { ICON_MAP, ICON_OPTIONS } from '@jbpark/live-editor';

Author a brand-new field with any other widget the same way — type still describes the data kind (so validation/coercion keeps working), widget is free text your own panel switches on:

// data-binding={[{ label: 'Spacing', property: 'size', type: 'number', widget: 'slider', min: 0, max: 40 }]}

bindings.map(binding =>
binding.widget === 'slider' ? (
<input
type="range"
min={binding.min}
max={binding.max}
defaultValue={binding.rawValue}
onChange={e => binding.onChange(e.target.value)}
/>
) : (
// ...fall back to type-based defaults
<input defaultValue={binding.rawValue} onBlur={e => binding.onChange(e.target.value)} />
),
);

Constraints declared on a binding (min/max/pattern/required) aren't enforced automatically — call the exported validateBindingValue(binding, value) yourself before committing an edit, the same way the built-in panel does.

Authoring constraints

Two things a data-binding entry has to satisfy for its edits to actually commit, neither enforced at parse time:

  • property must already exist as an attribute on the element. binding.onChange writes through the same AST-update pipeline the built-in panel uses, which updates an existing JSX attribute — it doesn't add a new one. A binding whose property names something not already present in the markup (as a prop, or as the element's own innerText/children/etc.) parses fine and shows up in bindings, but every onChange call fails silently on the built-in panel's side (a "Failed to update this field" toast) or returns success: false if you're calling update() yourself.
  • label must be unique per element. label is a display string, not an identifier, but where a binding doesn't declare property it's still how the built-in AST utilities resolve which binding you mean. Two bindings on the same element sharing a label is an authoring mistake update() refuses to silently guess at — it returns success: false instead of writing whichever one happened to match first.

Escaping the binding system entirely

data-binding covers declared, per-property edits. For anything else — restructuring markup, adding a whole new element, editing something with no binding declared at all — useDndPanel()'s item.code is the section's full current source, and the exported extract() walks it into the same DataAttrNode tree the built-in panel reads (including custom, entirely undeclared data-* attributes, which extract() passes through intact). Commit any string back with onChange({ code }):

import { extract } from '@jbpark/live-editor/utils/ast';

const RawEditor = () => {
const { item, onChange } = useDndPanel();

if (!item) return null;

const nodes = extract(item.code); // read whatever you need

return (
<button onClick={() => onChange({ code: item.code.replace(/foo/, 'bar') })}>
Edit raw source
</button>
);
};

This is a full escape hatch, not a fallback of last resort — it's the same mechanism Editor mode itself commits through.

Editing object/array values

For an object/array binding, binding.value is already the parsed object or array, and binding.rawValue is its serialized source text. A binding's own declared render map (above) is one way to know how to decompose it, but most content doesn't declare one; e.g. the shipped Hero item's "Background Style" binding on style has no type or render at all, since style is just naturally object-shaped.

flattenEditableValue(rawValue) recovers editable structure straight from the source text, with no declaration required: it parses the string (the same way parseValue does) and, if the result is an object or array, recursively walks it, returning one { path, value } entry per primitive leaf found. path is a list of keys/indices you can hand to setEditableValue to commit an edit to just that leaf, re-serializing the whole structure back into a string for binding.onChange. A nested object/array is walked into further; a string, number, or boolean is always a leaf — including one that happens to contain JSX. This is what lets it handle the shipped Stats/FAQ sections' items arrays of { key, children }, where children is itself a separately data-bound JSX element: children comes back as one opaque text leaf rather than being decomposed further, exactly like parseValue already treats JSX elsewhere.

const entries = flattenEditableValue(binding.rawValue);
// null if the value isn't an object/array, or is empty

entries?.map(({ path, value }) => (
<input
key={path.join('.')}
defaultValue={String(value)}
onBlur={e =>
binding.onChange(setEditableValue(binding.rawValue, path, e.target.value))
}
/>
));

BindingType, BindingOption, BindingRenderMap, validateBindingValue, flattenEditableValue, and setEditableValue aren't exported from the package root — import them from the utils/ast subpath instead:

import {
type BindingOption,
type BindingRenderMap,
type BindingType,
flattenEditableValue,
setEditableValue,
validateBindingValue,
} from '@jbpark/live-editor/utils/ast';