@binaryoperations/json-tree-editor
v2.0.1
Published
Interactive JSON tree editor — Solid components + framework-agnostic web component
Maintainers
Readme
@binaryoperations/json-tree-editor
Interactive JSON tree editor you can drop into any app: edit keys, types, and primitives in a collapsible tree while treating a JSON string as the single source of truth.
Use it as a SolidJS component (TypeScript source, peer solid-js) or as a framework-agnostic web component (<json-tree-editor> with Solid bundled).
Highlights
- Collapsible object/array tree with type changes, rename, add/delete, clear, duplicate
- Controlled
value/onChange(pretty JSON string) - Optional plugins (e.g. path-scoped history / undo-redo)
- In-tree search (Cmd/Ctrl+F), optional array drag-reorder, read-only mode
- CSS variables +
::parttheming
Install
npm install @binaryoperations/json-tree-editor
# or: pnpm add @binaryoperations/json-tree-editor
# or: yarn add @binaryoperations/json-tree-editorSolid apps also need solid-js and a Solid toolchain (for example vite-plugin-solid):
npm install solid-jsPackage entry points
| Import | What you get |
| --- | --- |
| @binaryoperations/json-tree-editor | JsonTreeView + props/handle types (peer solid-js) |
| @binaryoperations/json-tree-editor/plugin | definePlugin + plugin contract types |
| @binaryoperations/json-tree-editor/history | Path-scoped undo/redo — opt-in (history README) |
| @binaryoperations/json-tree-editor/breadcrumbs | Path bar + selectPath — opt-in (breadcrumbs README) |
| @binaryoperations/json-tree-editor/dnd | Array drag-and-drop (HTML5_ARRAY_REORDER, …) — opt-in |
| @binaryoperations/json-tree-editor/utils | Parse helpers, path utilities, lower-level primitives |
| @binaryoperations/json-tree-editor/web-component | Custom element class (no auto-register) |
| @binaryoperations/json-tree-editor/web-component/register | Defines <json-tree-editor> on import (Solid bundled; DnD on by default) |
| @binaryoperations/json-tree-editor/styles.css | Structure only (also inlined into the WC shadow) |
| @binaryoperations/json-tree-editor/themes/*.css | A complete look — each file @imports structure. Import exactly one. |
Quick start
import { createSignal } from 'solid-js';
import { JsonTreeView } from '@binaryoperations/json-tree-editor';
import '@binaryoperations/json-tree-editor/themes/default.css';
export function JsonPanel() {
const [source, setSource] = createSignal('{"hello":"world"}');
return (
<JsonTreeView
value={source()}
onChange={(prettyJson) => setSource(prettyJson)}
/>
);
}<script type="module">
import '@binaryoperations/json-tree-editor/web-component/register';
import '@binaryoperations/json-tree-editor/themes/default.css';
const el = document.querySelector('json-tree-editor');
el.value = JSON.stringify({ hello: 'world', count: 1 }, null, 2);
el.addEventListener('change', (e) => {
console.log(e.detail.value); // pretty JSON string
});
</script>
<json-tree-editor></json-tree-editor>import '@binaryoperations/json-tree-editor/web-component/register';
import '@binaryoperations/json-tree-editor/themes/default.css';
import type { JsonTreeEditorElement } from '@binaryoperations/json-tree-editor/web-component';
const el = document.querySelector('json-tree-editor') as JsonTreeEditorElement;
el.value = '{"name":"Ada"}';
el.addEventListener('change', (event) => {
const { value } = (event as CustomEvent<{ value: string }>).detail;
console.log(value);
});SolidJS API
Import one theme in your app entry or layout (it pulls in structure):
import '@binaryoperations/json-tree-editor/themes/default.css';JsonTreeView props
| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| value | string | yes | JSON document source (parsed internally) |
| onChange | (prettyJson: string) => void | yes | Called after an edit with pretty JSON (2-space indent) |
| defaultExpandedDepth | number | no | Nesting levels open on mount (0 = root only, default) |
| arrayReorder | ArrayReorderController \| false | no | Array drag-reorder. Omit / false = off. Pass HTML5_ARRAY_REORDER from /dnd |
| plugins | JsonTreeEditorPlugin[] | no | Editor plugins (e.g. historyPlugin()). Stable by plugin name |
| readOnly | boolean | no | Browse/expand only; no mutations |
| search | boolean | no | In-tree find (Cmd/Ctrl+F). Default true |
| ref | handle or callback | no | Imperative API (see below) |
Ref handle
| Method | Description |
| --- | --- |
| getRoot() | Root .json-tree DOM element (or null before mount) |
| use(plugin) | Register a plugin; returns dispose |
| callCommand(name, …args) | Invoke a registered command (e.g. history undo) |
| hasCommand(name) | Whether a command is registered |
Keep the source string as document truth: pass it as value, push tree edits back via onChange.
Root rules (applied inside the view): blank source → valid empty object {}. Root must be an object or array. Syntax errors keep a last-good tree visible with an error banner; tree edits still emit pretty JSON so you can recover.
Plugins
Plugins are opt-in modules that observe document transactions, register commands, and may contribute UI. Core stays free of undo stacks / CRDTs / chrome.
| Surface | How |
| --- | --- |
| Solid | plugins={[myPlugin()]} and/or handle.use(myPlugin()) |
| Web component | el.plugins = […] and/or el.use(plugin) (queues until connected) |
| Commands | handle.callCommand('…') / el.callCommand('…') |
Command registry: first registrant is master for a command name; later plugins are subordinates. Types: @binaryoperations/json-tree-editor/plugin. Architecture: plugin system PRD.
Prefer stable plugin instances. Identity is by
name, and a changed list reinstalls the set; hoisting the array to aconstkeeps that from happening on unrelated re-renders. (An inlineplugins={[myPlugin()]}is read once and cached, so it works — it is just easier to churn by accident.)
The contract
type JsonTreeEditorPlugin = {
name: string;
/** Slot for `render`. Default `'head'`. Ignored without `render`. */
stage?: 'head' | 'tail';
setup(ctx: PluginContext): void | (() => void);
/** Optional UI. Called once, in the slot named by `stage`. */
render?(stage: 'head' | 'tail', ctx: PluginContext): JSX.Element | null;
};render — plugin UI
A plugin that needs chrome (a path bar, a status strip, a toolbar) returns it from
render instead of asking the host to place a component.
- Slots.
stage: 'head'(default) mounts above everything — before the error banner, the find bar and the tree.stage: 'tail'mounts below the tree scroller. - Called once per plugin, in its own slot. The
stageargument is passed so one component can serve both placements; it is not an invitation to render twice. - Reactive context.
renderruns inside the view's reactive root, so Solid primitives are safe and cleanup runs on unmount. Hold plugin state in signals created by the factory and written fromsetup. - Isolated. A throwing
renderis logged and skipped — it cannot blank the tree. - Solid hosts only.
JsonTreeViewand the web component render it; headless hosts ignore it. Returnnullto contribute nothing.
Plugins that render usually also need the view's DOM mechanics. Those are published as commands, and are registered only once at least one plugin is installed:
| Command | Signature | Does |
| --- | --- | --- |
| json-tree.expandPath | (path) => true | Expand every ancestor of path (and path itself when it is a container) |
| json-tree.revealPath | (path, { focus? }) => Promise<HTMLElement \| null> | Scroll the row into view once it exists and optionally focus it. Resolves the row element — so a plugin can decorate it — or null if it never appears |
| json-tree.getFocusedPath | () => JsonPath | The roving-tabindex row |
| json-tree.onFocusedPathChange | (cb) => unsubscribe | Fires when the focused row changes |
The split is deliberate: the view owns what needs its internals (expand state, sticky-aware
scrolling) and hands back the element. Everything else — navigation policy, decoration —
stays in the plugin. selectPath and its flash ring are composed from these primitives by
the breadcrumbs plugin, not built in.
setup runs untracked, so reading editor state there does not subscribe the host's
install effect to it.
import { definePlugin } from '@binaryoperations/json-tree-editor/plugin';
export function statusPlugin() {
const [count, setCount] = createSignal(0);
return definePlugin({
name: 'status',
stage: 'tail',
setup(ctx) {
return ctx.onTransaction(() => setCount((n) => n + 1));
},
render(_stage, ctx) {
return <footer>{count()} edits · {ctx.getState().validity.ok ? 'valid' : 'invalid'}</footer>;
},
});
}Breadcrumbs plugin
Import: @binaryoperations/json-tree-editor/breadcrumbs
A path bar for the focused row, and the selectPath command — expand ancestors,
scroll the row into view, flash a ring around its key.
→ Full docs: src/breadcrumbs/README.md
import { breadcrumbsPlugin } from '@binaryoperations/json-tree-editor/breadcrumbs';
const plugins = [breadcrumbsPlugin()];
<JsonTreeView value={json()} onChange={setJson} plugins={plugins} />
// The bar is rendered by the plugin — no extra markup.
await handle.callCommand('selectPath', ['meta', 'author', 'email']);History plugin
Import: @binaryoperations/json-tree-editor/history
Path-scoped undo/redo — subtree deltas on the stack, not full-document copies.
→ Full docs: src/history/README.md
Design: history PRD.
import { JsonTreeView } from '@binaryoperations/json-tree-editor';
import { historyPlugin } from '@binaryoperations/json-tree-editor/history';
const plugins = [historyPlugin({ maxDepth: 50 })];
<JsonTreeView value={json()} onChange={setJson} plugins={plugins} />
// handle.callCommand('undo' | 'redo' | 'canUndo' | 'readHistory' | …)import '@binaryoperations/json-tree-editor/web-component/register';
import '@binaryoperations/json-tree-editor/themes/default.css';
import { historyPlugin } from '@binaryoperations/json-tree-editor/history';
const el = document.querySelector('json-tree-editor')!;
el.use(historyPlugin({ maxDepth: 50 }));
el.callCommand('undo');Web component API
| Surface | Type | Notes |
| --- | --- | --- |
| Property value | string | Preferred source of truth (esp. large JSON) |
| Attribute value | string | Optional; reflected only when length ≤ ~8KB |
| defaultExpandedDepth / default-expanded-depth | number | Nesting levels open on mount (default 0) |
| readOnly / readonly | boolean | Browse/expand only |
| arrayReorder / array-reorder | boolean | Array DnD (default on for WC). Set false to disable |
| search | boolean | In-tree find (default on) |
| Property plugins | JsonTreeEditorPlugin[] | Replace plugin set (no HTML attribute) |
| Method use(plugin) | () => void dispose | Register one plugin (pre-connect queue OK) |
| Method callCommand(name, …) | varies | Invoke plugin commands |
| Method hasCommand(name) | boolean | Command registered? |
| Method getRoot() | HTMLDivElement \| null | .json-tree in shadow DOM |
| Event change / json-change | CustomEvent<{ value: string }> | After tree edit (pretty JSON) |
Styles live in an open shadow DOM. Theme tokens sit on :host (see Theming).
DnD (/dnd)
Array reorder is opt-in on Solid so the core entry stays smaller. The web component enables HTML5 DnD by default. Also exports types and path helpers for custom controllers.
import { JsonTreeView } from '@binaryoperations/json-tree-editor';
import { HTML5_ARRAY_REORDER } from '@binaryoperations/json-tree-editor/dnd';
<JsonTreeView
value={source()}
onChange={setSource}
arrayReorder={HTML5_ARRAY_REORDER}
/>Utils (/utils)
Path helpers (getAtPath, setAtPath, insertAtPath, …), parse helpers (parseJsonSource, JsonValidity), and lower-level primitives. Most apps only need the package root.
Theming
Import exactly one theme. Each file @imports styles.css (structure), so you do not import structure separately. High-contrast replaces default — do not load both.
import '@binaryoperations/json-tree-editor/themes/default.css';
// or:
import '@binaryoperations/json-tree-editor/themes/high-contrast.css';Theme files set tokens on json-tree-editor and .json-tree. They do not use :host — that selector is the custom element only inside a shadow stylesheet, and this package does not style the host from the shadow. Size json-tree-editor from the page (display: block is in the document copy of styles.css). The web component still inlines structure into its shadow so class rules apply there; the document theme supplies tokens (custom properties inherit).
@import '@binaryoperations/json-tree-editor/themes/default.css';Force a side on the host (or on .json-tree for Solid):
json-tree-editor,
.json-tree {
color-scheme: light; /* or dark; omit to keep system */
}Public tokens use --jte--{block}[--{modifier}|__{element}]. There are no aliases for the old --jte-* names (--jte-bg, --jte-string, …). Value color lives on .json-tree-value--{type} — .json-tree-input--string (and --number / --boolean / --null) are gone.
Token groups
Override any of these on json-tree-editor or .json-tree. Chip -bg / -border tokens are optional; the default preset derives them with color-mix. High-contrast sets opaque fills.
| Group | Tokens |
| --- | --- |
| --jte--json-tree--* | bg, color, border, muted, surface, font, font-mono, font-size, focus |
| Row | --jte--json-tree-row--hover / --focus / --search-active |
| Action | --jte--json-tree-action, --jte--json-tree-action--danger, --jte--json-tree-action--disabled |
| Key | --jte--json-tree-key, --root, --index |
| Type | --jte--json-tree-type--{string\|number\|boolean\|null\|object\|array} (+ optional -bg / -border) |
| Value | --jte--json-tree-value--{type} (defaults to the matching type token) |
| Mark | --jte--json-tree-mark, --mark-bg, --mark--active, --mark--active-bg |
| Flash | --jte--json-tree-flash-ring (fill is color-mix(… 20%, transparent)) |
| Error | --jte--json-tree__error, --error-bg, --error-border |
json-tree-editor,
.json-tree {
--jte--json-tree-type--string: light-dark(#0b3, #6f6);
--jte--json-tree--font-mono: ui-monospace, Menlo, Consolas, monospace;
--jte--json-tree--font-size: 12.5px;
color-scheme: light; /* optional force */
}Web component ::part map
Stems match BEM after json-tree- / json-tree__. Variants are extra space-separated names on the same node (part="action delete"). There is no disabled part.
| Part | Node |
| --- | --- |
| tree | .json-tree |
| scroll | .json-tree__scroll |
| error | .json-tree__error |
| search | Find bar |
| search-icon | Find icon |
| search-input | Find field |
| search-count | Match count |
| action search-prev / search-next / search-close | Find buttons |
| row (+ search-active when that class is on) | Tree row |
| drag-handle | Array reorder handle |
| chevron (+ open or leaf) | Expand/collapse cue |
| key (+ root and/or index) | Property key |
| type + JSON type (string, …) | Type chip / select |
| value + JSON type | Value wrapper |
| input | Key / value input |
| null | Null affordance |
| summary | Container summary |
| actions | Row action cluster |
| action duplicate / action delete | Duplicate / delete |
| add-row | Expand / add toolbar |
| action expand-children / collapse-children | Expand / collapse children |
| action add-key / add-item / clear | Add key / item / clear |
| children | Nested group |
| mark (+ active) | Search highlight |
| breadcrumbs | Path bar |
| crumb (+ current and/or index) | Path crumb |
| breadcrumb-sep | Crumb separator |
| breadcrumb-ellipsis | Truncation ellipsis |
| flash-ring | Reveal ring (only while .json-tree-flash-ring is on) |
json-tree-editor::part(row) { }
json-tree-editor::part(search) { }
json-tree-editor::part(action delete) { }
json-tree-editor::part(type string) { }
json-tree-editor::part(flash-ring) { }On the Solid path, the same tokens apply; you can also target BEM classes (.json-tree-row, .json-tree-value--string, …) after importing a theme.
Keyboard
Focus a tree row (or Tab to the active row). Arrow keys move among visible rows. Navigation is disabled while focus is inside an input, select, or textarea.
| Key | Action |
| --- | --- |
| ↓ / ↑ | Next / previous visible row |
| → | Expand collapsed container, else first child |
| ← | Collapse expanded container, else parent |
| Home / End | First / last visible row |
| Cmd/Ctrl+F | Open in-tree find (when search enabled) |
Roving tabindex keeps one visible role="treeitem" tabbable.
Changelog
See CHANGELOG.md.
Demos
Clone the monorepo and run the local demo package (see the repository README):
| Page | Description |
| --- | --- |
| / | Solid three-pane editor |
| /large.html | ~5k-node stress test |
| /wc.html | Vanilla web component host |
| /history.html | Solid + historyPlugin (stack UI + bootstrap snippet) |
| /wc-history.html | WC + historyPlugin (stack UI + bootstrap snippet) |
pnpm install
pnpm dev
# or: pnpm --filter @json-tree-editor/demo dev:historyLicense
MIT © 2026 Shashank
