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 Vue ->Installation
$ pnpm dlx shadcn@latest add https://uipkge.dev/r/react/json-tree-view.json $ npx shadcn@latest add https://uipkge.dev/r/react/json-tree-view.json $ yarn dlx shadcn@latest add https://uipkge.dev/r/react/json-tree-view.json $ bunx 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
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
data | JsonValue | — | required |
expandDepth | number | — | optional |
maxDepth | number | — | optional |
showSearch | boolean | — | optional |
showToolbar | boolean | — | optional |
showPath | boolean | — | optional |
rootLabel | string | — | optional |
onCopy | (value: string, path: string) => void | — | optional |
npm dependencies
Files installed (4)
-
components/ui/json-tree-view/JsonTreeView.tsx 10.1 kB
import * as React from 'react' import { ChevronDown, ChevronRight, Search, Braces, Copy, Check, 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 showPath?: 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, showPath = 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 [hoveredPath, setHoveredPath] = 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 } } function onHover(path: string | null) { setHoveredPath(path) } const displayPath = React.useMemo(() => { if (!showPath || !hoveredPath) return '' if (hoveredPath === '$') return rootLabel return hoveredPath }, [showPath, hoveredPath, rootLabel]) 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 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 && searchMatchCount > 0 && ( <span className="text-muted-foreground text-xs">{searchMatchCount} matches</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> )} {/* Path bar */} {showPath && displayPath && ( <div className="border-border bg-muted/30 text-muted-foreground truncate border-b px-3 py-1 text-xs"> {displayPath} </div> )} {/* Tree */} <div className="overflow-auto p-2"> <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} onHover={onHover} /> </div> </div> ) }) JsonTreeView.displayName = 'JsonTreeView' export { JsonTreeView } -
components/ui/json-tree-view/JsonTreeNode.tsx 7 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 onHover?: (path: string | null) => 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, onHover, } = 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]) function handleCopy() { onCopy?.(data, path) } function handleHover() { onHover?.(key) } function handleLeave() { onHover?.(null) } return ( <div data-dimmed={dimmed ? '' : undefined} className={dimmed ? 'opacity-30' : ''}> {/* Container header row (object/array) */} {isContainer && ( <div className="group hover:bg-accent/40 -mx-1 flex items-center gap-0.5 rounded px-1 py-0.5 transition-colors" style={{ paddingLeft: `${indent}px` }} > <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'} onClick={() => toggle(path)} > {open ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />} </button> <span className={`${keyColor} select-none`} onMouseEnter={handleHover} onMouseLeave={handleLeave}> {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 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" onClick={(e) => { e.stopPropagation() handleCopy() }} > {copiedPath === key ? <Check className="size-3 text-emerald-500" /> : <Copy className="size-3" />} </button> </div> )} {/* Container children */} {isContainer && open && ( <> {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} onHover={onHover} /> ))} <div className="text-muted-foreground py-0.5 select-none" style={{ paddingLeft: `${indent}px` }}> {type === 'array' ? ']' : '}'} </div> </> )} {/* Primitive leaf */} {!isContainer && ( <div className="group hover:bg-accent/40 -mx-1 flex items-center gap-0.5 rounded px-1 py-0.5 transition-colors" style={{ paddingLeft: `${indent}px` }} > <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'} cursor-pointer`} onClick={handleCopy} onMouseEnter={handleHover} onMouseLeave={handleLeave} > {formatValue(data)} </span> <button type="button" className="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" 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