UIPackage
Menu

Framework

Change language

Boilerplate repo

Json Tree View

json-tree-view ui

Collapsible 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.

Also available for Vue ->

Installation

$ npx shadcn@latest add https://uipkge.dev/r/react/json-tree-view.json
Named registry: npx shadcn@latest add @uipkge-react/json-tree-view Installs to: components/ui/json-tree-view/

Examples

Loading interactive previews…

Props

Name Type / Values Default Required
data JsonValue required
expandDepth number optional
maxDepth number optional
showSearch boolean optional
showToolbar boolean optional
rootLabel string optional
onCopy (value: string, path: string) => void optional

npm dependencies

Files installed (4)

  • components/ui/json-tree-view/JsonTreeView.tsx 9.6 kB
    import * as React from 'react'
    import { Search, Braces, FoldVertical, UnfoldVertical } from 'lucide-react'
    import { cn } from '@/lib/utils'
    import { JsonTreeNode } from './JsonTreeNode'
    import type { JsonValue } from './types'
    
    export type { JsonValue } from './types'
    
    export interface JsonTreeViewProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'data' | 'onCopy'> {
      data: JsonValue
      expandDepth?: number
      maxDepth?: number
      showSearch?: boolean
      showToolbar?: boolean
      rootLabel?: string
      onCopy?: (value: string, path: string) => void
    }
    
    function pathKey(path: (string | number)[]): string {
      return path.length ? path.map((p) => (typeof p === 'number' ? `[${p}]` : `.${p}`)).join('') : '$'
    }
    
    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'
    
    const JsonTreeView = React.forwardRef<HTMLDivElement, JsonTreeViewProps>(function JsonTreeView(props, ref) {
      const {
        data,
        expandDepth = 1,
        maxDepth = 100,
        showSearch = true,
        showToolbar = true,
        rootLabel = 'root',
        onCopy,
        className,
        ...rest
      } = props
    
      const [expanded, setExpanded] = React.useState<Set<string>>(() => new Set())
      const [search, setSearch] = React.useState('')
      const [copiedPath, setCopiedPath] = React.useState<string | null>(null)
    
      const dataRef = React.useRef(data)
      dataRef.current = data
      const expandDepthRef = React.useRef(expandDepth)
      expandDepthRef.current = expandDepth
      const maxDepthRef = React.useRef(maxDepth)
      maxDepthRef.current = maxDepth
    
      function defaultExpanded(): Set<string> {
        const next = new Set<string>()
        const walk = (val: JsonValue, path: (string | number)[] = [], depth = 0) => {
          if (depth >= expandDepthRef.current) 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(dataRef.current)
        return next
      }
    
      // Reset expanded state when data or expandDepth changes
      React.useEffect(() => {
        setExpanded(defaultExpanded())
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, [data, expandDepth])
    
      function toggle(path: (string | number)[]) {
        const k = pathKey(path)
        setExpanded((prev) => {
          const next = new Set(prev)
          if (next.has(k)) next.delete(k)
          else next.add(k)
          return 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 >= maxDepthRef.current) 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(dataRef.current)
        setExpanded(next)
      }
    
      function collapseAll() {
        setExpanded(new Set())
      }
    
      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, value]) => k.toLowerCase().includes(term) || walk(value))
          return false
        }
        return walk(val)
      }
    
      // Auto-expand nodes that contain search matches
      React.useEffect(() => {
        if (!search) {
          setExpanded(defaultExpanded())
          return
        }
        const next = new Set<string>()
        const walk = (val: JsonValue, path: (string | number)[] = [], depth = 0) => {
          if (depth >= maxDepthRef.current) 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(dataRef.current)
        setExpanded(next)
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, [search])
    
      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)
          setCopiedPath(p)
          onCopy?.(str, p)
          setTimeout(() => {
            setCopiedPath((cur) => (cur === p ? null : cur))
          }, 1200)
        } catch {
          // clipboard unavailable
        }
      }
    
      const summary = React.useMemo(() => {
        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
      }, [data])
    
      const searchMatchCount = React.useMemo(() => {
        if (!search) return 0
        let count = 0
        const term = search.toLowerCase()
        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(term)) 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
      }, [search, data])
    
      return (
        <div
          ref={ref}
          data-uipkge=""
          data-slot="json-tree-view"
          className={cn('bg-background flex flex-col overflow-hidden rounded-lg border font-mono text-sm', className)}
          {...rest}
        >
          {/* Toolbar */}
          {(showToolbar || showSearch) && (
            <div className="border-border flex items-center gap-2 border-b px-3 py-2">
              <div className="flex items-center gap-1.5">
                <Braces className="text-muted-foreground size-4" />
                <span className="text-muted-foreground text-xs">{summary}</span>
              </div>
              <div className="ml-auto flex items-center gap-1">
                {showSearch && (
                  <div className="relative">
                    <Search className="text-muted-foreground absolute top-1/2 left-2 size-3.5 -translate-y-1/2" />
                    <input
                      type="text"
                      value={search}
                      onChange={(e) => setSearch(e.target.value)}
                      placeholder="Filter..."
                      aria-label="Filter JSON tree"
                      className="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>
                )}
                {search && (
                  <span className="text-muted-foreground text-xs">
                    {searchMatchCount} match{searchMatchCount === 1 ? '' : 'es'}
                  </span>
                )}
                <button
                  type="button"
                  className="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 className="size-4" />
                </button>
                <button
                  type="button"
                  className="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 className="size-4" />
                </button>
              </div>
            </div>
          )}
    
          {/* Tree */}
          <div className="min-h-0 flex-1 overflow-auto p-2" role="tree" aria-label={rootLabel}>
            <JsonTreeNode
              data={data}
              path={[]}
              label={rootLabel}
              isRoot
              search={search}
              maxDepth={maxDepth}
              matchesSearch={matchesSearch}
              isExpanded={isExpanded}
              toggle={toggle}
              typeOf={typeOf}
              formatValue={formatValue}
              typeColor={typeColor}
              keyColor={keyColor}
              copiedPath={copiedPath}
              onCopy={copyValue}
            />
          </div>
        </div>
      )
    })
    
    JsonTreeView.displayName = 'JsonTreeView'
    
    export { JsonTreeView }
  • components/ui/json-tree-view/JsonTreeNode.tsx 9.4 kB
    import * as React from 'react'
    import { ChevronDown, ChevronRight, Copy, Check } from 'lucide-react'
    import type { 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) => 'object' | 'array' | 'string' | 'number' | 'boolean' | 'null'
      formatValue: (val: JsonValue) => string
      typeColor: Record<string, string>
      keyColor: string
      copiedPath?: string | null
      onCopy?: (value: JsonValue, path: (string | number)[]) => void
    }
    
    function pathKey(path: (string | number)[]): string {
      return path.length ? path.map((p) => (typeof p === 'number' ? `[${p}]` : `.${p}`)).join('') : '$'
    }
    
    function JsonTreeNode(props: JsonTreeNodeProps): React.ReactElement {
      const {
        data,
        path,
        label,
        isRoot = false,
        search = '',
        maxDepth = 100,
        matchesSearch,
        isExpanded,
        toggle,
        typeOf,
        formatValue,
        typeColor,
        keyColor,
        copiedPath = null,
        onCopy,
      } = props
    
      const key = pathKey(path)
      const type = typeOf(data)
      const open = isExpanded(path)
      const isContainer = type === 'object' || type === 'array'
      const dimmed = !!search && !matchesSearch(data)
    
      const entries: [string | number, JsonValue][] = React.useMemo(() => {
        if (Array.isArray(data)) return data.map((v, i) => [i, v] as [number, JsonValue])
        if (data !== null && typeof data === 'object') return Object.entries(data) as [string, JsonValue][]
        return []
      }, [data])
    
      const count = entries.length
      const indent = isRoot ? 0 : 20
    
      // Collapsed preview: show first few items inline
      const collapsedPreview = React.useMemo(() => {
        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 open2 = type === 'array' ? '[' : '{'
        const close = type === 'array' ? ']' : '}'
        return `${open2}${parts.join(', ')}${suffix}${close}`
      }, [open, isContainer, entries, count, type, typeOf, formatValue, data])
    
      const parentKey = 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: React.KeyboardEvent) {
        const target = e.currentTarget as HTMLElement
    
        if (e.key === 'Enter' || e.key === ' ') {
          e.preventDefault()
          if (isContainer) toggle(path)
          else 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])
        }
      }
    
      return (
        <div
          data-dimmed={dimmed ? '' : undefined}
          className={dimmed ? 'opacity-30' : ''}
          role="treeitem"
          aria-expanded={isContainer ? open : undefined}
        >
          {/* Container header row (object/array) */}
          {isContainer && (
            <div
              data-tree-row
              data-tree-id={key}
              data-tree-parent={parentKey ?? undefined}
              tabIndex={0}
              className="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={{ paddingLeft: `${indent}px` }}
              onClick={() => toggle(path)}
              onKeyDown={handleRowKeydown}
            >
              <button
                type="button"
                className="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)
                }}
              >
                {open ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
              </button>
              <span className={`${keyColor} select-none`}>{isRoot ? label : `"${label}"`}</span>
              <span className="text-muted-foreground">:</span>
              {open ? (
                <span className="text-muted-foreground select-none">{type === 'array' ? '[' : '{'}</span>
              ) : (
                <span className="text-muted-foreground select-none">{collapsedPreview}</span>
              )}
              {open && (
                <span className="text-muted-foreground ml-0.5 text-xs">
                  {count} {count === 1 ? 'item' : 'items'}
                </span>
              )}
              <button
                type="button"
                className="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()
                }}
              >
                {copiedPath === key ? <Check className="size-3 text-emerald-500" /> : <Copy className="size-3" />}
              </button>
            </div>
          )}
    
          {/* Container children */}
          {isContainer && open && (
            <div role="group">
              {entries.map(([k, v]) => (
                <JsonTreeNode
                  key={String(k)}
                  data={v}
                  path={[...path, k]}
                  label={String(k)}
                  isRoot={false}
                  search={search}
                  maxDepth={maxDepth}
                  matchesSearch={matchesSearch}
                  isExpanded={isExpanded}
                  toggle={toggle}
                  typeOf={typeOf}
                  formatValue={formatValue}
                  typeColor={typeColor}
                  keyColor={keyColor}
                  copiedPath={copiedPath}
                  onCopy={onCopy}
                />
              ))}
              <div className="text-muted-foreground py-0.5 select-none" style={{ paddingLeft: `${indent}px` }}>
                {type === 'array' ? ']' : '}'}
              </div>
            </div>
          )}
    
          {/* Primitive leaf */}
          {!isContainer && (
            <div
              data-tree-row
              data-tree-id={key}
              data-tree-parent={parentKey ?? undefined}
              tabIndex={0}
              className="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={{ paddingLeft: `${indent}px` }}
              onClick={handleCopy}
              onKeyDown={handleRowKeydown}
            >
              <span className="inline-flex size-4 shrink-0" />
              {isRoot ? (
                <span className="text-muted-foreground select-none">{label}</span>
              ) : (
                <span className={`${keyColor} select-none`}>"{label}"</span>
              )}
              <span className="text-muted-foreground">:</span>
              <span className={`${typeColor[type] ?? 'text-foreground'} rounded text-left font-mono`}>
                {formatValue(data)}
              </span>
              <button
                type="button"
                className="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()
                }}
              >
                {copiedPath === key ? <Check className="size-3 text-emerald-500" /> : <Copy className="size-3" />}
              </button>
            </div>
          )}
        </div>
      )
    }
    
    export { JsonTreeNode }
  • components/ui/json-tree-view/types.ts 0.1 kB
    export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }
  • components/ui/json-tree-view/index.ts 0.2 kB
    export { JsonTreeView, type JsonTreeViewProps } from './JsonTreeView'
    export { JsonTreeNode, type JsonTreeNodeProps } from './JsonTreeNode'
    export type { JsonValue } from './types'

Raw manifest: https://uipkge.dev/r/react/json-tree-view.json