Json Tree View
json-tree-viewuiCollapsible JSON tree viewer with color-coded value types, click-to-copy, live search/filter, and expand/collapse-all controls. Renders objects, arrays, and primitives with configurable depth and contained scrolling.
Installation
$pnpm dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/json-tree-view.json$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/json-tree-view.json$yarn dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/json-tree-view.json$bunx shadcn-svelte@latest add https://uipkge.dev/r/svelte/json-tree-view.jsonManifest:
https://uipkge.dev/r/svelte/json-tree-view.jsonInstalls to:src/lib/components/ui/json-tree-view/Examples
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
data | JsonValue | — | required |
expandDepth | number | 1 | optional |
maxDepth | number | 100 | optional |
showSearch | boolean | true | optional |
showToolbar | boolean | true | optional |
rootLabel | string | root | optional |
oncopyFires after a value is copied: the copied string and its path key. | (value: string, path: string) => void | — | optional |
ref | HTMLDivElement | null | — | optional |
npm dependencies
Files installed (4)
src/lib/components/ui/json-tree-view/JsonTreeView.svelte8.6 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' import type { JsonTreeType, JsonValue } from './types' export type { JsonValue } from './types' export interface JsonTreeViewProps extends Omit<HTMLAttributes<HTMLDivElement>, 'oncopy'> { data: JsonValue expandDepth?: number maxDepth?: number showSearch?: boolean showToolbar?: boolean rootLabel?: string /** Fires after a value is copied: the copied string and its path key. */ oncopy?: (value: string, path: string) => void ref?: HTMLDivElement | null } </script> <script lang="ts"> import { Braces, FoldVertical, Search, UnfoldVertical } from '@lucide/svelte' import { cn } from '$lib/utils' import JsonTreeNode from './JsonTreeNode.svelte' let { class: className, data, expandDepth = 1, maxDepth = 100, showSearch = true, showToolbar = true, rootLabel = 'root', oncopy, ref = $bindable(null), ...restProps }: JsonTreeViewProps = $props() let expanded = $state<Set<string>>(new Set()) let search = $state('') let copiedPath = $state<string | null>(null) function pathKey(path: (string | number)[]): string { return path.length ? path.map((p) => (typeof p === 'number' ? `[${p}]` : `.${p}`)).join('') : '$' } function defaultExpanded(): Set<string> { const next = new Set<string>() const walk = (val: JsonValue, path: (string | number)[] = [], depth = 0) => { if (depth >= expandDepth) return if (val !== null && typeof val === 'object') { next.add(pathKey(path)) const entries = Array.isArray(val) ? val.map((v, i) => [i, v] as const) : Object.entries(val) for (const [k, v] of entries) { walk(v as JsonValue, [...path, k], depth + 1) } } } walk(data) return next } $effect(() => { void data void expandDepth expanded = defaultExpanded() }) function toggle(path: (string | number)[]) { const key = pathKey(path) const next = new Set(expanded) if (next.has(key)) next.delete(key) else next.add(key) expanded = next } function isExpanded(path: (string | number)[]): boolean { return expanded.has(pathKey(path)) } function expandAll() { const next = new Set<string>() const walk = (val: JsonValue, path: (string | number)[] = [], depth = 0) => { if (depth >= maxDepth) return if (val !== null && typeof val === 'object') { next.add(pathKey(path)) const entries = Array.isArray(val) ? val.map((v, i) => [i, v] as const) : Object.entries(val) for (const [k, v] of entries) { walk(v as JsonValue, [...path, k], depth + 1) } } } walk(data) expanded = next } function collapseAll() { expanded = new Set() } // Auto-expand nodes that contain search matches $effect(() => { const q = search if (!q) { expanded = defaultExpanded() return } const next = new Set<string>() const walk = (val: JsonValue, path: (string | number)[] = [], depth = 0) => { if (depth >= maxDepth) return if (val !== null && typeof val === 'object') { if (matchesSearch(val)) next.add(pathKey(path)) const entries = Array.isArray(val) ? val.map((v, i) => [i, v] as const) : Object.entries(val) for (const [k, v] of entries) { walk(v as JsonValue, [...path, k], depth + 1) } } } walk(data) expanded = next }) function matchesSearch(val: JsonValue): boolean { if (!search) return true const term = search.toLowerCase() const walk = (v: JsonValue): boolean => { if (v === null) return 'null'.includes(term) if (typeof v === 'string') return v.toLowerCase().includes(term) if (typeof v === 'number' || typeof v === 'boolean') return String(v).includes(term) if (Array.isArray(v)) return v.some(walk) if (typeof v === 'object') return Object.entries(v).some(([k, val]) => k.toLowerCase().includes(term) || walk(val)) return false } return walk(val) } function typeOf(val: JsonValue): JsonTreeType { if (val === null) return 'null' if (Array.isArray(val)) return 'array' return typeof val as 'object' | 'string' | 'number' | 'boolean' } function formatValue(val: JsonValue): string { if (val === null) return 'null' if (typeof val === 'string') return JSON.stringify(val) return String(val) } const typeColor: Record<string, string> = { string: 'text-emerald-600 dark:text-emerald-400', number: 'text-blue-600 dark:text-blue-400', boolean: 'text-amber-600 dark:text-amber-400', null: 'text-muted-foreground italic', object: 'text-foreground', array: 'text-foreground', } const keyColor = 'text-violet-600 dark:text-violet-400' async function copyValue(val: JsonValue, path: (string | number)[]) { const str = typeof val === 'string' ? val : JSON.stringify(val, null, 2) const p = pathKey(path) try { await navigator.clipboard.writeText(str) copiedPath = p oncopy?.(str, p) setTimeout(() => { if (copiedPath === p) copiedPath = null }, 1200) } catch { // clipboard unavailable } } const summary = $derived.by(() => { const t = typeOf(data) if (t === 'array') return `Array(${(data as JsonValue[]).length})` if (t === 'object') return `Object(${Object.keys(data as object).length})` return t }) const searchMatchCount = $derived.by(() => { if (!search) return 0 const term = search.toLowerCase() let count = 0 const walk = (v: JsonValue) => { if (v === null) { if ('null'.includes(term)) count++ return } if (typeof v === 'string') { if (v.toLowerCase().includes(term)) count++ return } if (typeof v === 'number' || typeof v === 'boolean') { if (String(v).includes(search)) count++ return } if (Array.isArray(v)) { v.forEach(walk) return } if (typeof v === 'object') { Object.entries(v).forEach(([k, val]) => { if (k.toLowerCase().includes(term)) count++ walk(val) }) } } walk(data) return count }) </script> <div bind:this={ref} data-uipkge="" data-slot="json-tree-view" class={cn('bg-background flex flex-col overflow-hidden rounded-lg border font-mono text-sm', className)} {...restProps} > <!-- Toolbar --> {#if showToolbar || showSearch} <div class="border-border flex items-center gap-2 border-b px-3 py-2"> <div class="flex items-center gap-1.5"> <Braces class="text-muted-foreground size-4" /> <span class="text-muted-foreground text-xs">{summary}</span> </div> <div class="ml-auto flex items-center gap-1"> {#if showSearch} <div class="relative"> <Search class="text-muted-foreground absolute top-1/2 left-2 size-3.5 -translate-y-1/2" /> <input bind:value={search} type="text" placeholder="Filter..." aria-label="Filter JSON tree" class="border-input bg-muted/40 focus:border-ring focus:ring-ring/30 h-7 w-32 rounded-md pr-2 pl-7 text-xs transition-[width] outline-none focus:w-44 focus:ring-2" /> </div> {/if} {#if search} <span class="text-muted-foreground text-xs"> {searchMatchCount} match{searchMatchCount === 1 ? '' : 'es'} </span> {/if} <button type="button" class="text-muted-foreground hover:text-foreground hover:bg-accent inline-flex size-7 items-center justify-center rounded-md transition-colors" title="Expand all" aria-label="Expand all" onclick={expandAll} > <UnfoldVertical class="size-4" /> </button> <button type="button" class="text-muted-foreground hover:text-foreground hover:bg-accent inline-flex size-7 items-center justify-center rounded-md transition-colors" title="Collapse all" aria-label="Collapse all" onclick={collapseAll} > <FoldVertical class="size-4" /> </button> </div> </div> {/if} <!-- Tree --> <div class="min-h-0 flex-1 overflow-auto p-2" role="tree" aria-label={rootLabel}> <JsonTreeNode {data} path={[]} label={rootLabel} isRoot={true} {search} {maxDepth} {matchesSearch} {isExpanded} {toggle} {typeOf} {formatValue} {typeColor} {keyColor} {copiedPath} oncopy={copyValue} /> </div> </div>src/lib/components/ui/json-tree-view/JsonTreeNode.svelte9.4 kB
<script lang="ts" module> import type { JsonTreeType, JsonValue } from './types' export interface JsonTreeNodeProps { data: JsonValue path: (string | number)[] label: string isRoot?: boolean search?: string maxDepth?: number matchesSearch: (val: JsonValue) => boolean isExpanded: (path: (string | number)[]) => boolean toggle: (path: (string | number)[]) => void typeOf: (val: JsonValue) => JsonTreeType formatValue: (val: JsonValue) => string typeColor: Record<string, string> keyColor: string copiedPath?: string | null oncopy?: (value: JsonValue, path: (string | number)[]) => void } </script> <script lang="ts"> import { Check, ChevronDown, ChevronRight, Copy } from '@lucide/svelte' // Self-reference for recursive rendering. import JsonTreeNode from './JsonTreeNode.svelte' let { data, path, label, isRoot = false, search = '', maxDepth = 100, matchesSearch, isExpanded, toggle, typeOf, formatValue, typeColor, keyColor, copiedPath = null, oncopy, }: JsonTreeNodeProps = $props() function pathKey(p: (string | number)[]): string { return p.length ? p.map((seg) => (typeof seg === 'number' ? `[${seg}]` : `.${seg}`)).join('') : '$' } const key = $derived(pathKey(path)) const type = $derived(typeOf(data)) const open = $derived(isExpanded(path)) const isContainer = $derived(type === 'object' || type === 'array') const dimmed = $derived(!!search && !matchesSearch(data)) const entries = $derived.by((): [string | number, JsonValue][] => { if (Array.isArray(data)) return data.map((v, i): [number, JsonValue] => [i, v]) if (data !== null && typeof data === 'object') return Object.entries(data) as [string, JsonValue][] return [] }) const count = $derived(entries.length) const indent = $derived(isRoot ? 0 : 20) // Collapsed preview: show first few items inline const collapsedPreview = $derived.by(() => { if (open || !isContainer) return '' const items = entries.slice(0, 3) const parts = items.map(([k, v]) => { const vt = typeOf(v) let valStr: string if (vt === 'string') valStr = `"${String(v).slice(0, 20)}"` else if (vt === 'array') valStr = '[…]' else if (vt === 'object') valStr = '{…}' else valStr = formatValue(v) return `${Array.isArray(data) ? '' : `"${k}": `}${valStr}` }) const suffix = count > 3 ? ', …' : '' const openBracket = type === 'array' ? '[' : '{' const close = type === 'array' ? ']' : '}' return `${openBracket}${parts.join(', ')}${suffix}${close}` }) const parentKey = $derived(path.length ? pathKey(path.slice(0, -1)) : null) function handleCopy() { oncopy?.(data, path) } function getTreeRows(from: HTMLElement): HTMLElement[] { const tree = from.closest('[role="tree"]') if (!tree) return [] return Array.from(tree.querySelectorAll<HTMLElement>('[data-tree-row]')) } function focusRow(row: HTMLElement | null | undefined) { row?.focus() } function handleRowKeydown(e: KeyboardEvent & { currentTarget: HTMLElement }) { const target = e.currentTarget if (e.key === 'Enter' || e.key === ' ') { if (isContainer) { e.preventDefault() toggle(path) } else { e.preventDefault() handleCopy() } return } if (e.key === 'ArrowRight') { e.preventDefault() if (isContainer && !open) { toggle(path) } else if (isContainer && open) { const rows = getTreeRows(target) const idx = rows.indexOf(target) if (idx >= 0 && idx < rows.length - 1) focusRow(rows[idx + 1]) } return } if (e.key === 'ArrowLeft') { e.preventDefault() if (isContainer && open) { toggle(path) } else if (parentKey) { const tree = target.closest('[role="tree"]') const parent = tree?.querySelector<HTMLElement>( `[data-tree-row][data-tree-id="${CSS.escape(parentKey)}"]`, ) focusRow(parent) } return } if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault() const rows = getTreeRows(target) const idx = rows.indexOf(target) if (idx < 0) return focusRow(e.key === 'ArrowDown' ? rows[idx + 1] : rows[idx - 1]) return } if (e.key === 'Home') { e.preventDefault() focusRow(getTreeRows(target)[0]) return } if (e.key === 'End') { e.preventDefault() const rows = getTreeRows(target) focusRow(rows[rows.length - 1]) } } </script> <!-- svelte-ignore a11y_role_has_required_aria_props: rows are navigable, not selectable — aria-selected would mislead --> <div data-dimmed={dimmed ? '' : undefined} class={dimmed ? 'opacity-30' : ''} role="treeitem" aria-expanded={isContainer ? open : undefined} > <!-- Container header row (object/array) --> {#if isContainer} <!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_static_element_interactions: tree row with full roving keyboard support --> <div data-tree-row data-tree-id={key} data-tree-parent={parentKey ?? undefined} tabindex="0" class="group hover:bg-accent/40 focus-visible:ring-ring/50 -mx-1 flex items-center gap-0.5 rounded px-1 py-0.5 transition-colors focus-visible:ring-2 focus-visible:outline-none" style="padding-left: {indent}px" onclick={() => toggle(path)} onkeydown={handleRowKeydown} > <button type="button" class="text-muted-foreground hover:text-foreground hover:bg-accent inline-flex size-4 shrink-0 items-center justify-center rounded" aria-expanded={open} aria-label={open ? 'Collapse' : 'Expand'} tabindex="-1" onclick={(e) => { e.stopPropagation() toggle(path) }} > {#if open} <ChevronDown class="size-3.5" /> {:else} <ChevronRight class="size-3.5" /> {/if} </button> <span class="{keyColor} select-none"> {isRoot ? label : `"${label}"`} </span> <span class="text-muted-foreground">:</span> {#if open} <span class="text-muted-foreground select-none">{type === 'array' ? '[' : '{'}</span> {:else} <span class="text-muted-foreground select-none">{collapsedPreview}</span> {/if} {#if open} <span class="text-muted-foreground ml-0.5 text-xs">{count} {count === 1 ? 'item' : 'items'}</span> {/if} <button type="button" class="text-muted-foreground hover:text-foreground focus-visible:ring-ring ml-auto inline-flex size-5 items-center justify-center rounded opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-1" title="Copy value" aria-label="Copy value" tabindex="-1" onclick={(e) => { e.stopPropagation() handleCopy() }} > {#if copiedPath === key} <Check class="size-3 text-emerald-500" /> {:else} <Copy class="size-3" /> {/if} </button> </div> {/if} <!-- Container children --> {#if isContainer && open} <div role="group"> {#each entries as [k, v] (String(k))} <JsonTreeNode data={v} path={[...path, k]} label={String(k)} isRoot={false} {search} {maxDepth} {matchesSearch} {isExpanded} {toggle} {typeOf} {formatValue} {typeColor} {keyColor} {copiedPath} {oncopy} /> {/each} <div class="text-muted-foreground py-0.5 select-none" style="padding-left: {indent}px"> {type === 'array' ? ']' : '}'} </div> </div> {/if} <!-- Primitive leaf --> {#if !isContainer} <!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_static_element_interactions: tree row with full roving keyboard support --> <div data-tree-row data-tree-id={key} data-tree-parent={parentKey ?? undefined} tabindex="0" class="group hover:bg-accent/40 focus-visible:ring-ring/50 -mx-1 flex items-center gap-0.5 rounded px-1 py-0.5 transition-colors focus-visible:ring-2 focus-visible:outline-none" style="padding-left: {indent}px" onclick={handleCopy} onkeydown={handleRowKeydown} > <span class="inline-flex size-4 shrink-0"></span> {#if isRoot} <span class="text-muted-foreground select-none">{label}</span> {:else} <span class="{keyColor} select-none">"{label}"</span> {/if} <span class="text-muted-foreground">:</span> <span class="{typeColor[type] ?? 'text-foreground'} rounded text-left font-mono"> {formatValue(data)} </span> <button type="button" class="text-muted-foreground hover:text-foreground focus-visible:ring-ring ml-auto inline-flex size-5 items-center justify-center rounded opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-1" title="Copy value" aria-label="Copy value" tabindex="-1" onclick={(e) => { e.stopPropagation() handleCopy() }} > {#if copiedPath === key} <Check class="size-3 text-emerald-500" /> {:else} <Copy class="size-3" /> {/if} </button> </div> {/if} </div>src/lib/components/ui/json-tree-view/types.ts0.2 kB
export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue } export type JsonTreeType = 'object' | 'array' | 'string' | 'number' | 'boolean' | 'null'src/lib/components/ui/json-tree-view/index.ts0.2 kB
export { default as JsonTreeView, type JsonTreeViewProps } from './JsonTreeView.svelte' export { default as JsonTreeNode, type JsonTreeNodeProps } from './JsonTreeNode.svelte' export type { JsonTreeType, JsonValue } from './types'
Raw manifest:https://uipkge.dev/r/svelte/json-tree-view.json