react-json-virtualization
v0.7.0
Published
Virtualized React JSON viewer for very large JSON strings with token-based color theming.
Maintainers
Readme
react-json-virtualization
Virtualized React JSON viewer for large JSON strings with token-based color theming.
Features
- Incremental async JSON parsing on the main thread
- Virtualized tree rendering for large nested structures
- JSON-only metadata/tree mode
- Plain virtualized text mode for non-JSON text-like formats (for example Markdown, XML, YAML, TXT)
- Expand/collapse nodes with keyboard support
- Path and primitive-value search over JSON paths
- Dedicated
searchQuerysupport with AND-combination againstpathFilterQuery - Direct-match highlighting for tree rows and pretty-printed lines
- Active match navigation via
activeMatchIndexfor external search UI - Row-level customization hooks (filter, decorators, render overrides)
- Trie-indexed prefix path search for low-latency filtering on large trees
- Controlled and uncontrolled expansion state
- Token-based color theme overrides (keys, values, punctuation)
- TypeScript-first public API
Install
npm install react-json-virtualizationDemo
Try the live demo:
https://mietoprogramming.github.io/react-json-virtualization/
Mode comparison note (Collapsable vs Static):
demo/public/docs/virtualizejson-modes.md
Benchmark Snapshot (Modes)
Measured with:
npm run bench:modesEnvironment: Node v24.14.1, linux x64, fixtures from bench/fixtures/generated, 5 iterations.
Scope: Collapsable and Static include parse + expansion + flatten work. Plain (metadata=false) measures pretty-line generation only.
large-10mb.json (10.00 MB)
| Mode | Avg (ms) | Min (ms) | Max (ms) | Output size | vs Collapsable |
| --- | ---: | ---: | ---: | ---: | ---: |
| Collapsable (metadata=true, depth=1) | 1024.77 | 1017.08 | 1042.66 | 64,838 rows | 1.00x |
| Static (metadata=true, alwaysExpanded) | 1384.20 | 1308.87 | 1468.24 | 842,834 rows | 1.35x |
| Plain (metadata=false) | 136.72 | 127.33 | 143.63 | 1,037,335 lines | 0.13x |
large-50mb.json (50.00 MB)
| Mode | Avg (ms) | Min (ms) | Max (ms) | Output size | vs Collapsable |
| --- | ---: | ---: | ---: | ---: | ---: |
| Collapsable (metadata=true, depth=1) | 5230.99 | 4973.87 | 5534.76 | 324,181 rows | 1.00x |
| Static (metadata=true, alwaysExpanded) | 7630.65 | 7380.08 | 8191.26 | 4,214,293 rows | 1.46x |
| Plain (metadata=false) | 911.44 | 839.57 | 1030.96 | 5,186,823 lines | 0.17x |
large-100mb.json (100.00 MB)
| Mode | Avg (ms) | Min (ms) | Max (ms) | Output size | vs Collapsable |
| --- | ---: | ---: | ---: | ---: | ---: |
| Collapsable (metadata=true, depth=1) | 10771.17 | 10303.29 | 11218.33 | 648,359 rows | 1.00x |
| Static (metadata=true, alwaysExpanded) | 16062.59 | 15595.29 | 16422.84 | 8,428,607 rows | 1.49x |
| Plain (metadata=false) | 1776.98 | 1639.63 | 1935.16 | 10,373,671 lines | 0.16x |
Plain is fastest in this end-to-end benchmark because metadata=false bypasses incremental tree parsing and flattening.
Usage
import { VirtualizeJSON } from "react-json-virtualization";
const payload = JSON.stringify({
users: [{ id: 1, name: "Ada" }, { id: 2, name: "Linus" }],
stats: { total: 2, active: true }
});
export function Example() {
return (
<VirtualizeJSON.Collapsable
json={payload}
metadata={true}
height={480}
rowHeight={24}
initialExpandDepth={1}
pathFilterQuery="$.users[1]"
theme={{
key: "#8a4f00",
string: "#006b4f",
number: "#094b9d"
}}
/>
);
}Static, always-expanded viewer:
import { VirtualizeJSON } from "react-json-virtualization";
export function StaticExample({ json }: { json: string }) {
return <VirtualizeJSON.Static json={json} height={480} rowHeight={24} />;
}Search navigation (Ctrl+F-style UI managed by the host app):
import { useState } from "react";
import { VirtualizeJSON, type JSONViewerSearchMetadata } from "react-json-virtualization";
export function SearchableViewer({ json }: { json: string }) {
const [searchQuery, setSearchQuery] = useState("");
const [activeMatchIndex, setActiveMatchIndex] = useState<number | null>(null);
const [searchMetadata, setSearchMetadata] = useState<JSONViewerSearchMetadata | null>(null);
const availableMatches = searchMetadata?.matchedRowIds.length ?? 0;
const hasMore = searchMetadata?.hasMore ?? false;
const goNext = (): void => {
if (availableMatches === 0) {
return;
}
setActiveMatchIndex((current) => {
const next = current === null ? 0 : (current + 1) % availableMatches;
return next;
});
};
const goPrev = (): void => {
if (availableMatches === 0) {
return;
}
setActiveMatchIndex((current) => {
const next = current === null ? availableMatches - 1 : (current - 1 + availableMatches) % availableMatches;
return next;
});
};
return (
<div>
<div>
<input value={searchQuery} onChange={(event) => setSearchQuery(event.target.value)} />
<button type="button" onClick={goPrev}>Prev</button>
<button type="button" onClick={goNext}>Next</button>
<span>
{availableMatches === 0 ? "0" : `${(activeMatchIndex ?? 0) + 1} / ${availableMatches}${hasMore ? "+" : ""}`}
</span>
</div>
<VirtualizeJSON.Collapsable
json={json}
searchQuery={searchQuery}
activeMatchIndex={activeMatchIndex}
onSearchMetadata={setSearchMetadata}
/>
</div>
);
}Row customization (highlight, actions, and custom rendering):
import { VirtualizeJSON, type JSONViewerRowDecorator, type JSONViewerRowFilter, type JSONViewerRowRenderer } from "react-json-virtualization";
const rowFilter: JSONViewerRowFilter = (context) => {
if (context.text.toLowerCase().includes("internal")) {
return false;
}
return true;
};
const rowDecorator: JSONViewerRowDecorator = (context) => {
if (!context.text.toLowerCase().includes("craw")) {
return undefined;
}
return {
className: "row-highlight",
actions: (
<button type="button" onClick={(event) => event.stopPropagation()}>
Flag
</button>
)
};
};
const rowRenderer: JSONViewerRowRenderer = (context, defaultContent) => {
if (!context.text.toLowerCase().includes("craw")) {
return defaultContent;
}
return (
<button type="button" onClick={(event) => event.stopPropagation()}>
{defaultContent}
</button>
);
};
export function CustomRows({ json }: { json: string }) {
return (
<VirtualizeJSON.Collapsable
json={json}
rowFilter={rowFilter}
rowDecorator={rowDecorator}
rowRenderer={rowRenderer}
/>
);
}Note: custom render content should stay within the fixed row height to keep virtualization accurate.
API
VirtualizeJSON.Collapsable props
json: stringRaw JSON string input.sourceFormat?: "auto" | "json" | "yaml" | "xml" | "markdown" | "text"Input format hint. Default"auto", which infers by content. All non-JSON formats render in plain virtualized mode.metadata?: booleanEnables tree metadata mode (Object/Array counts, expansion, filtering, virtualization). Defaulttrue. Tree metadata is available only forjson. All non-JSON formats render a virtualized plain-text view.showLineNumbers?: booleanShows line numbers in the virtualized pretty-printed JSON view (metadata=false). Defaulttrue.height?: number | stringContainer height. Default520.rowHeight?: numberFixed row height used by virtualization. Default24.overscan?: numberNumber of extra rows rendered around viewport. Default8.initialExpandDepth?: numberInitial expansion depth from root. Default1.expandedPaths?: ReadonlySet<string>Controlled expanded path set.defaultExpandedPaths?: Iterable<string>Initial expanded paths in uncontrolled mode.onExpandedPathsChange?: (paths) => voidExpansion state callback.pathFilterQuery?: stringFilters by JSON path and all JSON value types (string,number,boolean,null,object,array). Unquoted terms are split by whitespace and matched with OR semantics (zero hellomatches either term). Wrap exact phrases in quotes (for example"new york" name). Quoted single terms are treated the same as unquoted terms. Inmetadata=false, it filters pretty-printed lines.searchQuery?: stringHighlights matches without filtering rows or lines. Uses the same tokenization aspathFilterQuery. Match strategy is controlled bysearchMode(defaults topathFilterMode); case sensitivity is controlled bysearchCaseSensitive.activeMatchIndex?: number | null0-based index into the currentmatchedPaths(tree) ormatchedLineNumbers(plain) list fromonSearchMetadata. The viewer scrolls to the active match and applies an active-match highlight. In tree mode, the viewer also updates internal selection whenselectedPathis uncontrolled.pathFilterCaseSensitive?: booleanCase-sensitive path filter mode.searchCaseSensitive?: booleanCase-sensitive search mode (applies to path and value matching). Defaultfalse. Note: prior versions inheritedpathFilterCaseSensitivefor search; set this totrueto preserve that behavior.pathFilterMode?: "auto" | "prefix" | "includes" | "exact"Filter strategy. Defaults toauto.exactmatches full path/segments and full values.searchMode?: "auto" | "prefix" | "includes" | "exact"Search strategy. Defaults topathFilterMode.exactmatches full path/segments and full values.searchMetadataLimit?: numberMaximum identifiers returned in search metadata arrays. Default500.theme?: JsonThemeOverridePer-token color overrides.selectedPath?: stringControlled selected node path.className?: stringOptional custom class.rowFilter?: (context) => booleanOptional row/line filter applied afterpathFilterQueryand before search metadata. Returntrueto keep a row,falseto hide it.rowDecorator?: (context) => { className?; style?; leading?; trailing?; actions? }Optional per-row decoration for classes, styles, and extra UI slots.rowRenderer?: (context, defaultContent) => ReactNodeOptional override for the row body content while keeping the row container intact.onNodeClick?: (path, row) => voidNode selection callback.onSearchMetadata?: (metadata) => voidSearch callback with counts and capped match identifiers for bothmetadata=true(tree rows) andmetadata=false(pretty lines). Does not filter the view.onParseProgress?: (processed, total) => voidParse progress callback.onParseError?: (error) => voidParse error callback.
onSearchMetadata payload fields:
mode: "tree" | "plain"query,pathFilterQuery,searchQuerymatchCount(direct matches)visibleCount(rows or lines currently visible after context inclusion)matchedPaths,matchedRowIds,matchedLineNumbers(all capped bysearchMetadataLimit)hasMore(truewhen any metadata list was truncated)
When hasMore is true, matchCount can exceed the length of matchedPaths or matchedLineNumbers. Use searchMetadataLimit to raise the cap if you need to navigate every match.
Row customization context fields (both modes) include:
mode:treeorplainid: row id (row.id) or line id (line:${lineNumber})text: row or line text used for quick matchingsourceFormat: resolved source format
Tree mode adds: path, row (the full FlatJsonRow).
Plain mode adds: line, lineIndex, lineNumber.
VirtualizeJSON.Static props
All VirtualizeJSON.Collapsable props except expansion control props:
- Omitted:
initialExpandDepth - Omitted:
expandedPaths - Omitted:
defaultExpandedPaths - Omitted:
onExpandedPathsChange
VirtualizeJSON.Static always renders all expandable paths and disables collapse/expand interactions.
Migration (breaking change)
JSONViewerwas renamed toVirtualizeJSON.Collapsable.- Static always-expanded mode is available as
VirtualizeJSON.Static. pathFilterQuerynow treats unquoted whitespace as multiple OR terms. Use quotes to keep multi-word phrases together.
Expansion helper exports
createExpandedPathSet(paths?)expandPath(current, path)collapsePath(current, path)toggleExpandedPath(current, path)expandedPathsFromDepth(root, depth)
Path filter helper export
filterRowsByPathQuery(rows, query, options?)createPathSearchIndex(rows, { caseSensitive? })
For best path-filter performance on very large expanded row sets, use mode: "prefix" with a cached index from createPathSearchIndex.
