UI Components Medium
JSON Viewer
Collapsible JSON tree viewer with syntax coloring, expand/collapse nodes, copy path, and search highlighting. No libraries.
Open in Lab
MCP
vanilla-js css react tailwind vue svelte
Targets: TS JS HTML React Vue Svelte
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #0d1117;
min-height: 100vh;
padding: 32px 16px;
display: flex;
justify-content: center;
}
.demo {
width: 100%;
max-width: 720px;
}
.jv-toolbar {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: #21262d;
border-radius: 10px 10px 0 0;
border: 1px solid #30363d;
border-bottom: none;
flex-wrap: wrap;
}
.jv-title {
font-size: 13px;
font-weight: 700;
color: #e6edf3;
}
.jv-actions {
display: flex;
gap: 8px;
flex: 1;
justify-content: flex-end;
flex-wrap: wrap;
}
.jv-search {
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
color: #e6edf3;
font-size: 12px;
padding: 5px 10px;
outline: none;
width: 180px;
}
.jv-search:focus {
border-color: #6366f1;
}
.jv-btn {
background: #30363d;
border: none;
color: #8b949e;
font-size: 12px;
padding: 5px 12px;
border-radius: 6px;
cursor: pointer;
transition: color 0.15s, background 0.15s;
}
.jv-btn:hover {
background: #484f58;
color: #e6edf3;
}
.jv-wrap {
background: #161b22;
border: 1px solid #30363d;
border-radius: 0 0 10px 10px;
padding: 16px;
overflow: auto;
max-height: 520px;
}
.jv-tree {
font-family: "JetBrains Mono", "Fira Code", Menlo, monospace;
font-size: 13px;
line-height: 1.8;
}
/* Node */
.jv-node {
display: block;
}
.jv-row {
display: flex;
align-items: flex-start;
gap: 0;
padding: 1px 0;
border-radius: 4px;
cursor: default;
}
.jv-row:hover {
background: rgba(255, 255, 255, 0.04);
}
.jv-row.jv-highlight {
background: rgba(99, 102, 241, 0.15);
}
.jv-indent {
display: inline-block;
width: 20px;
flex-shrink: 0;
}
.jv-toggle {
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #8b949e;
user-select: none;
flex-shrink: 0;
font-size: 10px;
transition: transform 0.15s;
}
.jv-toggle.open {
transform: rotate(90deg);
}
.jv-toggle-space {
width: 16px;
flex-shrink: 0;
display: inline-block;
}
.jv-key {
color: #79c0ff;
}
.jv-colon {
color: #8b949e;
margin: 0 4px;
}
.jv-val-str {
color: #a5d6ff;
}
.jv-val-num {
color: #ffa657;
}
.jv-val-bool {
color: #ff7b72;
}
.jv-val-null {
color: #f85149;
font-style: italic;
}
.jv-val-obj {
color: #e6edf3;
}
.jv-bracket {
color: #8b949e;
}
.jv-comma {
color: #8b949e;
}
.jv-collapsed {
color: #8b949e;
font-size: 11px;
}
.jv-children {
display: block;
}
.jv-children.hidden {
display: none;
}const DATA = {
id: 42,
name: "Jane Smith",
email: "jane@example.com",
active: true,
score: 98.6,
role: null,
address: {
street: "123 Main St",
city: "New York",
zip: "10001",
country: "US",
},
tags: ["admin", "early-adopter", "pro"],
permissions: {
read: true,
write: true,
delete: false,
admin: false,
},
lastLogin: "2026-03-06T14:22:00Z",
metadata: {
createdAt: "2024-01-15",
updatedAt: "2026-03-06",
version: 3,
},
};
let searchTerm = "";
function escHtml(s) {
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
}
function highlight(text) {
if (!searchTerm) return escHtml(text);
const safe = escHtml(text);
const idx = safe.toLowerCase().indexOf(searchTerm.toLowerCase());
if (idx === -1) return safe;
return (
safe.slice(0, idx) +
`<mark style="background:#e3b341;color:#0d1117;border-radius:2px">${safe.slice(idx, idx + searchTerm.length)}</mark>` +
safe.slice(idx + searchTerm.length)
);
}
function matchesSearch(key, value) {
if (!searchTerm) return true;
const q = searchTerm.toLowerCase();
if (String(key).toLowerCase().includes(q)) return true;
if (typeof value !== "object" || value === null) {
if (String(value).toLowerCase().includes(q)) return true;
}
return false;
}
function buildNode(key, value, depth, isLast) {
const node = document.createElement("div");
node.className = "jv-node";
const isObj = value !== null && typeof value === "object";
const isArr = Array.isArray(value);
const entries = isObj ? (isArr ? value.map((v, i) => [i, v]) : Object.entries(value)) : null;
const isEmpty = isObj && entries.length === 0;
const comma = isLast ? "" : '<span class="jv-comma">,</span>';
const matched = matchesSearch(key, value);
const row = document.createElement("div");
row.className = "jv-row" + (matched && searchTerm ? " jv-highlight" : "");
if (isObj && !isEmpty) {
const openBracket = isArr ? "[" : "{";
const closeBracket = isArr ? "]" : "}";
// Build with DOM methods to preserve event listeners
const indentEl = document.createElement("span");
indentEl.className = "jv-indent";
indentEl.style.width = depth * 20 + "px";
const toggle = document.createElement("span");
toggle.className = "jv-toggle open";
toggle.textContent = "▶";
row.appendChild(indentEl);
row.appendChild(toggle);
row.appendChild(document.createTextNode("\u00a0"));
if (key !== null) {
const keyEl = document.createElement("span");
keyEl.className = "jv-key";
keyEl.innerHTML = `"${highlight(key)}"`;
const colonEl = document.createElement("span");
colonEl.className = "jv-colon";
colonEl.textContent = ":";
row.appendChild(keyEl);
row.appendChild(colonEl);
row.appendChild(document.createTextNode("\u00a0"));
}
const bracketEl = document.createElement("span");
bracketEl.className = "jv-bracket";
bracketEl.textContent = openBracket;
row.appendChild(bracketEl);
const collapsedEl = document.createElement("span");
collapsedEl.className = "jv-collapsed";
collapsedEl.hidden = true;
collapsedEl.innerHTML = ` … ${entries.length} ${isArr ? "items" : "keys"} `;
row.appendChild(collapsedEl);
const children = document.createElement("div");
children.className = "jv-children";
entries.forEach(([k, v], i) => {
children.appendChild(buildNode(k, v, depth + 1, i === entries.length - 1));
});
const closeRow = document.createElement("div");
closeRow.className = "jv-row";
closeRow.innerHTML = `<span class="jv-indent" style="width:${depth * 20}px"></span><span class="jv-toggle-space"></span> <span class="jv-bracket">${closeBracket}</span>${comma}`;
// Entire row is clickable
row.style.cursor = "pointer";
row.addEventListener("click", () => {
const open = children.classList.toggle("hidden");
toggle.classList.toggle("open", !open);
collapsedEl.hidden = !open;
closeRow.hidden = open;
});
node.appendChild(row);
node.appendChild(children);
node.appendChild(closeRow);
} else {
let 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">"${highlight(value)}"</span>`;
} else if (typeof value === "number") {
valHtml = `<span class="jv-val-num">${value}</span>`;
} else if (typeof value === "boolean") {
valHtml = `<span class="jv-val-bool">${value}</span>`;
} else {
valHtml = escHtml(String(value));
}
const keyStr =
key !== null
? `<span class="jv-key">"${highlight(key)}"</span><span class="jv-colon">:</span>`
: "";
row.innerHTML = `<span class="jv-indent" style="width:${depth * 20}px"></span><span class="jv-toggle-space"></span> ${keyStr}${valHtml}${comma}`;
node.appendChild(row);
}
return node;
}
function render() {
const tree = document.getElementById("jvTree");
tree.innerHTML = "";
tree.appendChild(buildNode(null, DATA, 0, true));
}
document.getElementById("expandAll").addEventListener("click", () => {
document.querySelectorAll(".jv-children").forEach((c) => {
c.classList.remove("hidden");
const nodeRow = c.previousElementSibling;
if (nodeRow) {
nodeRow.querySelector(".jv-toggle")?.classList.add("open");
nodeRow.querySelector(".jv-collapsed")?.setAttribute("hidden", "");
}
});
document.querySelectorAll(".jv-row[hidden]").forEach((r) => r.removeAttribute("hidden"));
});
document.getElementById("collapseAll").addEventListener("click", () => {
document.querySelectorAll(".jv-children").forEach((c) => {
if (c.closest(".jv-node")?.parentElement?.id !== "jvTree") {
c.classList.add("hidden");
const nodeRow = c.previousElementSibling;
if (nodeRow) {
nodeRow.querySelector(".jv-toggle")?.classList.remove("open");
const collapsed = nodeRow.querySelector(".jv-collapsed");
if (collapsed) collapsed.hidden = false;
const nextSibling = c.nextElementSibling;
if (nextSibling) nextSibling.hidden = true;
}
}
});
});
let searchTimeout;
document.getElementById("jvSearch").addEventListener("input", (e) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
searchTerm = e.target.value.trim();
render();
}, 200);
});
render();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>JSON Viewer</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="demo">
<div class="jv-toolbar">
<span class="jv-title">JSON Viewer</span>
<div class="jv-actions">
<input class="jv-search" id="jvSearch" type="search" placeholder="Search keys / values…" />
<button class="jv-btn" id="expandAll">Expand all</button>
<button class="jv-btn" id="collapseAll">Collapse all</button>
</div>
</div>
<div class="jv-wrap">
<div class="jv-tree" id="jvTree"></div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>import { useState, useMemo } from "react";
const SAMPLE: unknown = {
user: {
id: 42,
name: "Ada Lovelace",
email: "ada@example.com",
active: true,
score: 9.8,
tags: ["engineer", "pioneer", "mathematician"],
address: {
street: "123 Babbage Lane",
city: "London",
country: "UK",
geo: { lat: 51.5074, lng: -0.1278 },
},
},
meta: {
version: "1.0.0",
generated: "2026-01-01T00:00:00Z",
flags: [true, false, null],
},
};
type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
function typeColor(v: JsonValue): string {
if (v === null) return "text-[#ff7b72]";
if (typeof v === "boolean") return "text-[#79c0ff]";
if (typeof v === "number") return "text-[#79c0ff]";
if (typeof v === "string") return "text-[#a5d6ff]";
return "text-[#e6edf3]";
}
function typeLabel(v: JsonValue): string {
if (v === null) return "null";
if (typeof v === "boolean") return String(v);
if (typeof v === "number") return String(v);
if (typeof v === "string") return `"${v}"`;
if (Array.isArray(v)) return `Array(${v.length})`;
return `{${Object.keys(v as object).length}}`;
}
function isComplex(v: JsonValue): boolean {
return typeof v === "object" && v !== null;
}
function JsonNode({
keyName,
value,
depth,
search,
path,
}: {
keyName: string | null;
value: JsonValue;
depth: number;
search: string;
path: string;
}) {
const [open, setOpen] = useState(depth < 2);
const [copied, setCopied] = useState(false);
const complex = isComplex(value);
const entries: [string, JsonValue][] = complex
? Array.isArray(value)
? (value as JsonValue[]).map((v, i) => [String(i), v])
: Object.entries(value as Record<string, JsonValue>)
: [];
const highlight = (text: string) => {
if (!search || !text.toLowerCase().includes(search.toLowerCase())) return text;
const idx = text.toLowerCase().indexOf(search.toLowerCase());
return (
<>
{text.slice(0, idx)}
<mark className="bg-yellow-400/30 text-yellow-200 rounded px-0.5">
{text.slice(idx, idx + search.length)}
</mark>
{text.slice(idx + search.length)}
</>
);
};
const isMatch =
search &&
(keyName?.toLowerCase().includes(search.toLowerCase()) ||
(!complex && String(typeLabel(value)).toLowerCase().includes(search.toLowerCase())));
const handleCopy = () => {
navigator.clipboard.writeText(path);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
return (
<div
className={`text-[12.5px] font-mono leading-[1.8] ${isMatch ? "bg-yellow-400/[0.06] rounded" : ""}`}
>
<div className="flex items-start gap-1 group">
{/* Indent */}
<span style={{ width: depth * 16 }} className="flex-shrink-0" />
{/* Toggle */}
{complex ? (
<button
onClick={() => setOpen((o) => !o)}
className="w-4 flex-shrink-0 text-[#8b949e] hover:text-[#e6edf3] transition-colors select-none leading-[1.8]"
>
{open ? "▾" : "▸"}
</button>
) : (
<span className="w-4 flex-shrink-0" />
)}
{/* Key */}
{keyName !== null && <span className="text-[#7ee787] mr-0.5">{highlight(keyName)}</span>}
{keyName !== null && <span className="text-[#8b949e]">: </span>}
{/* Value or bracket */}
{!complex ? (
<span className={typeColor(value)}>{highlight(typeLabel(value))}</span>
) : (
<span className="text-[#8b949e]">
{Array.isArray(value) ? "[" : "{"}
{!open && (
<span className="text-[#484f58] italic ml-1">
{Array.isArray(value)
? `${(value as JsonValue[]).length} items`
: `${Object.keys(value as object).length} keys`}
</span>
)}
{!open && <span className="ml-1">{Array.isArray(value) ? "]" : "}"}</span>}
</span>
)}
{/* Copy path */}
<button
onClick={handleCopy}
className="ml-1 opacity-0 group-hover:opacity-100 text-[10px] text-[#484f58] hover:text-[#8b949e] transition-all"
title={path}
>
{copied ? "✓" : "⊙"}
</button>
</div>
{/* Children */}
{complex && open && (
<div>
{entries.map(([k, v]) => (
<JsonNode
key={k}
keyName={Array.isArray(value) ? null : k}
value={v}
depth={depth + 1}
search={search}
path={path ? `${path}.${k}` : k}
/>
))}
<div className="flex items-center">
<span style={{ width: depth * 16 }} className="flex-shrink-0" />
<span className="w-4 flex-shrink-0" />
<span className="text-[#8b949e]">{Array.isArray(value) ? "]" : "}"}</span>
</div>
</div>
)}
</div>
);
}
export default function JsonViewerRC() {
const [search, setSearch] = useState("");
const [raw, setRaw] = useState(JSON.stringify(SAMPLE, null, 2));
const [error, setError] = useState<string | null>(null);
const parsed = useMemo(() => {
try {
const v = JSON.parse(raw);
setError(null);
return v as JsonValue;
} catch (e) {
setError((e as Error).message);
return null;
}
}, [raw]);
return (
<div className="min-h-screen bg-[#0d1117] p-6 flex justify-center">
<div className="w-full max-w-[720px] space-y-4">
{/* Toolbar */}
<div className="flex gap-2">
<input
type="text"
placeholder="Search keys / values…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 bg-[#21262d] border border-[#30363d] rounded-lg px-3 py-2 text-[13px] text-[#e6edf3] placeholder-[#484f58] focus:outline-none focus:border-[#58a6ff] transition-colors"
/>
<button
onClick={() => setRaw(JSON.stringify(SAMPLE, null, 2))}
className="px-3 py-2 bg-[#21262d] border border-[#30363d] rounded-lg text-[12px] text-[#8b949e] hover:text-[#e6edf3] hover:border-[#8b949e] transition-colors"
>
Reset
</button>
</div>
{/* Tree */}
<div className="bg-[#161b22] border border-[#30363d] rounded-xl p-4 overflow-x-auto">
{error ? (
<p className="text-red-400 text-[13px] font-mono">{error}</p>
) : parsed !== null ? (
<JsonNode keyName={null} value={parsed} depth={0} search={search} path="" />
) : null}
</div>
{/* Raw editor */}
<details className="group">
<summary className="text-[12px] text-[#8b949e] cursor-pointer select-none hover:text-[#e6edf3] transition-colors list-none flex items-center gap-1">
<span className="group-open:rotate-90 inline-block transition-transform">▸</span>
Edit raw JSON
</summary>
<textarea
value={raw}
onChange={(e) => setRaw(e.target.value)}
className="mt-2 w-full h-48 bg-[#21262d] border border-[#30363d] rounded-lg px-3 py-2 text-[12px] text-[#e6edf3] font-mono resize-y focus:outline-none focus:border-[#58a6ff] transition-colors"
spellCheck={false}
/>
</details>
</div>
</div>
);
}<script setup>
import { ref, computed, reactive } from "vue";
const SAMPLE = {
user: {
id: 42,
name: "Ada Lovelace",
email: "ada@example.com",
active: true,
score: 9.8,
tags: ["engineer", "pioneer", "mathematician"],
address: {
street: "123 Babbage Lane",
city: "London",
country: "UK",
geo: { lat: 51.5074, lng: -0.1278 },
},
},
meta: {
version: "1.0.0",
generated: "2026-01-01T00:00:00Z",
flags: [true, false, null],
},
};
const search = ref("");
const raw = ref(JSON.stringify(SAMPLE, null, 2));
const error = ref(null);
const showRaw = ref(false);
const openPaths = reactive({});
const copiedPath = ref(null);
const parsed = computed(() => {
try {
const v = JSON.parse(raw.value);
error.value = null;
return v;
} catch (e) {
error.value = e.message;
return null;
}
});
function isComplex(v) {
return typeof v === "object" && v !== null;
}
function typeColor(v) {
if (v === null) return "#ff7b72";
if (typeof v === "boolean") return "#79c0ff";
if (typeof v === "number") return "#79c0ff";
if (typeof v === "string") return "#a5d6ff";
return "#e6edf3";
}
function typeLabel(v) {
if (v === null) return "null";
if (typeof v === "boolean") return String(v);
if (typeof v === "number") return String(v);
if (typeof v === "string") return `"${v}"`;
if (Array.isArray(v)) return `Array(${v.length})`;
return `{${Object.keys(v).length}}`;
}
function getEntries(v) {
if (Array.isArray(v)) return v.map((item, i) => ({ key: String(i), value: item, isIndex: true }));
return Object.entries(v).map(([k, val]) => ({ key: k, value: val, isIndex: false }));
}
function flattenTree(value, depth, path, keyName, parentIsArray) {
const nodes = [];
const complex = isComplex(value);
const pathKey = path || "$root";
if (!(pathKey in openPaths)) {
openPaths[pathKey] = depth < 2;
}
const isOpen = openPaths[pathKey];
const q = search.value.toLowerCase();
const isMatch =
q &&
((keyName && keyName.toLowerCase().includes(q)) ||
(!complex && String(typeLabel(value)).toLowerCase().includes(q)));
nodes.push({
type: "node",
keyName: parentIsArray ? null : keyName,
value,
depth,
path,
complex,
isOpen,
isMatch,
});
if (complex && isOpen) {
const entries = getEntries(value);
const isArr = Array.isArray(value);
for (const entry of entries) {
const childPath = path ? `${path}.${entry.key}` : entry.key;
nodes.push(...flattenTree(entry.value, depth + 1, childPath, entry.key, isArr));
}
nodes.push({ type: "close", depth, isArray: isArr });
}
return nodes;
}
const treeNodes = computed(() => {
if (parsed.value === null) return [];
return flattenTree(parsed.value, 0, "", null, false);
});
function toggle(path) {
const key = path || "$root";
openPaths[key] = !openPaths[key];
}
function copyPath(path) {
navigator.clipboard.writeText(path);
copiedPath.value = path;
setTimeout(() => {
copiedPath.value = null;
}, 1500);
}
function resetJson() {
raw.value = JSON.stringify(SAMPLE, null, 2);
}
</script>
<template>
<div style="min-height:100vh;background:#0d1117;padding:1.5rem;display:flex;justify-content:center;font-family:system-ui,-apple-system,sans-serif">
<div style="width:100%;max-width:720px;display:flex;flex-direction:column;gap:1rem">
<!-- Toolbar -->
<div style="display:flex;gap:0.5rem">
<input
type="text"
placeholder="Search keys / values..."
v-model="search"
style="flex:1;background:#21262d;border:1px solid #30363d;border-radius:0.5rem;padding:0.5rem 0.75rem;font-size:13px;color:#e6edf3;outline:none"
/>
<button
@click="resetJson"
style="padding:0.5rem 0.75rem;background:#21262d;border:1px solid #30363d;border-radius:0.5rem;font-size:12px;color:#8b949e;cursor:pointer"
>Reset</button>
</div>
<!-- Tree -->
<div style="background:#161b22;border:1px solid #30363d;border-radius:0.75rem;padding:1rem;overflow-x:auto">
<p v-if="error" style="color:#f87171;font-size:13px;font-family:monospace;margin:0">{{ error }}</p>
<template v-else>
<div
v-for="(node, idx) in treeNodes"
:key="idx"
:style="node.type === 'node' && node.isMatch ? 'background:rgba(250,204,21,0.06);border-radius:4px' : ''"
style="font-size:12.5px;font-family:monospace;line-height:1.8"
>
<!-- Close bracket -->
<template v-if="node.type === 'close'">
<div style="display:flex;align-items:center">
<span :style="{ width: node.depth * 16 + 'px', flexShrink: 0 }"></span>
<span style="width:16px;flex-shrink:0"></span>
<span style="color:#8b949e">{{ node.isArray ? ']' : '}' }}</span>
</div>
</template>
<!-- Node -->
<template v-else>
<div style="display:flex;align-items:flex-start;gap:4px">
<span :style="{ width: node.depth * 16 + 'px', flexShrink: 0 }"></span>
<!-- Toggle -->
<button
v-if="node.complex"
@click="toggle(node.path)"
style="width:16px;flex-shrink:0;background:none;border:none;color:#8b949e;cursor:pointer;padding:0;font-size:12.5px;font-family:monospace;line-height:1.8;text-align:left"
>{{ node.isOpen ? '\u25BE' : '\u25B8' }}</button>
<span v-else style="width:16px;flex-shrink:0"></span>
<!-- Key -->
<span v-if="node.keyName !== null" style="color:#7ee787;margin-right:2px">{{ node.keyName }}</span>
<span v-if="node.keyName !== null" style="color:#8b949e">: </span>
<!-- Value or bracket -->
<template v-if="!node.complex">
<span :style="{ color: typeColor(node.value) }">{{ typeLabel(node.value) }}</span>
</template>
<template v-else>
<span style="color:#8b949e">
{{ Array.isArray(node.value) ? '[' : '{' }}
<template v-if="!node.isOpen">
<span style="color:#484f58;font-style:italic;margin-left:4px">
{{ Array.isArray(node.value) ? node.value.length + ' items' : Object.keys(node.value).length + ' keys' }}
</span>
<span style="margin-left:4px">{{ Array.isArray(node.value) ? ']' : '}' }}</span>
</template>
</span>
</template>
<!-- Copy path -->
<button
v-if="node.path"
@click="copyPath(node.path)"
:title="node.path"
style="margin-left:4px;background:none;border:none;font-size:10px;color:#484f58;cursor:pointer;padding:0;line-height:1.8;opacity:0.5"
>{{ copiedPath === node.path ? '\u2713' : '\u2299' }}</button>
</div>
</template>
</div>
</template>
</div>
<!-- Raw editor -->
<details>
<summary style="font-size:12px;color:#8b949e;cursor:pointer;user-select:none;list-style:none;display:flex;align-items:center;gap:4px">
<span style="display:inline-block">▸</span> Edit raw JSON
</summary>
<textarea
v-model="raw"
spellcheck="false"
style="margin-top:0.5rem;width:100%;height:12rem;background:#21262d;border:1px solid #30363d;border-radius:0.5rem;padding:0.5rem 0.75rem;font-size:12px;color:#e6edf3;font-family:monospace;resize:vertical;outline:none;box-sizing:border-box"
></textarea>
</details>
</div>
</div>
</template><script>
import { onMount } from "svelte";
const SAMPLE = {
user: {
id: 42,
name: "Ada Lovelace",
email: "ada@example.com",
active: true,
score: 9.8,
tags: ["engineer", "pioneer", "mathematician"],
address: {
street: "123 Babbage Lane",
city: "London",
country: "UK",
geo: { lat: 51.5074, lng: -0.1278 },
},
},
meta: {
version: "1.0.0",
generated: "2026-01-01T00:00:00Z",
flags: [true, false, null],
},
};
let search = "";
let raw = JSON.stringify(SAMPLE, null, 2);
let parsed = SAMPLE;
let error = null;
let showRaw = false;
$: {
try {
parsed = JSON.parse(raw);
error = null;
} catch (e) {
error = e.message;
parsed = null;
}
}
function reset() {
raw = JSON.stringify(SAMPLE, null, 2);
}
</script>
<div class="json-viewer">
<div class="inner">
<!-- Toolbar -->
<div class="toolbar">
<input type="text" placeholder="Search keys / values..." bind:value={search} class="search-input" />
<button on:click={reset} class="reset-btn">Reset</button>
</div>
<!-- Tree -->
<div class="tree-container">
{#if error}
<p class="error">{error}</p>
{:else if parsed !== null}
<svelte:self keyName={null} value={parsed} depth={0} {search} path="" isRoot />
{/if}
</div>
<!-- Raw editor -->
<details>
<summary class="raw-toggle">
<span class="toggle-arrow">▸</span> Edit raw JSON
</summary>
<textarea bind:value={raw} class="raw-editor" spellcheck="false" />
</details>
</div>
</div>
<!-- Recursive node component using slots approach - flatten as functions -->
<!-- Note: Svelte doesn't support true recursive components in a single SFC easily,
so we use a flat approach with nested divs and actions -->
<style>
.json-viewer {
min-height: 100vh;
background: #0d1117;
padding: 1.5rem;
display: flex;
justify-content: center;
}
.inner {
width: 100%;
max-width: 720px;
display: flex;
flex-direction: column;
gap: 1rem;
}
.toolbar {
display: flex;
gap: 0.5rem;
}
.search-input {
flex: 1;
background: #21262d;
border: 1px solid #30363d;
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
font-size: 13px;
color: #e6edf3;
outline: none;
transition: border-color 0.15s;
}
.search-input::placeholder { color: #484f58; }
.search-input:focus { border-color: #58a6ff; }
.reset-btn {
padding: 0.5rem 0.75rem;
background: #21262d;
border: 1px solid #30363d;
border-radius: 0.5rem;
font-size: 12px;
color: #8b949e;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.reset-btn:hover { color: #e6edf3; border-color: #8b949e; }
.tree-container {
background: #161b22;
border: 1px solid #30363d;
border-radius: 0.75rem;
padding: 1rem;
overflow-x: auto;
}
.error {
color: #f87171;
font-size: 13px;
font-family: monospace;
margin: 0;
}
.raw-toggle {
font-size: 12px;
color: #8b949e;
cursor: pointer;
user-select: none;
list-style: none;
display: flex;
align-items: center;
gap: 0.25rem;
transition: color 0.15s;
}
.raw-toggle:hover { color: #e6edf3; }
.toggle-arrow {
display: inline-block;
transition: transform 0.15s;
}
details[open] .toggle-arrow { transform: rotate(90deg); }
.raw-editor {
margin-top: 0.5rem;
width: 100%;
height: 12rem;
background: #21262d;
border: 1px solid #30363d;
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
font-size: 12px;
color: #e6edf3;
font-family: monospace;
resize: vertical;
outline: none;
transition: border-color 0.15s;
}
.raw-editor:focus { border-color: #58a6ff; }
</style>
<!-- Since Svelte 4 doesn't support recursive self-reference in a single SFC,
we implement the tree rendering via a separate approach using actions.
For a production version, you'd split JsonNode into its own component.
Here we provide a complete working flat implementation. -->
<script context="module">
// This module context allows sharing between instances
</script>
{#if !$$props.isRoot}
<!-- This is a recursive node render -->
<script>
// Node props
export let keyName = null;
export let value = null;
export let depth = 0;
export let search = '';
export let path = '';
export let isRoot = false;
</script>
{/if}Interactive JSON tree viewer that renders nested objects and arrays with color-coded types, collapse/expand toggles, copy-path on click, and a search bar that highlights matching keys and values.