JSON Viewer
Collapsible, searchable JSON with typed colors, copy value or path, and chunked large arrays.
@drift-ui/json-viewerRegistry JSON- id:"evt_1Qx8"
- type:"invoice.paid"
- livemode:false
- created:1793350800
- data:{…}
- invoice:{…}
- number:"INV-0042"
- amount_due:4900
- currency:"usd"
- customer:{…}
- lines:Array(2)
- retries:Array(240)
- [0 … 99]
- [100 … 199]
- [200 … 239]
On this page
Installation
The CLI also installs the npm packages and registry items it builds on. Register the namespace once first with https://drift-ui.dev/r/{style}/{name}.json. The CLI fills in {style} from your components.json and installs the Base UI or Radix build to match. See setup.
API reference
JsonViewer
A searchable JSON tree on top of TreeView. Rows show copy value and copy path buttons on hover, or always on touch screens; Ctrl or Cmd+C copies the focused value and Shift+Ctrl or Cmd+C its path. Arrays over 100 items open as groups of 100.
| Prop | Type | Default |
|---|---|---|
dataRequiredunknownAny JSON value. | unknown | |
defaultExpandDepthnumber= 1How many levels start open. | number | 1 |
onCopy(path: string, value: unknown, target: "value" | "path") => voidCalled after a value or path is copied. | (path: string, value: unknown, target: "value" | "path") => void |
Other props go to the <div>.
Source
"use client"
import * as React from "react"
import { CheckIcon, CopyIcon, RouteIcon } from "lucide-react"
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
import {
childEntries,
expandedToDepth,
formatValue,
rootEntries,
searchJson,
sizeOf,
type JsonEntry,
} from "@/lib/json-viewer"
import { cn } from "@/lib/utils"
import { Input } from "@/components/ui/input"
import { TreeView, type TreeNode } from "@/components/ui/tree-view"
type CopyTarget = "value" | "path"
const TYPE_CLASSES: Record<string, string> = {
string: "text-[color-mix(in_oklab,var(--chart-2)_65%,var(--foreground))]",
number: "text-[color-mix(in_oklab,var(--chart-1)_65%,var(--foreground))]",
boolean: "text-[color-mix(in_oklab,var(--chart-3)_65%,var(--foreground))]",
object: "text-muted-foreground italic",
}
function Highlight({ text, query }: { text: string; query: string }) {
const index = query ? text.toLowerCase().indexOf(query.toLowerCase()) : -1
if (index === -1) return text
return (
<>
{text.slice(0, index)}
<mark className="rounded-sm bg-chart-4/40 text-inherit">
{text.slice(index, index + query.length)}
</mark>
{text.slice(index + query.length)}
</>
)
}
function ValueText({ value, query }: { value: unknown; query: string }) {
if (Array.isArray(value)) {
return (
<span className="text-muted-foreground">
{value.length === 0 ? "[]" : `Array(${value.length})`}
</span>
)
}
if (typeof value === "object" && value !== null) {
return (
<span className="text-muted-foreground">
{sizeOf(value) === 0 ? "{}" : "{…}"}
</span>
)
}
const text = String(value)
return (
<span className={TYPE_CLASSES[typeof value]}>
{typeof value === "string" ? (
<>
"
<Highlight text={text} query={query} />
"
</>
) : (
<Highlight text={text} query={query} />
)}
</span>
)
}
function JsonViewer({
data,
defaultExpandDepth = 1,
onCopy,
className,
"aria-label": ariaLabel = "JSON",
...props
}: Omit<React.ComponentProps<"div">, "onCopy"> & {
data: unknown
defaultExpandDepth?: number
onCopy?: (path: string, value: unknown, target: CopyTarget) => void
}) {
const [expanded, setExpanded] = React.useState(() =>
expandedToDepth(data, defaultExpandDepth)
)
const [expandedBeforeSearch, setExpandedBeforeSearch] = React.useState<
string[] | null
>(null)
const [query, setQuery] = React.useState("")
const { copied, copy } = useCopyToClipboard()
const needle = query.trim()
const { matches } = React.useMemo(
() => searchJson(data, needle),
[data, needle]
)
const expandedIds = new Set(expanded)
const entriesById = new Map<string, JsonEntry>()
function copyEntry(entry: JsonEntry, target: CopyTarget) {
const text = target === "value" ? formatValue(entry.value) : entry.path
copy(text, `${target}:${entry.id}`).then(() =>
onCopy?.(entry.path, entry.value, target)
)
}
function search(next: string) {
setQuery(next)
const base = expandedBeforeSearch ?? expanded
const { ancestors } = searchJson(data, next)
setExpandedBeforeSearch(next.trim() ? base : null)
setExpanded([...new Set([...base, ...ancestors])])
}
function renderRow(entry: JsonEntry) {
if (entry.range) {
return (
<span
data-json-id={entry.id}
className="font-mono text-xs text-muted-foreground"
>
[{entry.range[0]} … {entry.range[1]}]
</span>
)
}
return (
<span
data-json-id={entry.id}
className="inline-flex min-w-0 items-center gap-1.5 font-mono text-xs"
>
{entry.key !== null && (
<span
className={
typeof entry.key === "number"
? "text-muted-foreground"
: "text-foreground"
}
>
<Highlight
text={String(entry.key)}
query={typeof entry.key === "string" ? needle : ""}
/>
:
</span>
)}
<ValueText value={entry.value} query={needle} />
<span
aria-hidden
className="inline-flex opacity-0 transition-opacity pointer-coarse:opacity-100 [[role=treeitem]:focus-visible_&]:opacity-100 [[role=treeitem]:hover_&]:opacity-100"
>
{(["value", "path"] as const).map((target) => (
<button
key={target}
type="button"
tabIndex={-1}
aria-label={`Copy ${target}`}
onClick={(event) => {
event.stopPropagation()
copyEntry(entry, target)
}}
className="inline-flex size-6 items-center justify-center rounded-sm hover:bg-background"
>
{copied === `${target}:${entry.id}` ? (
<CheckIcon />
) : target === "value" ? (
<CopyIcon />
) : (
<RouteIcon />
)}
</button>
))}
</span>
</span>
)
}
function toNodes(entries: JsonEntry[]): TreeNode[] {
return entries.map((entry) => {
entriesById.set(entry.id, entry)
return {
id: entry.id,
label: renderRow(entry),
textValue: entry.key === null ? "" : String(entry.key),
isBranch: entry.isBranch,
children:
entry.isBranch && expandedIds.has(entry.id)
? toNodes(childEntries(entry.value, entry.path, entry.range))
: undefined,
}
})
}
function handleKeyDown(event: React.KeyboardEvent<HTMLUListElement>) {
if (event.key.toLowerCase() !== "c") return
if (!(event.metaKey || event.ctrlKey)) return
if (window.getSelection()?.toString()) return
const id = (event.target as HTMLElement).querySelector<HTMLElement>(
"[data-json-id]"
)?.dataset.jsonId
const entry = id ? entriesById.get(id) : undefined
if (!entry || entry.range) return
event.preventDefault()
copyEntry(entry, event.shiftKey ? "path" : "value")
}
return (
<div
data-slot="json-viewer"
className={cn("flex flex-col gap-2 rounded-lg border p-2", className)}
{...props}
>
<div className="flex items-center gap-2">
<Input
type="search"
aria-label="Search keys and values"
placeholder="Search keys and values"
value={query}
onChange={(event) => search(event.target.value)}
className="h-8"
/>
<span role="status" className="shrink-0 text-xs text-muted-foreground">
{needle &&
`${matches.size} ${matches.size === 1 ? "match" : "matches"}`}
</span>
</div>
<TreeView
aria-label={ariaLabel}
items={toNodes(rootEntries(data))}
expanded={expanded}
onExpandedChange={setExpanded}
onKeyDown={handleKeyDown}
className="overflow-auto"
/>
<span role="status" className="sr-only">
{copied ? `Copied ${copied.slice(0, copied.indexOf(":"))}` : ""}
</span>
</div>
)
}
export { JsonViewer }