WebberUI

Miller Column Browser

A Finder-style Miller-column hierarchy browser: every level you descend springs a new column in from the right, the whole row pans smoothly once it overflows, and breadcrumbs highlight the current path in step.

This is a WebberUI Pro component

Free during the launch campaign: sign up or sign in, then hit “Copy install command” in the preview above and it installs straight away — no payment, no credit card. The command below returns 401 while you are signed out.

How to install Pro components →See the plans →

Loading preview…
npx shadcn@latest add "https://webberui.com/r/miller-column-browser.json?t=<install token>"

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

240
<MillerColumnBrowser />

Installation

npx shadcn@latest add "https://webberui.com/r/miller-column-browser.json?t=<install token>"

Or, once registries are configured in components.json, install it as @webberui/miller-column-browser.

Usage

Pass in a tree of MillerNodes (items). A node with children is a folder — selecting it pushes a new column in from the right showing its children; a node without children is a leaf, and selecting it only highlights, without expanding further. The breadcrumbs reflect the current path in step, and clicking any level jumps back to it.

import {
  MillerColumnBrowser,
  type MillerNode,
} from "@/components/ui/miller-column-browser";

const items: MillerNode[] = [
  {
    id: "design",
    label: "Design system",
    children: [
      { id: "color", label: "Color", children: [{ id: "neutral", label: "Neutrals" }] },
      { id: "type", label: "Typography" },
    ],
  },
  { id: "docs", label: "Documentation" },
];

<div className="h-[320px]">
  <MillerColumnBrowser items={items} rootLabel="Library" />
</div>;

Every node is a MillerNode (id / label, with optional children / icon / meta / disabled). children can nest arbitrarily, producing columns of any depth.

Controlled mode

Pass path and onPathChange to enter controlled mode, where the current selection path (an array of node ids from the root to the current node) is fully owned outside. Without them it is uncontrolled, starting from defaultPath and managing state internally.

const [path, setPath] = React.useState<string[]>(["design", "color"]);

<MillerColumnBrowser
  items={items}
  path={path}
  onPathChange={(next) => setPath(next)}
/>;

Listening for leaf selection

onLeafSelect only fires when a node with no children is selected, which makes it a good place to load details or trigger navigation once a final item has been picked.

<MillerColumnBrowser
  items={items}
  onLeafSelect={(node, path) => console.log("selected", node.id, path)}
/>

Props

MillerColumnBrowser

PropTypeDefaultDescription
itemsMillerNode[]Root-level items of the hierarchical data
pathstring[]Controlled mode: the current selection path (array of node ids)
defaultPathstring[][]Uncontrolled mode: initial selection path
onPathChange(path: string[], node: MillerNode | null) => voidCallback when the selection path changes
onLeafSelect(node: MillerNode, path: string[]) => voidCallback when a leaf node (no children) is selected
breadcrumbsbooleantrueWhether to show the breadcrumb bar at the top
rootLabelReact.ReactNode"根目錄"Label shown for the root level in the breadcrumbs
columnWidthnumber240Width of each column (px)
emptyLabelReact.ReactNode"沒有項目"What to show for an empty folder
renderItem(node, state) => React.ReactNodeCustom rendering for a single row
classNamestringClass attached to the outermost container
columnClassNamestringClass applied to each column container

MillerNode

FieldTypeDescription
idstringUnique identifier; used for path resolution, selection highlighting, and de-duplication
labelReact.ReactNodeDisplay label; also used in the breadcrumbs
childrenMillerNode[]Child items; a node with children is an expandable folder that pushes a column when selected
iconReact.ReactNodeIcon before the label
metaReact.ReactNodeTrailing content to the right of the label (counts, dates, and so on)
disabledbooleanDisables this item (not selectable, not focusable)

How it works

  • Selecting a folder turns its children into a new column that springs in from the right; the column being left fades out and moves right, and AnimatePresence's popLayout mode keeps the exiting column out of the layout so going back does not jitter
  • A ResizeObserver measures the viewport width; when the total width of all columns overflows it, the whole row pans with a spring so that it is right-aligned (the pan distance = total content width − viewport width), which keeps the most recently expanded column always visible
  • The breadcrumbs reflect the current path live and highlight the current level; clicking any level truncates back with path.slice, and the root label collapses to the topmost position
  • Controlled and uncontrolled modes: pass path for controlled, otherwise defaultPath is managed internally; onPathChange fires in both modes
  • Selecting a leaf node (no children) only updates the highlight and breadcrumbs without pushing a column, and additionally fires onLeafSelect

Accessibility

  • When the user has "reduce motion" enabled at the system level, the spring transitions for pushing, panning, and exiting all drop to zero and positioning becomes instant; layout and functionality are unaffected
  • Each column is a role="listbox" (aria-orientation="vertical") and each row a role="option" marked with aria-selected for the current selection; disabled items are marked aria-disabled
  • Keyboard: / move focus within a column, Home / End jump to the first / last, expands a folder and moves focus into the new column, returns to the selected item one level up, and Enter / Space select
  • Roving tabindex: only the currently selected item (or the first) in each column is reachable with Tab, with the rest driven by arrow keys; focus moves into the new column automatically after expanding (preventScroll)
  • The breadcrumbs are a native <nav><ol> structure whose levels are focusable buttons, with the current level marked aria-current="page"

On this page