UIPackage
Menu

Json Tree View

json-tree-view ui
Edit on GitHub

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

$ npx 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

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