Json Tree View
json-tree-view ui Collapsible JSON tree viewer with color-coded value types, click-to-copy, live search/filter, hover path display, and expand/collapse-all controls. Renders objects, arrays, and primitives with configurable default depth and max depth.
Also available for React ->Installation
$ pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/json-tree-view.json $ npx shadcn-vue@latest add https://uipkge.dev/r/vue/json-tree-view.json $ yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/json-tree-view.json $ bunx shadcn-vue@latest add https://uipkge.dev/r/vue/json-tree-view.json Named registry:
npx shadcn-vue@latest add @uipkge/json-tree-view Installs to: app/components/ui/json-tree-view/ Examples
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 |
showPath | boolean | true | optional |
rootLabel | string | 'root' | optional |
class | HTMLAttributes['class'] | — | optional |
npm dependencies
Files installed (4)
-
app/components/ui/json-tree-view/JsonTreeView.vue 8.7 kB
<script setup lang="ts"> import type { HTMLAttributes } from 'vue' import { computed, ref, watch } from 'vue' import { ChevronDown, ChevronRight, Search, Braces, Copy, Check, FoldVertical, UnfoldVertical } from 'lucide-vue-next' import { cn } from '@/lib/utils' import JsonTreeNode from './JsonTreeNode.vue' import type { JsonValue } from './types' export type { JsonValue } from './types' interface Props { data: JsonValue expandDepth?: number maxDepth?: number showSearch?: boolean showToolbar?: boolean showPath?: boolean rootLabel?: string class?: HTMLAttributes['class'] } const props = withDefaults(defineProps<Props>(), { expandDepth: 1, maxDepth: 100, showSearch: true, showToolbar: true, showPath: true, rootLabel: 'root', }) const emit = defineEmits<{ copy: [value: string, path: string] }>() const expanded = ref<Set<string>>(new Set()) const search = ref('') const copiedPath = ref<string | null>(null) const hoveredPath = ref<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 >= props.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(props.data) return next } watch( () => [props.data, props.expandDepth], () => { expanded.value = defaultExpanded() }, { immediate: true }, ) function toggle(path: (string | number)[]) { const key = pathKey(path) const next = new Set(expanded.value) if (next.has(key)) next.delete(key) else next.add(key) expanded.value = next } function isExpanded(path: (string | number)[]): boolean { return expanded.value.has(pathKey(path)) } function expandAll() { const next = new Set<string>() const walk = (val: JsonValue, path: (string | number)[] = [], depth = 0) => { if (depth >= props.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(props.data) expanded.value = next } function collapseAll() { expanded.value = new Set() } // Auto-expand nodes that contain search matches watch(search, (q) => { if (!q) { expanded.value = defaultExpanded() return } const next = new Set<string>() const walk = (val: JsonValue, path: (string | number)[] = [], depth = 0) => { if (depth >= props.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(props.data) expanded.value = next }) function matchesSearch(val: JsonValue): boolean { if (!search.value) return true const term = search.value.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): 'object' | 'array' | 'string' | 'number' | 'boolean' | 'null' { 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.value = p emit('copy', str, p) setTimeout(() => { if (copiedPath.value === p) copiedPath.value = null }, 1200) } catch { // clipboard unavailable } } function onHover(path: string | null) { hoveredPath.value = path } const displayPath = computed(() => { if (!props.showPath || !hoveredPath.value) return '' if (hoveredPath.value === '$') return props.rootLabel return hoveredPath.value }) const summary = computed(() => { const t = typeOf(props.data) if (t === 'array') return `Array(${(props.data as JsonValue[]).length})` if (t === 'object') return `Object(${Object.keys(props.data as object).length})` return t }) const searchMatchCount = computed(() => { if (!search.value) return 0 let count = 0 const walk = (v: JsonValue) => { if (v === null) { if ('null'.includes(search.value.toLowerCase())) count++; return } if (typeof v === 'string') { if (v.toLowerCase().includes(search.value.toLowerCase())) count++; return } if (typeof v === 'number' || typeof v === 'boolean') { if (String(v).includes(search.value)) count++; return } if (Array.isArray(v)) { v.forEach(walk); return } if (typeof v === 'object') { Object.entries(v).forEach(([k, val]) => { if (k.toLowerCase().includes(search.value.toLowerCase())) count++; walk(val) }) } } walk(props.data) return count }) </script> <template> <div data-uipkge data-slot="json-tree-view" :class="cn('bg-background rounded-lg border font-mono text-sm', props.class)" > <!-- Toolbar --> <div v-if="showToolbar || showSearch" 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"> <div v-if="showSearch" class="relative"> <Search class="text-muted-foreground absolute top-1/2 left-2 size-3.5 -translate-y-1/2" /> <input v-model="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> <span v-if="search && searchMatchCount > 0" class="text-muted-foreground text-xs">{{ searchMatchCount }} matches</span> <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" @click="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" @click="collapseAll" > <FoldVertical class="size-4" /> </button> </div> </div> <!-- Path bar --> <div v-if="showPath && displayPath" class="border-border bg-muted/30 text-muted-foreground truncate border-b px-3 py-1 text-xs" > {{ displayPath }} </div> <!-- Tree --> <div class="overflow-auto p-2"> <JsonTreeNode :data="data" :path="[]" :label="rootLabel" :is-root="true" :search="search" :max-depth="maxDepth" :matches-search="matchesSearch" :is-expanded="isExpanded" :toggle="toggle" :type-of="typeOf" :format-value="formatValue" :type-color="typeColor" :key-color="keyColor" :copied-path="copiedPath" @copy="copyValue" @hover="onHover" /> </div> </div> </template> -
app/components/ui/json-tree-view/JsonTreeNode.vue 6.5 kB
<script setup lang="ts"> import { computed, defineAsyncComponent } from 'vue' import { ChevronDown, ChevronRight, Copy, Check } from 'lucide-vue-next' import type { JsonValue } from './types' // Self-reference for recursive rendering — use defineAsyncComponent to avoid circular import const JsonTreeNode = defineAsyncComponent(() => import('./JsonTreeNode.vue')) interface Props { 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) => 'object' | 'array' | 'string' | 'number' | 'boolean' | 'null' formatValue: (val: JsonValue) => string typeColor: Record<string, string> keyColor: string copiedPath?: string | null } const props = withDefaults(defineProps<Props>(), { isRoot: false, search: '', maxDepth: 100, copiedPath: null, }) const emit = defineEmits<{ copy: [value: JsonValue, path: (string | number)[]] hover: [path: string | null] }>() function pathKey(path: (string | number)[]): string { return path.length ? path.map((p) => (typeof p === 'number' ? `[${p}]` : `.${p}`)).join('') : '$' } const key = computed(() => pathKey(props.path)) const type = computed(() => props.typeOf(props.data)) const open = computed(() => props.isExpanded(props.path)) const isContainer = computed(() => type.value === 'object' || type.value === 'array') const dimmed = computed(() => !!props.search && !props.matchesSearch(props.data)) const entries = computed<[string | number, JsonValue][]>(() => { const val = props.data if (Array.isArray(val)) return val.map((v, i) => [i, v] as const) if (val !== null && typeof val === 'object') return Object.entries(val) as [string, JsonValue][] return [] }) const count = computed(() => entries.value.length) const indent = computed(() => (props.isRoot ? 0 : 20)) // Collapsed preview: show first few items inline const collapsedPreview = computed(() => { if (open.value || !isContainer.value) return '' const items = entries.value.slice(0, 3) const parts = items.map(([k, v]) => { const vt = props.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 = props.formatValue(v) return `${Array.isArray(props.data) ? '' : `"${k}": `}${valStr}` }) const suffix = count.value > 3 ? ', …' : '' const open2 = type.value === 'array' ? '[' : '{' const close = type.value === 'array' ? ']' : '}' return `${open2}${parts.join(', ')}${suffix}${close}` }) function onCopy() { emit('copy', props.data, props.path) } function onHover() { emit('hover', key.value) } function onLeave() { emit('hover', null) } </script> <template> <div :data-dimmed="dimmed ? '' : undefined" :class="dimmed ? 'opacity-30' : ''"> <!-- Container header row (object/array) --> <div v-if="isContainer" class="group hover:bg-accent/40 flex items-center gap-0.5 rounded px-1 -mx-1 py-0.5 transition-colors" :style="{ paddingLeft: `${indent}px` }" > <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'" @click="toggle(path)" > <ChevronDown v-if="open" class="size-3.5" /> <ChevronRight v-else class="size-3.5" /> </button> <span :class="keyColor" class="select-none" @mouseenter="onHover" @mouseleave="onLeave"> {{ isRoot ? label : `"${label}"` }} </span> <span class="text-muted-foreground">:</span> <span v-if="open" class="text-muted-foreground select-none">{{ type === 'array' ? '[' : '{' }}</span> <span v-else class="text-muted-foreground select-none">{{ collapsedPreview }}</span> <span v-if="open" class="text-muted-foreground ml-0.5 text-xs">{{ count }} {{ count === 1 ? 'item' : 'items' }}</span> <button type="button" class="text-muted-foreground hover:text-foreground ml-auto inline-flex size-5 items-center justify-center rounded opacity-0 transition-opacity group-hover:opacity-100" title="Copy value" aria-label="Copy value" @click.stop="onCopy" > <Check v-if="copiedPath === key" class="size-3 text-emerald-500" /> <Copy v-else class="size-3" /> </button> </div> <!-- Container children --> <template v-if="isContainer && open"> <JsonTreeNode v-for="[k, v] in entries" :key="String(k)" :data="v" :path="[...path, k]" :label="String(k)" :is-root="false" :search="search" :max-depth="maxDepth" :matches-search="matchesSearch" :is-expanded="isExpanded" :toggle="toggle" :type-of="typeOf" :format-value="formatValue" :type-color="typeColor" :key-color="keyColor" :copied-path="copiedPath" @copy="(val, p) => emit('copy', val, p)" @hover="(p) => emit('hover', p)" /> <div class="text-muted-foreground select-none py-0.5" :style="{ paddingLeft: `${indent}px` }"> {{ type === 'array' ? ']' : '}' }} </div> </template> <!-- Primitive leaf --> <div v-if="!isContainer" class="group hover:bg-accent/40 flex items-center gap-0.5 rounded px-1 -mx-1 py-0.5 transition-colors" :style="{ paddingLeft: `${indent}px` }" > <span class="inline-flex size-4 shrink-0" /> <span v-if="isRoot" class="text-muted-foreground select-none">{{ label }}</span> <span v-else :class="keyColor" class="select-none">"{{ label }}"</span> <span class="text-muted-foreground">:</span> <span :class="typeColor[type] ?? 'text-foreground'" class="cursor-pointer" @click="onCopy" @mouseenter="onHover" @mouseleave="onLeave" > {{ formatValue(data) }} </span> <button type="button" class="text-muted-foreground hover:text-foreground ml-auto inline-flex size-5 items-center justify-center rounded opacity-0 transition-opacity group-hover:opacity-100" title="Copy value" aria-label="Copy value" @click.stop="onCopy" > <Check v-if="copiedPath === key" class="size-3 text-emerald-500" /> <Copy v-else class="size-3" /> </button> </div> </div> </template> -
app/components/ui/json-tree-view/types.ts 0.1 kB
export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue } -
app/components/ui/json-tree-view/index.ts 0.2 kB
export { default as JsonTreeView } from './JsonTreeView.vue' export { default as JsonTreeNode } from './JsonTreeNode.vue' export type { JsonValue } from './types'
Raw manifest: https://uipkge.dev/r/vue/json-tree-view.json