JSON Tree Viewer
Collapsible JSON tree with live search, match navigation, expand/collapse all, copy, and a Raw text tab. Zero dependencies, syntax-colored by value type.
MCP
Code
:root {
--jv-canvas: #0a0e16;
--jv-surface: #121a27;
--jv-surface-2: #18222f;
--jv-surface-3: #1e2937;
--jv-line: #28323f;
--jv-ink: #e7edf5;
--jv-ink-soft: #c2cdda;
--jv-muted: #93a1b5;
--jv-accent: #5b9bff;
--jv-amber: #f4b34a;
--jv-green: #34d399;
--jv-red: #f87171;
--jv-violet: #c77dff;
--jv-font-ui: "Hanken Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--jv-font-mono: "JetBrains Mono", ui-monospace, Menlo, monospace;
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: 28px;
background: var(--jv-canvas);
color: var(--jv-ink);
font-family: var(--jv-font-ui);
}
.jv-shell { display: flex; flex-direction: column; min-height: 0; min-width: 0; width: 100%; max-width: 1180px; margin: 0 auto; }
.jv-toolbar {
display: flex; align-items: center; gap: 8px;
padding: 8px 10px;
background: var(--jv-surface-2);
border: 1px solid var(--jv-line);
border-bottom: none;
border-radius: 8px 8px 0 0;
flex-wrap: nowrap;
min-width: 0;
}
.jv-title { font-size: 12px; font-weight: 700; color: var(--jv-ink); letter-spacing: .02em; white-space: nowrap; flex: 0 0 auto; }
.jv-search {
background: var(--jv-surface); border: 1px solid var(--jv-line);
border-radius: 6px; color: var(--jv-ink);
font-family: var(--jv-font-ui); font-size: 12px; padding: 5px 10px;
outline: none; flex: 1 1 90px; min-width: 70px;
}
.jv-search:focus { border-color: var(--jv-accent); }
.jv-search:disabled { opacity: .4; cursor: not-allowed; }
.jv-actions { display: flex; gap: 6px; flex: 0 0 auto; align-items: center; }
.jv-btn {
background: var(--jv-surface-3); border: 1px solid var(--jv-line);
color: var(--jv-ink-soft); font-family: var(--jv-font-ui); font-size: 12px;
padding: 5px 11px; border-radius: 6px; cursor: pointer;
transition: color .15s, background .15s, border-color .15s;
}
.jv-btn:hover { background: var(--jv-line); color: var(--jv-ink); }
.jv-btn-icon { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 26px; padding: 0; flex: 0 0 auto; }
.jv-btn-icon svg { display: block; }
.jv-btn.jv-copied { color: var(--jv-green); border-color: var(--jv-green); background: rgba(52, 211, 153, .12); }
.jv-mode { display: inline-flex; border: 1px solid var(--jv-line); border-radius: 6px; overflow: hidden; }
.jv-mode-btn {
background: var(--jv-surface); border: none; color: var(--jv-muted);
font-family: var(--jv-font-ui); font-size: 12px; padding: 5px 12px; cursor: pointer;
}
.jv-mode-btn.active { background: var(--jv-accent); color: #06101f; font-weight: 600; }
.jv-wrap {
background: var(--jv-canvas);
border: 1px solid var(--jv-line);
border-radius: 0 0 8px 8px;
padding: 12px; overflow: auto;
min-height: 200px; max-height: 70vh;
}
.jv-tree[hidden], .jv-raw[hidden] { display: none !important; }
.jv-tree {
font-family: var(--jv-font-mono); font-size: 12.5px; line-height: 1.7;
color: var(--jv-ink-soft);
font-variant-numeric: tabular-nums;
}
.jv-raw {
width: 100%; min-height: 320px; resize: vertical;
background: transparent; color: var(--jv-ink);
border: none; outline: none; padding: 0;
font-family: var(--jv-font-mono); font-size: 12.5px; line-height: 1.55;
white-space: pre; tab-size: 2;
}
.jv-node { display: block; }
.jv-row { display: flex; align-items: flex-start; padding: 1px 0; border-radius: 4px; min-width: 0; }
.jv-row:hover { background: rgba(255, 255, 255, .035); }
.jv-row.jv-highlight { background: rgba(91, 155, 255, .12); }
.jv-indent { flex: 0 0 auto; }
.jv-toggle {
flex: 0 0 auto; width: 14px; color: var(--jv-muted);
font-size: 9px; cursor: pointer; user-select: none;
display: inline-block; transform: rotate(0deg); transition: transform .12s;
}
.jv-toggle.open { transform: rotate(90deg); }
.jv-toggle-space { flex: 0 0 auto; width: 14px; }
.jv-key { color: var(--jv-accent); }
.jv-colon, .jv-comma { color: var(--jv-muted); }
.jv-bracket { color: var(--jv-ink-soft); }
.jv-collapsed { color: var(--jv-muted); font-style: italic; }
.jv-val-str { color: var(--jv-green); }
.jv-val-num { color: var(--jv-amber); }
.jv-val-bool { color: var(--jv-violet); }
.jv-val-null { color: var(--jv-red); }
.jv-val-str, .jv-val-num, .jv-val-bool, .jv-val-null {
min-width: 0; overflow-wrap: anywhere; word-break: break-word; white-space: pre-wrap;
}
.jv-children.hidden { display: none; }
.jv-mark { background: var(--jv-amber); color: #06101f; border-radius: 2px; padding: 0 1px; }
.jv-mark.jv-mark-active { background: #ff8c42; outline: 2px solid rgba(255, 140, 66, .55); }
.jv-search-nav { display: inline-flex; align-items: center; gap: 4px; margin-left: 6px; }
.jv-search-counter { font-size: 11.5px; color: var(--jv-muted); font-variant-numeric: tabular-nums; min-width: 34px; text-align: right; }
.jv-nav-btn {
font-size: 10px; line-height: 1; padding: 4px 7px;
background: var(--jv-surface-3); border: 1px solid var(--jv-line);
color: var(--jv-ink-soft); border-radius: 4px; cursor: pointer;
}
.jv-nav-btn:hover:not(:disabled) { background: var(--jv-surface-2); color: var(--jv-ink); }
.jv-nav-btn:disabled { opacity: .35; cursor: default; }
.jv-empty { color: var(--jv-muted); font-family: var(--jv-font-ui); font-size: 13px; padding: 10px 4px; }
.jv-empty.jv-error { color: var(--jv-amber); }
@media (prefers-reduced-motion: reduce) {
.jv-toggle, .jv-btn { transition: none; }
}/* JSON Tree Viewer — vanilla, zero dependencies.
Exposes window.JsonTreeViewer = { mount, attachShell } */
(function () {
var INDENT = 18;
function escHtml(s) {
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
}
function JsonViewer(container) {
this.container = container;
this.data = undefined;
this.searchTerm = "";
this._mi = 0;
this.matchCount = 0;
this.activeMatch = 0;
this.onMatchesChanged = null;
}
JsonViewer.prototype.setData = function (data) {
this.data = data;
this.render();
};
JsonViewer.prototype.setSearch = function (term) {
this.searchTerm = String(term || "").trim();
this.activeMatch = 0;
this.render();
};
/* Wrap every occurrence of the search term in <mark>, numbering them so
navigation (prev/next) can address a specific hit. */
JsonViewer.prototype._highlight = function (text) {
var raw = String(text);
if (!this.searchTerm) return escHtml(raw);
var q = this.searchTerm.toLowerCase();
var lower = raw.toLowerCase();
var idx = lower.indexOf(q);
if (idx === -1) return escHtml(raw);
var out = "";
var from = 0;
while (idx !== -1) {
out += escHtml(raw.slice(from, idx));
out += '<mark class="jv-mark" data-mi="' + this._mi++ + '">' +
escHtml(raw.slice(idx, idx + this.searchTerm.length)) + "</mark>";
from = idx + this.searchTerm.length;
idx = lower.indexOf(q, from);
}
return out + escHtml(raw.slice(from));
};
JsonViewer.prototype._matches = function (key, value) {
if (!this.searchTerm) return false;
var q = this.searchTerm.toLowerCase();
if (key !== null && String(key).toLowerCase().indexOf(q) !== -1) return true;
if (value === null || typeof value !== "object") {
return String(value).toLowerCase().indexOf(q) !== -1;
}
return false;
};
JsonViewer.prototype._buildNode = function (key, value, depth, isLast) {
var self = this;
var node = document.createElement("div");
node.className = "jv-node";
var isObj = value !== null && typeof value === "object";
var isArr = Array.isArray(value);
var entries = isObj
? (isArr ? value.map(function (v, i) { return [i, v]; }) : Object.entries(value))
: null;
var isEmpty = isObj && entries.length === 0;
var comma = isLast ? "" : '<span class="jv-comma">,</span>';
var row = document.createElement("div");
row.className = "jv-row" + (this._matches(key, value) ? " jv-highlight" : "");
if (isObj && !isEmpty) {
var indentEl = document.createElement("span");
indentEl.className = "jv-indent";
indentEl.style.width = depth * INDENT + "px";
var toggle = document.createElement("span");
toggle.className = "jv-toggle open";
toggle.textContent = "▶";
row.appendChild(indentEl);
row.appendChild(toggle);
row.appendChild(document.createTextNode(" "));
if (key !== null) {
var keyEl = document.createElement("span");
keyEl.className = "jv-key";
keyEl.innerHTML = '"' + this._highlight(key) + '"';
var colonEl = document.createElement("span");
colonEl.className = "jv-colon";
colonEl.textContent = ":";
row.appendChild(keyEl);
row.appendChild(colonEl);
row.appendChild(document.createTextNode(" "));
}
var bracketEl = document.createElement("span");
bracketEl.className = "jv-bracket";
bracketEl.textContent = isArr ? "[" : "{";
row.appendChild(bracketEl);
var collapsedEl = document.createElement("span");
collapsedEl.className = "jv-collapsed";
collapsedEl.hidden = true;
collapsedEl.textContent = " … " + entries.length + " " +
(isArr ? "items ]" : "keys }");
row.appendChild(collapsedEl);
var children = document.createElement("div");
children.className = "jv-children";
entries.forEach(function (pair, i) {
children.appendChild(self._buildNode(pair[0], pair[1], depth + 1, i === entries.length - 1));
});
var closeRow = document.createElement("div");
closeRow.className = "jv-row";
closeRow.innerHTML =
'<span class="jv-indent" style="width:' + depth * INDENT + 'px"></span>' +
'<span class="jv-toggle-space"></span> <span class="jv-bracket">' +
(isArr ? "]" : "}") + "</span>" + comma;
row.style.cursor = "pointer";
row.addEventListener("click", function () {
var collapsed = children.classList.toggle("hidden");
toggle.classList.toggle("open", !collapsed);
collapsedEl.hidden = !collapsed;
closeRow.hidden = collapsed;
});
node.appendChild(row);
node.appendChild(children);
node.appendChild(closeRow);
} else {
var valHtml;
if (isEmpty) {
valHtml = '<span class="jv-bracket">' + (isArr ? "[]" : "{}") + "</span>";
} else if (value === null) {
valHtml = '<span class="jv-val-null">null</span>';
} else if (typeof value === "string") {
valHtml = '<span class="jv-val-str">"' + this._highlight(value) + '"</span>';
} else if (typeof value === "number") {
valHtml = '<span class="jv-val-num">' + this._highlight(String(value)) + "</span>";
} else if (typeof value === "boolean") {
valHtml = '<span class="jv-val-bool">' + value + "</span>";
} else {
valHtml = escHtml(String(value));
}
var keyStr = key !== null
? '<span class="jv-key">"' + this._highlight(key) + '"</span><span class="jv-colon">:</span> '
: "";
row.innerHTML =
'<span class="jv-indent" style="width:' + depth * INDENT + 'px"></span>' +
'<span class="jv-toggle-space"></span> ' + keyStr + valHtml + comma;
node.appendChild(row);
}
return node;
};
JsonViewer.prototype.render = function () {
if (!this.container) return;
this.container.innerHTML = "";
this._mi = 0;
if (this.data === undefined) {
this.container.textContent = "—";
this.matchCount = 0;
if (this.onMatchesChanged) this.onMatchesChanged();
return;
}
this.container.appendChild(this._buildNode(null, this.data, 0, true));
this.matchCount = this._mi;
if (this.activeMatch >= this.matchCount) this.activeMatch = 0;
this._applyActiveMatch(false);
if (this.onMatchesChanged) this.onMatchesChanged();
};
/* A hit can live inside collapsed branches — open every ancestor. */
JsonViewer.prototype._revealAncestors = function (elm) {
var childrenEl = elm.closest(".jv-children");
while (childrenEl) {
if (childrenEl.classList.contains("hidden")) {
childrenEl.classList.remove("hidden");
var row = childrenEl.previousElementSibling;
if (row) {
var t = row.querySelector(".jv-toggle");
if (t) t.classList.add("open");
var col = row.querySelector(".jv-collapsed");
if (col) col.hidden = true;
}
var close = childrenEl.nextElementSibling;
if (close) close.hidden = false;
}
childrenEl = childrenEl.parentElement ? childrenEl.parentElement.closest(".jv-children") : null;
}
};
JsonViewer.prototype._applyActiveMatch = function (scroll) {
if (!this.container) return;
var marks = this.container.querySelectorAll("mark.jv-mark");
marks.forEach(function (m) { m.classList.remove("jv-mark-active"); });
if (!marks.length) return;
if (this.activeMatch < 0) this.activeMatch = marks.length - 1;
if (this.activeMatch >= marks.length) this.activeMatch = 0;
var active = marks[this.activeMatch];
if (!active) return;
active.classList.add("jv-mark-active");
this._revealAncestors(active);
if (scroll) active.scrollIntoView({ block: "center", behavior: "smooth" });
};
JsonViewer.prototype.gotoMatch = function (delta) {
if (!this.matchCount) return;
this.activeMatch = (this.activeMatch + delta + this.matchCount) % this.matchCount;
this._applyActiveMatch(true);
if (this.onMatchesChanged) this.onMatchesChanged();
};
JsonViewer.prototype.expandAll = function () {
if (!this.container) return;
this.container.querySelectorAll(".jv-children.hidden").forEach(function (c) {
c.classList.remove("hidden");
var row = c.previousElementSibling;
if (row) {
var t = row.querySelector(".jv-toggle");
if (t) t.classList.add("open");
var col = row.querySelector(".jv-collapsed");
if (col) col.hidden = true;
}
var close = c.nextElementSibling;
if (close) close.hidden = false;
});
};
/* Collapse everything except the root node, so the shape stays readable. */
JsonViewer.prototype.collapseAll = function () {
if (!this.container) return;
var rootTree = this.container;
this.container.querySelectorAll(".jv-children").forEach(function (c) {
var ownerNode = c.closest(".jv-node");
if (ownerNode && ownerNode.parentElement === rootTree) return;
c.classList.add("hidden");
var row = c.previousElementSibling;
if (row) {
var t = row.querySelector(".jv-toggle");
if (t) t.classList.remove("open");
var col = row.querySelector(".jv-collapsed");
if (col) col.hidden = false;
}
var close = c.nextElementSibling;
if (close) close.hidden = true;
});
};
function attachShell(shell, opts) {
opts = opts || {};
var treeEl = shell.querySelector("[data-jv-tree]");
var rawEl = shell.querySelector("[data-jv-raw]");
var searchEl = shell.querySelector("[data-jv-search]");
var viewer = new JsonViewer(treeEl);
var mode = "tree";
function getText() {
if (typeof opts.getText === "function") return opts.getText();
return rawEl ? rawEl.value : "";
}
function hasText() {
var t = getText();
return !!(t && t.trim());
}
function renderTree() {
if (!hasText()) {
treeEl.innerHTML = '<div class="jv-empty">Nothing to display.</div>';
return;
}
try {
viewer.setData(JSON.parse(getText()));
viewer.collapseAll();
if (searchEl && searchEl.value.trim()) viewer.setSearch(searchEl.value.trim());
} catch (e) {
treeEl.innerHTML = '<div class="jv-empty jv-error">Invalid JSON — switch to Raw to fix it.</div>';
}
}
function setMode(next) {
mode = next;
shell.querySelectorAll("[data-jv-mode]").forEach(function (b) {
b.classList.toggle("active", b.getAttribute("data-jv-mode") === next);
});
var rawMode = next === "raw";
treeEl.hidden = rawMode;
if (rawEl) rawEl.hidden = !rawMode;
if (searchEl) searchEl.disabled = rawMode;
if (!rawMode) renderTree();
}
function refresh() {
if (mode === "tree" && rawEl && !rawEl.readOnly && !hasText()) setMode("raw");
else setMode(mode);
}
var navCounter = null, navPrev = null, navNext = null;
if (searchEl) {
var nav = document.createElement("span");
nav.className = "jv-search-nav";
nav.innerHTML =
'<span class="jv-search-counter"></span>' +
'<button type="button" class="jv-nav-btn" data-jv-nav="prev" title="Previous (Shift+Enter)">▲</button>' +
'<button type="button" class="jv-nav-btn" data-jv-nav="next" title="Next (Enter)">▼</button>';
searchEl.insertAdjacentElement("afterend", nav);
navCounter = nav.querySelector(".jv-search-counter");
navPrev = nav.querySelector('[data-jv-nav="prev"]');
navNext = nav.querySelector('[data-jv-nav="next"]');
navPrev.addEventListener("click", function () { viewer.gotoMatch(-1); });
navNext.addEventListener("click", function () { viewer.gotoMatch(1); });
}
function updateMatchNav() {
if (!navCounter) return;
var hasTerm = !!(searchEl && searchEl.value.trim());
navCounter.textContent = viewer.matchCount
? viewer.activeMatch + 1 + "/" + viewer.matchCount
: (hasTerm ? "0/0" : "");
navPrev.disabled = navNext.disabled = viewer.matchCount === 0;
}
viewer.onMatchesChanged = updateMatchNav;
if (searchEl) {
searchEl.addEventListener("input", function () {
if (mode !== "tree") return;
var term = searchEl.value.trim();
if (term) {
viewer.setSearch(term);
viewer._applyActiveMatch(true);
} else {
viewer.setSearch("");
viewer.collapseAll();
}
updateMatchNav();
});
searchEl.addEventListener("keydown", function (e) {
if (e.key === "Enter") {
e.preventDefault();
viewer.gotoMatch(e.shiftKey ? -1 : 1);
}
});
}
shell.querySelectorAll("[data-jv-act]").forEach(function (btn) {
btn.addEventListener("click", function () {
var act = btn.getAttribute("data-jv-act");
if (act === "expand") viewer.expandAll();
else if (act === "collapse") viewer.collapseAll();
else if (act === "copy") {
if (navigator.clipboard) navigator.clipboard.writeText(getText());
btn.classList.add("jv-copied");
setTimeout(function () { btn.classList.remove("jv-copied"); }, 1200);
}
});
});
shell.querySelectorAll("[data-jv-mode]").forEach(function (btn) {
btn.addEventListener("click", function () { setMode(btn.getAttribute("data-jv-mode")); });
});
return {
refresh: refresh,
setMode: setMode,
viewer: viewer,
seed: function (text) {
if (rawEl) rawEl.value = text || "";
refresh();
},
};
}
window.JsonTreeViewer = {
mount: function (container, data) {
var v = new JsonViewer(container);
v.setData(data);
return v;
},
attachShell: attachShell,
};
/* ---- demo boot ---- */
var DEMO = {
profileId: "prof_8842himlox",
locale: "en-US",
plan: { tier: "PREMIUM", maxStreams: 4, resolution: "4K_HDR", adSupported: false },
device: { type: "SMART_TV", os: "Tizen 7.0", drm: "widevine_l1", hdrCapable: true },
homeRows: [
{
rowId: "row_continue_watching",
title: "Continue Watching",
rankingModel: "cw-recency-v3",
items: [
{
id: 81234561,
title: "Ashes of the Empire",
type: "SERIES",
genres: ["sci-fi", "drama"],
maturityRating: "TV-MA",
progress: { season: 2, episode: 5, positionSec: 1834, durationSec: 3120, percent: 58.8 },
badges: ["NEW_EPISODES", "TOP_10"],
artwork: {
boxart: "https://img.example.cdn/boxart/81234561.jpg",
titleLogo: "https://img.example.cdn/logo/81234561.png",
billboard: null,
},
},
{
id: 70298731,
title: "Midnight Recipe",
type: "MOVIE",
genres: ["thriller"],
maturityRating: "R",
progress: { positionSec: 421, durationSec: 6980, percent: 6.0 },
badges: [],
artwork: { boxart: "https://img.example.cdn/boxart/70298731.jpg", titleLogo: null, billboard: null },
},
],
},
{
rowId: "row_top_picks",
title: "Top Picks for You",
rankingModel: "personalized-rank-v12",
items: [
{
id: 81990045,
title: "The Last Cartographer",
type: "SERIES",
genres: ["adventure", "mystery"],
maturityRating: "TV-14",
matchScore: 0.97,
isOriginal: true,
badges: ["ORIGINAL", "AWARD_WINNER"],
seasons: 3,
audioLocales: ["en", "es", "fr", "ja"],
subtitleLocales: ["en", "es", "pt-BR", "de"],
},
],
},
],
playbackSession: {
sessionId: "pbs_02fb7d1e",
titleId: 81234561,
cdn: { provider: "open-connect", pop: "bog01", throughputKbps: 42311 },
stream: {
videoCodec: "hevc",
audioCodec: "eac3-atmos",
bitrateLadder: [
{ resolution: "3840x2160", kbps: 15000 },
{ resolution: "1920x1080", kbps: 5800 },
{ resolution: "1280x720", kbps: 3000 },
],
currentBitrateKbps: 15000,
bufferSec: 26.4,
droppedFrames: 2,
},
abTests: { skipIntroV2: "treatment", nextEpCountdown: "control" },
},
billing: {
country: "US",
currency: "USD",
monthlyPrice: 22.99,
nextBillingDate: "2026-08-05",
paymentMethod: { type: "CARD", last4: "4821", expiring: false },
},
};
document.querySelectorAll("[data-jv-shell]").forEach(function (shell) {
attachShell(shell).seed(JSON.stringify(DEMO, null, 2));
});
})();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>JSON Tree Viewer</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="jv-shell" data-jv-shell="demo">
<div class="jv-toolbar">
<span class="jv-title">Payload</span>
<input class="jv-search" type="text" placeholder="Search…" data-jv-search aria-label="Search in payload">
<div class="jv-actions">
<button type="button" class="jv-btn jv-btn-icon" data-jv-act="expand" title="Expand all" aria-label="Expand all">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 9 12 4 17 9"/><polyline points="7 15 12 20 17 15"/></svg>
</button>
<button type="button" class="jv-btn jv-btn-icon" data-jv-act="collapse" title="Collapse all" aria-label="Collapse all">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 4 12 9 17 4"/><polyline points="7 20 12 15 17 20"/></svg>
</button>
<button type="button" class="jv-btn jv-btn-icon" data-jv-act="copy" title="Copy JSON" aria-label="Copy JSON">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>
</button>
<div class="jv-mode">
<button type="button" class="jv-mode-btn active" data-jv-mode="tree">Tree</button>
<button type="button" class="jv-mode-btn" data-jv-mode="raw">Raw</button>
</div>
</div>
</div>
<div class="jv-wrap">
<div class="jv-tree" data-jv-tree></div>
<textarea class="jv-raw" data-jv-raw hidden spellcheck="false"></textarea>
</div>
</div>
<script src="script.js"></script>
</body>
</html>"use client";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
const INDENT = 18;
type Json = null | boolean | number | string | Json[] | { [k: string]: Json };
/* Self-contained styles so the component renders standalone (lab demo).
In a real app, move this to a stylesheet. */
const JV_CSS = `
.jv-shell { --jv-canvas:#0a0e16; --jv-surface:#121a27; --jv-surface-2:#18222f; --jv-surface-3:#1e2937; --jv-line:#28323f; --jv-ink:#e7edf5; --jv-ink-soft:#c2cdda; --jv-muted:#93a1b5; --jv-accent:#5b9bff; --jv-amber:#f4b34a; --jv-green:#34d399; --jv-red:#f87171; --jv-violet:#c77dff; --jv-font-ui:"Hanken Grotesk",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; --jv-font-mono:"JetBrains Mono",ui-monospace,Menlo,monospace;
display:flex; flex-direction:column; min-height:0; min-width:0; width:100%; max-width:1180px; margin:28px auto; font-family:var(--jv-font-ui); color:var(--jv-ink); }
.jv-shell *, .jv-shell *::before, .jv-shell *::after { box-sizing:border-box; }
.jv-toolbar { display:flex; align-items:center; gap:8px; padding:8px 10px; background:var(--jv-surface-2); border:1px solid var(--jv-line); border-bottom:none; border-radius:8px 8px 0 0; flex-wrap:nowrap; min-width:0; }
.jv-title { font-size:12px; font-weight:700; color:var(--jv-ink); letter-spacing:.02em; white-space:nowrap; flex:0 0 auto; }
.jv-search { background:var(--jv-surface); border:1px solid var(--jv-line); border-radius:6px; color:var(--jv-ink); font-family:var(--jv-font-ui); font-size:12px; padding:5px 10px; outline:none; flex:1 1 90px; min-width:70px; }
.jv-search:focus { border-color:var(--jv-accent); }
.jv-search:disabled { opacity:.4; cursor:not-allowed; }
.jv-actions { display:flex; gap:6px; flex:0 0 auto; align-items:center; }
.jv-btn { background:var(--jv-surface-3); border:1px solid var(--jv-line); color:var(--jv-ink-soft); font-family:var(--jv-font-ui); font-size:12px; padding:5px 11px; border-radius:6px; cursor:pointer; transition:color .15s,background .15s,border-color .15s; }
.jv-btn:hover { background:var(--jv-line); color:var(--jv-ink); }
.jv-btn-icon { display:inline-flex; align-items:center; justify-content:center; width:28px; height:26px; padding:0; flex:0 0 auto; }
.jv-btn-icon svg { display:block; }
.jv-btn.jv-copied { color:var(--jv-green); border-color:var(--jv-green); background:rgba(52,211,153,.12); }
.jv-mode { display:inline-flex; border:1px solid var(--jv-line); border-radius:6px; overflow:hidden; }
.jv-mode-btn { background:var(--jv-surface); border:none; color:var(--jv-muted); font-family:var(--jv-font-ui); font-size:12px; padding:5px 12px; cursor:pointer; }
.jv-mode-btn.active { background:var(--jv-accent); color:#06101f; font-weight:600; }
.jv-wrap { background:var(--jv-canvas); border:1px solid var(--jv-line); border-radius:0 0 8px 8px; padding:12px; overflow:auto; min-height:200px; max-height:70vh; }
.jv-tree[hidden], .jv-raw[hidden] { display:none !important; }
.jv-tree { font-family:var(--jv-font-mono); font-size:12.5px; line-height:1.7; color:var(--jv-ink-soft); font-variant-numeric:tabular-nums; }
.jv-raw { width:100%; min-height:320px; resize:vertical; background:transparent; color:var(--jv-ink); border:none; outline:none; padding:0; font-family:var(--jv-font-mono); font-size:12.5px; line-height:1.55; white-space:pre; tab-size:2; }
.jv-node { display:block; }
.jv-row { display:flex; align-items:flex-start; padding:1px 0; border-radius:4px; min-width:0; }
.jv-row:hover { background:rgba(255,255,255,.035); }
.jv-row.jv-highlight { background:rgba(91,155,255,.12); }
.jv-indent { flex:0 0 auto; }
.jv-toggle { flex:0 0 auto; width:14px; color:var(--jv-muted); font-size:9px; cursor:pointer; user-select:none; display:inline-block; transform:rotate(0deg); transition:transform .12s; }
.jv-toggle.open { transform:rotate(90deg); }
.jv-toggle-space { flex:0 0 auto; width:14px; }
.jv-key { color:var(--jv-accent); }
.jv-colon, .jv-comma { color:var(--jv-muted); }
.jv-bracket { color:var(--jv-ink-soft); }
.jv-collapsed { color:var(--jv-muted); font-style:italic; }
.jv-val-str { color:var(--jv-green); }
.jv-val-num { color:var(--jv-amber); }
.jv-val-bool { color:var(--jv-violet); }
.jv-val-null { color:var(--jv-red); }
.jv-val-str, .jv-val-num, .jv-val-bool, .jv-val-null { min-width:0; overflow-wrap:anywhere; word-break:break-word; white-space:pre-wrap; }
.jv-mark { background:var(--jv-amber); color:#06101f; border-radius:2px; padding:0 1px; }
.jv-mark.jv-mark-active { background:#ff8c42; outline:2px solid rgba(255,140,66,.55); }
.jv-search-nav { display:inline-flex; align-items:center; gap:4px; }
.jv-search-counter { font-size:11.5px; color:var(--jv-muted); font-variant-numeric:tabular-nums; min-width:34px; text-align:right; }
.jv-nav-btn { font-size:10px; line-height:1; padding:4px 7px; background:var(--jv-surface-3); border:1px solid var(--jv-line); color:var(--jv-ink-soft); border-radius:4px; cursor:pointer; }
.jv-nav-btn:hover:not(:disabled) { background:var(--jv-surface-2); color:var(--jv-ink); }
.jv-nav-btn:disabled { opacity:.35; cursor:default; }
.jv-empty { color:var(--jv-muted); font-family:var(--jv-font-ui); font-size:13px; padding:10px 4px; }
.jv-empty.jv-error { color:var(--jv-amber); }
@media (prefers-reduced-motion: reduce) { .jv-toggle, .jv-btn { transition:none; } }
`;
/* Demo payload used when no data/text prop is given. */
const DEMO_DATA: Json = {
profileId: "prof_8842himlox",
locale: "en-US",
plan: { tier: "PREMIUM", maxStreams: 4, resolution: "4K_HDR", adSupported: false },
device: { type: "SMART_TV", os: "Tizen 7.0", drm: "widevine_l1", hdrCapable: true },
homeRows: [
{
rowId: "row_continue_watching",
title: "Continue Watching",
rankingModel: "cw-recency-v3",
items: [
{
id: 81234561,
title: "Ashes of the Empire",
type: "SERIES",
genres: ["sci-fi", "drama"],
maturityRating: "TV-MA",
progress: { season: 2, episode: 5, positionSec: 1834, durationSec: 3120, percent: 58.8 },
badges: ["NEW_EPISODES", "TOP_10"],
artwork: {
boxart: "https://img.example.cdn/boxart/81234561.jpg",
titleLogo: "https://img.example.cdn/logo/81234561.png",
billboard: null,
},
},
{
id: 70298731,
title: "Midnight Recipe",
type: "MOVIE",
genres: ["thriller"],
maturityRating: "R",
progress: { positionSec: 421, durationSec: 6980, percent: 6.0 },
badges: [],
artwork: { boxart: "https://img.example.cdn/boxart/70298731.jpg", titleLogo: null, billboard: null },
},
],
},
{
rowId: "row_top_picks",
title: "Top Picks for You",
rankingModel: "personalized-rank-v12",
items: [
{
id: 81990045,
title: "The Last Cartographer",
type: "SERIES",
genres: ["adventure", "mystery"],
maturityRating: "TV-14",
matchScore: 0.97,
isOriginal: true,
badges: ["ORIGINAL", "AWARD_WINNER"],
seasons: 3,
audioLocales: ["en", "es", "fr", "ja"],
subtitleLocales: ["en", "es", "pt-BR", "de"],
},
],
},
],
playbackSession: {
sessionId: "pbs_02fb7d1e",
titleId: 81234561,
cdn: { provider: "open-connect", pop: "bog01", throughputKbps: 42311 },
stream: {
videoCodec: "hevc",
audioCodec: "eac3-atmos",
bitrateLadder: [
{ resolution: "3840x2160", kbps: 15000 },
{ resolution: "1920x1080", kbps: 5800 },
{ resolution: "1280x720", kbps: 3000 },
],
currentBitrateKbps: 15000,
bufferSec: 26.4,
droppedFrames: 2,
},
abTests: { skipIntroV2: "treatment", nextEpCountdown: "control" },
},
billing: {
country: "US",
currency: "USD",
monthlyPrice: 22.99,
nextBillingDate: "2026-08-05",
paymentMethod: { type: "CARD", last4: "4821", expiring: false },
},
};
/* ---------------------------------------------------------------
Match indexing: the tree is walked once per (data, term) pair to
assign every highlighted occurrence a stable global index, so the
prev/next navigation can address one specific <mark>.
--------------------------------------------------------------- */
type Counter = { n: number };
function splitTerm(raw: string, term: string) {
if (!term) return [{ text: raw, hit: false }];
const q = term.toLowerCase();
const lower = raw.toLowerCase();
const parts: { text: string; hit: boolean }[] = [];
let from = 0;
let idx = lower.indexOf(q);
while (idx !== -1) {
if (idx > from) parts.push({ text: raw.slice(from, idx), hit: false });
parts.push({ text: raw.slice(idx, idx + term.length), hit: true });
from = idx + term.length;
idx = lower.indexOf(q, from);
}
if (from < raw.length) parts.push({ text: raw.slice(from), hit: false });
return parts;
}
function Highlight({
text,
term,
counter,
active,
}: {
text: string;
term: string;
counter: Counter;
active: number;
}) {
const parts = splitTerm(text, term);
return (
<>
{parts.map((p, i) => {
if (!p.hit) return <span key={i}>{p.text}</span>;
const mi = counter.n++;
return (
<mark
key={i}
className={"jv-mark" + (mi === active ? " jv-mark-active" : "")}
data-mi={mi}
>
{p.text}
</mark>
);
})}
</>
);
}
function isContainer(v: Json): v is Json[] | { [k: string]: Json } {
return v !== null && typeof v === "object";
}
function entriesOf(v: Json[] | { [k: string]: Json }): [string | number, Json][] {
return Array.isArray(v) ? v.map((x, i) => [i, x] as [number, Json]) : Object.entries(v);
}
/* Paths of every container that holds a match — used to auto-open branches. */
function pathsWithMatches(value: Json, term: string): Set<string> {
const open = new Set<string>();
if (!term) return open;
const q = term.toLowerCase();
const walk = (v: Json, key: string | number | null, path: string): boolean => {
let hit = key !== null && String(key).toLowerCase().includes(q);
if (isContainer(v)) {
for (const [k, child] of entriesOf(v)) {
if (walk(child, k, path + "/" + k)) hit = true;
}
if (hit) open.add(path);
} else if (String(v).toLowerCase().includes(q)) {
hit = true;
}
return hit;
};
walk(value, null, "");
return open;
}
function valueClass(v: Json) {
if (v === null) return "jv-val-null";
if (typeof v === "string") return "jv-val-str";
if (typeof v === "number") return "jv-val-num";
return "jv-val-bool";
}
function Node({
nodeKey,
value,
depth,
isLast,
path,
open,
toggle,
term,
counter,
active,
}: {
nodeKey: string | number | null;
value: Json;
depth: number;
isLast: boolean;
path: string;
open: Set<string>;
toggle: (path: string) => void;
term: string;
counter: Counter;
active: number;
}) {
const q = term.toLowerCase();
const container = isContainer(value);
const entries = container ? entriesOf(value) : [];
const empty = container && entries.length === 0;
const comma = isLast ? null : <span className="jv-comma">,</span>;
const isArr = Array.isArray(value);
const matched =
!!term &&
((nodeKey !== null && String(nodeKey).toLowerCase().includes(q)) ||
(!container && String(value).toLowerCase().includes(q)));
const keyLabel: ReactNode =
nodeKey === null ? null : (
<>
<span className="jv-key">
"<Highlight text={String(nodeKey)} term={term} counter={counter} active={active} />"
</span>
<span className="jv-colon">:</span>{" "}
</>
);
if (container && !empty) {
const isOpen = open.has(path);
return (
<div className="jv-node">
<div
className={"jv-row" + (matched ? " jv-highlight" : "")}
style={{ cursor: "pointer" }}
onClick={() => toggle(path)}
>
<span className="jv-indent" style={{ width: depth * INDENT }} />
<span className={"jv-toggle" + (isOpen ? " open" : "")}>▶</span>{" "}
{keyLabel}
<span className="jv-bracket">{isArr ? "[" : "{"}</span>
{!isOpen && (
<span className="jv-collapsed">
{" … "}
{entries.length} {isArr ? "items ]" : "keys }"}
</span>
)}
</div>
{isOpen && (
<>
<div className="jv-children">
{entries.map(([k, child], i) => (
<Node
key={k}
nodeKey={k}
value={child}
depth={depth + 1}
isLast={i === entries.length - 1}
path={path + "/" + k}
open={open}
toggle={toggle}
term={term}
counter={counter}
active={active}
/>
))}
</div>
<div className="jv-row">
<span className="jv-indent" style={{ width: depth * INDENT }} />
<span className="jv-toggle-space" />{" "}
<span className="jv-bracket">{isArr ? "]" : "}"}</span>
{comma}
</div>
</>
)}
</div>
);
}
let valueNode: ReactNode;
if (empty) {
valueNode = <span className="jv-bracket">{isArr ? "[]" : "{}"}</span>;
} else if (typeof value === "string") {
valueNode = (
<span className="jv-val-str">
"<Highlight text={value} term={term} counter={counter} active={active} />"
</span>
);
} else if (typeof value === "number") {
valueNode = (
<span className="jv-val-num">
<Highlight text={String(value)} term={term} counter={counter} active={active} />
</span>
);
} else {
valueNode = <span className={valueClass(value)}>{String(value)}</span>;
}
return (
<div className="jv-node">
<div className={"jv-row" + (matched ? " jv-highlight" : "")}>
<span className="jv-indent" style={{ width: depth * INDENT }} />
<span className="jv-toggle-space" /> {keyLabel}
{valueNode}
{comma}
</div>
</div>
);
}
export type JsonTreeViewerProps = {
data?: Json;
/** Raw JSON text; when provided it wins over `data` and enables the Raw tab round-trip. */
text?: string;
title?: string;
/** Depth kept open on first render. */
defaultOpenDepth?: number;
};
export default function JsonTreeViewer({
data = DEMO_DATA,
text,
title = "Payload",
defaultOpenDepth = 1,
}: JsonTreeViewerProps) {
const [mode, setMode] = useState<"tree" | "raw">("tree");
const [term, setTerm] = useState("");
const [active, setActive] = useState(0);
const [copied, setCopied] = useState(false);
const treeRef = useRef<HTMLDivElement>(null);
const rawText = useMemo(
() => text ?? (data === undefined ? "" : JSON.stringify(data, null, 2)),
[text, data],
);
const parsed = useMemo<{ value?: Json; error?: boolean }>(() => {
if (text === undefined) return { value: data };
if (!text.trim()) return {};
try {
return { value: JSON.parse(text) as Json };
} catch {
return { error: true };
}
}, [text, data]);
const value = parsed.value;
const allPaths = useMemo(() => {
const acc: { path: string; depth: number }[] = [];
const walk = (v: Json, path: string, depth: number) => {
if (!isContainer(v)) return;
const es = entriesOf(v);
if (es.length) acc.push({ path, depth });
for (const [k, child] of es) walk(child, path + "/" + k, depth + 1);
};
if (value !== undefined) walk(value, "", 0);
return acc;
}, [value]);
const [open, setOpen] = useState<Set<string>>(new Set());
useEffect(() => {
setOpen(new Set(allPaths.filter((p) => p.depth < defaultOpenDepth).map((p) => p.path)));
}, [allPaths, defaultOpenDepth]);
// A hit can sit inside a collapsed branch — open every ancestor that holds one.
useEffect(() => {
if (!term || value === undefined) return;
const needed = pathsWithMatches(value, term);
setOpen((prev) => {
const next = new Set(prev);
let changed = false;
needed.forEach((p) => {
if (!next.has(p)) {
next.add(p);
changed = true;
}
});
return changed ? next : prev;
});
setActive(0);
}, [term, value]);
const toggle = useCallback((path: string) => {
setOpen((prev) => {
const next = new Set(prev);
if (!next.delete(path)) next.add(path);
return next;
});
}, []);
const counter: Counter = { n: 0 };
const [matchCount, setMatchCount] = useState(0);
// Marks only exist after paint, so count and scroll in a layout effect.
useLayoutEffect(() => {
const marks = treeRef.current?.querySelectorAll("mark.jv-mark") ?? [];
setMatchCount(marks.length);
const el = marks[active] as HTMLElement | undefined;
if (el) el.scrollIntoView({ block: "center", behavior: "smooth" });
}, [term, open, active, value]);
const goto = (delta: number) => {
if (!matchCount) return;
setActive((a) => (a + delta + matchCount) % matchCount);
};
const copy = () => {
if (navigator.clipboard) navigator.clipboard.writeText(rawText);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
};
return (
<div className="jv-shell">
<style>{JV_CSS}</style>
<div className="jv-toolbar">
<span className="jv-title">{title}</span>
<input
className="jv-search"
type="text"
placeholder="Search…"
aria-label={`Search in ${title}`}
value={term}
disabled={mode === "raw"}
onChange={(e) => setTerm(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
goto(e.shiftKey ? -1 : 1);
}
}}
/>
<span className="jv-search-nav">
<span className="jv-search-counter">
{matchCount ? `${active + 1}/${matchCount}` : term.trim() ? "0/0" : ""}
</span>
<button
type="button"
className="jv-nav-btn"
disabled={!matchCount}
title="Previous (Shift+Enter)"
onClick={() => goto(-1)}
>
▲
</button>
<button
type="button"
className="jv-nav-btn"
disabled={!matchCount}
title="Next (Enter)"
onClick={() => goto(1)}
>
▼
</button>
</span>
<div className="jv-actions">
<button
type="button"
className="jv-btn jv-btn-icon"
title="Expand all"
aria-label="Expand all"
onClick={() => setOpen(new Set(allPaths.map((p) => p.path)))}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="7 9 12 4 17 9" />
<polyline points="7 15 12 20 17 15" />
</svg>
</button>
<button
type="button"
className="jv-btn jv-btn-icon"
title="Collapse all"
aria-label="Collapse all"
onClick={() => setOpen(new Set([""]))}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="7 4 12 9 17 4" />
<polyline points="7 20 12 15 17 20" />
</svg>
</button>
<button
type="button"
className={"jv-btn jv-btn-icon" + (copied ? " jv-copied" : "")}
title="Copy JSON"
aria-label="Copy JSON"
onClick={copy}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="9" width="11" height="11" rx="2" />
<path d="M5 15V5a2 2 0 0 1 2-2h10" />
</svg>
</button>
<div className="jv-mode">
<button
type="button"
className={"jv-mode-btn" + (mode === "tree" ? " active" : "")}
onClick={() => setMode("tree")}
>
Tree
</button>
<button
type="button"
className={"jv-mode-btn" + (mode === "raw" ? " active" : "")}
onClick={() => setMode("raw")}
>
Raw
</button>
</div>
</div>
</div>
<div className="jv-wrap">
<div className="jv-tree" ref={treeRef} hidden={mode !== "tree"}>
{parsed.error ? (
<div className="jv-empty jv-error">Invalid JSON — switch to Raw to fix it.</div>
) : value === undefined ? (
<div className="jv-empty">Nothing to display.</div>
) : (
<Node
nodeKey={null}
value={value}
depth={0}
isLast
path=""
open={open}
toggle={toggle}
term={term.trim()}
counter={counter}
active={active}
/>
)}
</div>
<textarea className="jv-raw" hidden={mode !== "raw"} readOnly spellCheck={false} value={rawText} />
</div>
</div>
);
}<script context="module" lang="ts">
export type Json = null | boolean | number | string | Json[] | { [k: string]: Json };
export const INDENT = 18;
export function isContainer(v: Json): v is Json[] | { [k: string]: Json } {
return v !== null && typeof v === "object";
}
export function entriesOf(v: Json[] | { [k: string]: Json }): [string | number, Json][] {
return Array.isArray(v) ? v.map((x, i) => [i, x] as [number, Json]) : Object.entries(v);
}
/** Split a string into plain/hit segments for search highlighting. */
export function splitTerm(raw: string, term: string) {
if (!term) return [{ text: raw, hit: false }];
const q = term.toLowerCase();
const lower = raw.toLowerCase();
const parts: { text: string; hit: boolean }[] = [];
let from = 0;
let idx = lower.indexOf(q);
while (idx !== -1) {
if (idx > from) parts.push({ text: raw.slice(from, idx), hit: false });
parts.push({ text: raw.slice(idx, idx + term.length), hit: true });
from = idx + term.length;
idx = lower.indexOf(q, from);
}
if (from < raw.length) parts.push({ text: raw.slice(from), hit: false });
return parts;
}
/** Paths of every container holding a match — used to auto-open branches. */
export function pathsWithMatches(value: Json, term: string): Set<string> {
const open = new Set<string>();
if (!term) return open;
const q = term.toLowerCase();
const walk = (v: Json, key: string | number | null, path: string): boolean => {
let hit = key !== null && String(key).toLowerCase().includes(q);
if (isContainer(v)) {
for (const [k, child] of entriesOf(v)) {
if (walk(child, k, path + "/" + k)) hit = true;
}
if (hit) open.add(path);
} else if (String(v).toLowerCase().includes(q)) {
hit = true;
}
return hit;
};
walk(value, null, "");
return open;
}
export function collectPaths(value: Json) {
const acc: { path: string; depth: number }[] = [];
const walk = (v: Json, path: string, depth: number) => {
if (!isContainer(v)) return;
const es = entriesOf(v);
if (es.length) acc.push({ path, depth });
for (const [k, child] of es) walk(child, path + "/" + k, depth + 1);
};
walk(value, "", 0);
return acc;
}
</script>
<script lang="ts">
import { tick } from "svelte";
import Node from "./JsonNode.svelte";
export let data: Json | undefined = undefined;
/** Raw JSON text; wins over `data` and drives the Raw tab. */
export let text: string | undefined = undefined;
export let title = "Payload";
export let defaultOpenDepth = 1;
let mode: "tree" | "raw" = "tree";
let term = "";
let active = 0;
let copied = false;
let matchCount = 0;
let treeEl: HTMLDivElement;
let open = new Set<string>();
$: rawText = text ?? (data === undefined ? "" : JSON.stringify(data, null, 2));
let parseError = false;
$: {
parseError = false;
if (text === undefined) {
value = data;
} else if (!text.trim()) {
value = undefined;
} else {
try {
value = JSON.parse(text) as Json;
} catch {
value = undefined;
parseError = true;
}
}
}
let value: Json | undefined;
$: allPaths = value === undefined ? [] : collectPaths(value);
$: open = new Set(allPaths.filter((p) => p.depth < defaultOpenDepth).map((p) => p.path));
const trimmed = () => term.trim();
function onSearch() {
const t = trimmed();
active = 0;
if (t && value !== undefined) {
const needed = pathsWithMatches(value, t);
needed.forEach((p) => open.add(p));
open = open;
}
refreshMatches();
}
async function refreshMatches() {
await tick();
const marks = treeEl?.querySelectorAll("mark.jv-mark") ?? [];
matchCount = marks.length;
if (active >= matchCount) active = 0;
// Marks are rendered by the recursive child, which has no global index —
// flag the active one here, where the DOM order is known.
marks.forEach((m) => m.classList.remove("jv-mark-active"));
const el = marks[active] as HTMLElement | undefined;
el?.classList.add("jv-mark-active");
el?.scrollIntoView({ block: "center", behavior: "smooth" });
}
function goto(delta: number) {
if (!matchCount) return;
active = (active + delta + matchCount) % matchCount;
refreshMatches();
}
function toggle(path: string) {
if (!open.delete(path)) open.add(path);
open = open;
refreshMatches();
}
function copy() {
if (navigator.clipboard) navigator.clipboard.writeText(rawText);
copied = true;
setTimeout(() => (copied = false), 1200);
}
</script>
<div class="jv-shell">
<div class="jv-toolbar">
<span class="jv-title">{title}</span>
<input
class="jv-search"
type="text"
placeholder="Search…"
aria-label={`Search in ${title}`}
bind:value={term}
disabled={mode === "raw"}
on:input={onSearch}
on:keydown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
goto(e.shiftKey ? -1 : 1);
}
}}
/>
<span class="jv-search-nav">
<span class="jv-search-counter">
{matchCount ? `${active + 1}/${matchCount}` : trimmed() ? "0/0" : ""}
</span>
<button type="button" class="jv-nav-btn" disabled={!matchCount} title="Previous (Shift+Enter)" on:click={() => goto(-1)}>▲</button>
<button type="button" class="jv-nav-btn" disabled={!matchCount} title="Next (Enter)" on:click={() => goto(1)}>▼</button>
</span>
<div class="jv-actions">
<button
type="button" class="jv-btn jv-btn-icon" title="Expand all" aria-label="Expand all"
on:click={() => { open = new Set(allPaths.map((p) => p.path)); refreshMatches(); }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 9 12 4 17 9" /><polyline points="7 15 12 20 17 15" /></svg>
</button>
<button
type="button" class="jv-btn jv-btn-icon" title="Collapse all" aria-label="Collapse all"
on:click={() => { open = new Set([""]); refreshMatches(); }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 4 12 9 17 4" /><polyline points="7 20 12 15 17 20" /></svg>
</button>
<button type="button" class="jv-btn jv-btn-icon" class:jv-copied={copied} title="Copy JSON" aria-label="Copy JSON" on:click={copy}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" rx="2" /><path d="M5 15V5a2 2 0 0 1 2-2h10" /></svg>
</button>
<div class="jv-mode">
<button type="button" class="jv-mode-btn" class:active={mode === "tree"} on:click={() => (mode = "tree")}>Tree</button>
<button type="button" class="jv-mode-btn" class:active={mode === "raw"} on:click={() => (mode = "raw")}>Raw</button>
</div>
</div>
</div>
<div class="jv-wrap">
<div class="jv-tree" bind:this={treeEl} hidden={mode !== "tree"}>
{#if parseError}
<div class="jv-empty jv-error">Invalid JSON — switch to Raw to fix it.</div>
{:else if value === undefined}
<div class="jv-empty">Nothing to display.</div>
{:else}
<Node
nodeKey={null}
value={value}
depth={0}
isLast={true}
path=""
{open}
{toggle}
term={trimmed()}
{active}
/>
{/if}
</div>
<textarea class="jv-raw" hidden={mode !== "raw"} readonly spellcheck="false" value={rawText}></textarea>
</div>
</div>
<!--
============================================================
JsonNode.svelte — recursive child component (same folder)
============================================================
<script lang="ts">
import { INDENT, isContainer, entriesOf, splitTerm, type Json } from "./JsonTreeViewer.svelte";
import Self from "./JsonNode.svelte";
export let nodeKey: string | number | null;
export let value: Json;
export let depth = 0;
export let isLast = true;
export let path = "";
export let open: Set<string>;
export let toggle: (path: string) => void;
export let term = "";
export let active = 0;
// Marks are numbered by DOM order after render; `active` is compared against
// the index the parent shell resolved from the live <mark> node list.
$: q = term.toLowerCase();
$: container = isContainer(value);
$: entries = container ? entriesOf(value as Json[]) : [];
$: empty = container && entries.length === 0;
$: isArr = Array.isArray(value);
$: isOpen = open.has(path);
$: matched =
!!term &&
((nodeKey !== null && String(nodeKey).toLowerCase().includes(q)) ||
(!container && String(value).toLowerCase().includes(q)));
function valueClass(v: Json) {
if (v === null) return "jv-val-null";
if (typeof v === "string") return "jv-val-str";
if (typeof v === "number") return "jv-val-num";
return "jv-val-bool";
}
</script>
{#if container && !empty}
<div class="jv-node">
<div class="jv-row" class:jv-highlight={matched} style="cursor:pointer" on:click={() => toggle(path)}>
<span class="jv-indent" style={`width:${depth * INDENT}px`}></span>
<span class="jv-toggle" class:open={isOpen}>▶</span>
{#if nodeKey !== null}
<span class="jv-key">"{#each splitTerm(String(nodeKey), term) as p}{#if p.hit}<mark class="jv-mark">{p.text}</mark>{:else}{p.text}{/if}{/each}"</span><span class="jv-colon">:</span>
{/if}
<span class="jv-bracket">{isArr ? "[" : "{"}</span>
{#if !isOpen}
<span class="jv-collapsed"> … {entries.length} {isArr ? "items ]" : "keys }"}</span>
{/if}
</div>
{#if isOpen}
<div class="jv-children">
{#each entries as [k, child], i (k)}
<Self nodeKey={k} value={child} depth={depth + 1} isLast={i === entries.length - 1}
path={path + "/" + k} {open} {toggle} {term} {active} />
{/each}
</div>
<div class="jv-row">
<span class="jv-indent" style={`width:${depth * INDENT}px`}></span>
<span class="jv-toggle-space"></span>
<span class="jv-bracket">{isArr ? "]" : "}"}</span>{#if !isLast}<span class="jv-comma">,</span>{/if}
</div>
{/if}
</div>
{:else}
<div class="jv-node">
<div class="jv-row" class:jv-highlight={matched}>
<span class="jv-indent" style={`width:${depth * INDENT}px`}></span>
<span class="jv-toggle-space"></span>
{#if nodeKey !== null}
<span class="jv-key">"{#each splitTerm(String(nodeKey), term) as p}{#if p.hit}<mark class="jv-mark">{p.text}</mark>{:else}{p.text}{/if}{/each}"</span><span class="jv-colon">:</span>
{/if}
{#if empty}
<span class="jv-bracket">{isArr ? "[]" : "{}"}</span>
{:else if typeof value === "string"}
<span class="jv-val-str">"{#each splitTerm(value, term) as p}{#if p.hit}<mark class="jv-mark">{p.text}</mark>{:else}{p.text}{/if}{/each}"</span>
{:else if typeof value === "number"}
<span class="jv-val-num">{#each splitTerm(String(value), term) as p}{#if p.hit}<mark class="jv-mark">{p.text}</mark>{:else}{p.text}{/if}{/each}</span>
{:else}
<span class={valueClass(value)}>{String(value)}</span>
{/if}
{#if !isLast}<span class="jv-comma">,</span>{/if}
</div>
</div>
{/if}
-->JSON Tree Viewer
A dark “instrument panel” JSON inspector for API payloads, webhook bodies, and debug dumps. Renders any parsed JSON as a collapsible tree, colored per value type, with an incremental search that jumps between hits and opens whatever branch they are hiding in.
Features
- Collapsible nodes — click any row to fold an object or array; collapsed rows show
… 12 keys }so structure stays legible - Type coloring — strings, numbers, booleans, and
nulleach get their own token color - Incremental search — matches both keys and values, highlights every occurrence, and counts them (
3/17) - Match navigation —
Enter/Shift+Enter(or ▲▼) cycle hits; the active hit scrolls into view and its collapsed ancestors auto-open - Expand / collapse all — collapse leaves the root open so the top-level shape is always visible
- Raw tab — the exact
JSON.stringify(data, null, 2)text, selectable and copyable - Copy button — writes the raw JSON to the clipboard with a green confirmation flash
- Invalid JSON — the tree shows a recoverable error instead of throwing, pointing at the Raw tab
How it works
- The tree is built by a recursive node renderer. Each node knows its
depth(indent =depth * 18px) and whether it is the last sibling (to place the trailing comma correctly). - Search highlighting splits every rendered string into plain/hit segments and wraps hits in
<mark class="jv-mark">. Marks are numbered in DOM order, which is what makes prev/next navigation addressable. - Because a hit can live inside a collapsed branch, the viewer walks the data to find every container path that contains a match and force-opens those paths before scrolling.
- Collapse-all deliberately skips the root node — a fully collapsed viewer shows nothing useful.
Variants
| Target | Entry point | Notes |
|---|---|---|
html | script.js | window.JsonTreeViewer.attachShell(el).seed(jsonText); auto-boots any [data-jv-shell] |
react | react.tsx | <JsonTreeViewer data={payload} /> or <JsonTreeViewer text={rawString} /> |
svelte | svelte.svelte | Shell component; the recursive JsonNode.svelte child is included in the comment block at the bottom — split it into its own file |
Usage
<!-- vanilla -->
<div class="jv-shell" data-jv-shell="payload">…toolbar + .jv-tree + .jv-raw…</div>
<script>
const api = window.JsonTreeViewer.attachShell(document.querySelector('[data-jv-shell="payload"]'));
api.seed(JSON.stringify(payload, null, 2));
</script>
// react
<JsonTreeViewer data={payload} title="Input Payload" defaultOpenDepth={2} />
<!-- svelte -->
<JsonTreeViewer data={payload} title="Input Payload" defaultOpenDepth={2} />
Use cases
- Debug panels for API request/response payloads
- Webhook and event inspectors
- Admin dashboards showing raw record state
- Internal tools where the JSON shape matters as much as its values
Accessibility
Search input is labelled, every icon button carries an aria-label, and the toggle rotation
is disabled under prefers-reduced-motion. Rows are click-targets rather than buttons — wrap
them in <button> if you need full keyboard traversal of the tree itself.
Customization
All colors come from --jv-* custom properties on :root. Override them to retheme the
viewer; --jv-accent drives keys and the active mode tab, --jv-amber drives search marks.
Change INDENT (18px) to tighten or widen nesting.