shadcn/ui styled
You already have shadcn/ui in the project and the tree has to look like everything else in it — same checkbox, same chevron, same radius, same focus ring. The library ships no CSS, so nothing has to be overridden — you hand your own controls in.
Why a tree here. A tree is the control here because the data is nested, but the visual language should stay yours. Three render props replace the checkbox, the expander and the row body, and the row markup with its ARIA stays the library's problem.
The demo
This page has no shadcn/ui installed. The checkbox below is hand-written to the exact shape Radix renders — a square carrying data-state="checked" | "indeterminate" | "unchecked" — so the code in the next section is a drop-in swap.
The source
What it looks like with the actual shadcn/ui Checkbox and lucide-react icons installed.
"use client";
import { ChevronRight, File, Folder } from "lucide-react";
import { CheckedState, Tree, type TreeDefinition } from "react-virtual-checkbox-tree";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
const files: TreeDefinition = {
__root__: { id: "__root__", label: "root", children: ["docs", "src"] },
docs: { id: "docs", label: "docs", children: ["readme", "guide"] },
readme: { id: "readme", label: "README.md" },
guide: { id: "guide", label: "guide.md" },
src: { id: "src", label: "src", children: ["engine", "ui"] },
engine: { id: "engine", label: "engine.ts" },
ui: { id: "ui", label: "ui", children: ["tree", "row"] },
tree: { id: "tree", label: "tree.tsx" },
row: { id: "row", label: "row.tsx" },
};
const INITIAL_EXPANDED = ["docs", "src", "ui"];
export function ShadcnTree() {
return (
<div className="rounded-md border bg-background p-2">
<Tree
aria-label="Project files"
data={files}
estimateSize={32}
expandedItems={INITIAL_EXPANDED}
height={288}
indent={16}
renderCheckbox={({ a11yProps, checkedState, onChange }) => (
<Checkbox
{...a11yProps}
// Radix wants true | false | "indeterminate" — the library's enum is
// "checked" | "unchecked" | "indeterminate". Map it, don't cast it.
checked={
checkedState === CheckedState.Indeterminate
? "indeterminate"
: checkedState === CheckedState.Checked
}
className="size-4"
onCheckedChange={(next) => onChange(next === true)}
/>
)}
renderExpander={({ a11yProps, isExpanded, onToggle }) => (
<ChevronRight
{...a11yProps}
className={cn(
"size-4 shrink-0 text-muted-foreground transition-transform duration-150",
isExpanded && "rotate-90"
)}
onClick={(event) => {
event.stopPropagation();
onToggle();
}}
/>
)}
renderItem={({ isFolder, item }) => (
<span className="flex items-center gap-2 text-sm">
{isFolder ? (
<Folder className="size-4 shrink-0 text-muted-foreground" />
) : (
<File className="size-4 shrink-0 text-muted-foreground/70" />
)}
<span>{item.label}</span>
</span>
)}
/>
</div>
);
}The CSS
Rows emit data-rvct-row, data-state, data-level, data-expanded, data-leaf and data-active; the container emits data-rvct-tree. That is the styling contract, and it does not change between versions without a note.
/* globals.css — the rows carry the state, so the selectors are CSS, not props. */
[data-rvct-row] {
border-radius: calc(var(--radius) - 2px);
padding-right: 0.5rem;
}
[data-rvct-row]:hover {
background: hsl(var(--accent));
}
/* aria-activedescendant means the active row never holds DOM focus —
:focus-visible will never match it. Style [data-active] instead. */
[data-rvct-row][data-active] {
background: hsl(var(--accent));
box-shadow: inset 0 0 0 1px hsl(var(--ring));
}
[data-rvct-row][data-state="indeterminate"] .rvct-label {
color: hsl(var(--muted-foreground));
}
[data-rvct-row][data-leaf] {
font-weight: 400;
}Props exercised
renderCheckboxReplaces the default input. Gets a11yProps, checkedState, onChange, and the same row context every render prop gets.
renderExpanderCalled for folders only. Gets a11yProps, isExpanded and onToggle — stop propagation in your onClick or the row's own handler toggles it back.
renderItemThe row body. Returns content, not a row: the treeitem element, its ARIA and its absolute positioning are the library's.
indentPixels of padding per level. Defaults to 20; 16 matches shadcn's tighter spacing scale.
className / styleApplied to the scroll container, which is also the element carrying role="tree" and data-rvct-tree.
estimateSize32 by default, which already matches a size-4 checkbox with text-sm rows.