Skip to content
rvctv0.2.0

Examples

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.

Tick one file inside ui/ to see the indeterminate dash the real Radix checkbox renders in the same slot.

The source

What it looks like with the actual shadcn/ui Checkbox and lucide-react icons installed.

shadcn-tree.tsx
"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
/* 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

  • renderCheckbox

    Replaces the default input. Gets a11yProps, checkedState, onChange, and the same row context every render prop gets.

  • renderExpander

    Called for folders only. Gets a11yProps, isExpanded and onToggle — stop propagation in your onClick or the row's own handler toggles it back.

  • renderItem

    The row body. Returns content, not a row: the treeitem element, its ARIA and its absolute positioning are the library's.

  • indent

    Pixels of padding per level. Defaults to 20; 16 matches shadcn's tighter spacing scale.

  • className / style

    Applied to the scroll container, which is also the element carrying role="tree" and data-rvct-tree.

  • estimateSize

    32 by default, which already matches a size-4 checkbox with text-sm rows.

The gotcha