A horizontal strip of vertical lists where selecting a node reveals its children in the next column — the macOS Finder column view, with drag/keyboard-resizable columns and an optional preview pane.
Playground
Preview
import{MillerColumns,MillerColumnsColumn,MillerColumnsItem,MillerColumnsItemIndicator,MillerColumnsResizeHandle,MillerColumnsPreviewPanel}from"@/components/ui/miller-columns";import{ useMillerColumnsSelection }from"@primitiv-ui/react";// nodeById: your own Map<id, node>, built from the tree data you render.functionFilePreview(){const{ selectedValue }=useMillerColumnsSelection();const node = selectedValue ? nodeById.get(selectedValue):undefined;if(!node)returnnull;return(<div>{node.image&&<imgsrc={node.image}alt=""/>}<strong>{node.label}</strong><span>{node.meta}</span></div>);}functionNode({ node }){return(<MillerColumnsItemvalue={node.id}>{node.label}{node.children?(<><MillerColumnsItemIndicator/><MillerColumnsColumn><MillerColumnsResizeHandlearia-label={`Resize ${node.label}`}/>{node.children.map((child)=><Nodekey={child.id}node={child}/>)}</MillerColumnsColumn></>):null}</MillerColumnsItem>);}<MillerColumnsaria-label="Files"size="md"defaultValue={["cover"]}><MillerColumnsColumn><MillerColumnsResizeHandlearia-label="Resize column"minWidth={140}maxWidth={320}/>{tree.map((node)=><Nodekey={node.id}node={node}/>)}</MillerColumnsColumn><MillerColumnsPreviewPanel><FilePreview /></MillerColumnsPreviewPanel></MillerColumns>
import{MillerColumns, useMillerColumnsSelection }from"@primitiv-ui/react";// nodeById: your own Map<id, node>, built from the tree data you render.functionFilePreview(){const{ selectedValue }=useMillerColumnsSelection();const node = selectedValue ? nodeById.get(selectedValue):undefined;if(!node)returnnull;return(<div>{node.image&&<imgsrc={node.image}alt=""/>}<strong>{node.label}</strong><span>{node.meta}</span></div>);}functionNode({ node }){return(<MillerColumns.Itemvalue={node.id}>{node.label}{node.children?(<><MillerColumns.ItemIndicator/><MillerColumns.Column><MillerColumns.ResizeHandlearia-label={`Resize ${node.label}`}/>{node.children.map((child)=><Nodekey={child.id}node={child}/>)}</MillerColumns.Column></>):null}</MillerColumns.Item>);}<MillerColumns.Rootaria-label="Files"defaultValue={["cover"]}><MillerColumns.Column><MillerColumns.ResizeHandlearia-label="Resize column"minWidth={140}maxWidth={320}/>{tree.map((node)=><Nodekey={node.id}node={node}/>)}</MillerColumns.Column><MillerColumns.PreviewPanel><FilePreview /></MillerColumns.PreviewPanel></MillerColumns.Root>
Density is set by a data-density ancestor — the Context system, not a MillerColumns prop.
Copied into your project as .primitiv-miller-columns — you own the file afterwards, so upgrades are opt-in.
Headless mode installs the npm package instead: @primitiv-ui/react
Anatomy
Six parts, authored recursively — there is no data prop. A MillerColumns.Item becomes a branch by nesting a MillerColumns.Column among its children (with a MillerColumns.ItemIndicator chevron and a MillerColumns.ResizeHandle); an item with no nested column is a leaf. Every column is projected into the strip so they sit side by side. MillerColumns.PreviewPanel is an optional trailing pane — a sibling of the tree (a role="tree" may own only tree items), whose content you supply and drive from useMillerColumnsSelection.
<MillerColumns><MillerColumnsColumn><MillerColumnsResizeHandle/><MillerColumnsItem>{/* a leaf */}<MillerColumnsItem>{/* a branch: */}<MillerColumnsItemIndicator/><MillerColumnsColumn>...</MillerColumnsColumn>{/* projected beside its parent */}</MillerColumnsItem></MillerColumnsColumn><MillerColumnsPreviewPanel/>{/* optional trailing pane */}</MillerColumns>
<MillerColumns.Root><MillerColumns.Column><MillerColumns.ResizeHandle/><MillerColumns.Item>{/* a leaf */}<MillerColumns.Item>{/* a branch: */}<MillerColumns.ItemIndicator/><MillerColumns.Column>...</MillerColumns.Column>{/* projected beside its parent */}</MillerColumns.Item></MillerColumns.Column><MillerColumns.PreviewPanel/>{/* optional trailing pane */}</MillerColumns.Root>
Props
Generated from source — headless rows from each *Props type’s JSDoc, styled rows from contract.json. Never hand-maintained.
MillerColumns.Root
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
children* (required)
ReactNode
—
headless
The strip's tree, authored recursively: root
MillerColumnsColumnPropsColumn(s) whose branch
MillerColumnsItemPropsItems nest further Columns, plus an
optional trailing MillerColumnsPreviewPanelPropsPreviewPanel.
There is no items data prop — the JSX is the data.
defaultValue
string[]
—
headless
Initial selection path on first render, root column first (e.g.
["docs", "guides"]). The component owns the path thereafter.
Forbidden in controlled mode — use value instead.
dir
"ltr" | "rtl"
"ltr"
headless
Reading direction. Under "rtl" the horizontal arrow pair is
mirrored — ArrowLeft steps into a branch's child column and
ArrowRight walks back out — so navigation follows the visual order.
Inherited from a DirectionProvider ancestor when omitted.
onValueChange
(path: string[]) => void
—
headless
Forbidden in uncontrolled mode — pass value + onValueChange to
control the component.
Called with the new selection path whenever the selection changes.
Required in controlled mode.
value
string[]
—
headless
Forbidden in uncontrolled mode — use defaultValue instead.
The controlled selection path, root column first. Must be kept in sync
by the parent via onValueChange.
size
"xs" | "sm" | "md" | "lg" | "xl"
md
styled
Row and column size for the whole strip; data-density scales each size further.
MillerColumns.Column
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
children
ReactNode
—
headless
The column's MillerColumnsItemPropsItems and, optionally, a
MillerColumnsResizeHandlePropsResizeHandle. Optional: a
childless branch legitimately opens an empty column, which carries
data-empty for the styling layer.
MillerColumns.Item
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
children* (required)
ReactNode
—
headless
The cell content and, for a branch item, a single nested
<MillerColumns.Column> (split out by partitionItemChildren).
value* (required)
string
—
headless
Stable identifier used to match this item against the selection path.
The item is selected when the path entry at its depth equals value.
asChild
boolean
false
headless
Render the cell as a single consumer-supplied child element (e.g. an
<a>) instead of the default <div>, merging the treeitem ARIA,
handlers, and ref onto it via the Slot pattern. Any nested
<MillerColumns.Column> remains a sibling of the cell element.
disabled
boolean
false
headless
When true, the item renders aria-disabled / data-disabled and is
skipped by roving navigation — it cannot be selected, focused, or
activated.
ref
Ref<T>
—
headless
Forwarded to the rendered element. Defaults to HTMLDivElement; when
using asChild, specify the child's element type. Composed with the
library's internal ref.
MillerColumns.ItemIndicator
Extends HTMLSpanElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
children
ReactNode
—
headless
The glyph to render (e.g. ▸). Optional.
MillerColumns.ResizeHandle
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
maxWidth
number
Infinity
headless
Largest width the column can be dragged or stepped to, in pixels.
Published as aria-valuemax when finite; left unbounded by default,
in which case the attribute is omitted.
minWidth
number
0
headless
Smallest width the column can be dragged or stepped to, in pixels.
Also published as aria-valuemin.
step
number
10
headless
Pixels moved per arrow-key press.
MillerColumns.PreviewPanel
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
children
ReactNode
—
headless
The preview content for the current selection (typically driven by
useMillerColumnsSelection).
forceMount
boolean
false
headless
Keep the panel mounted regardless of the selection, instead of following
Finder's rule (a preview only where a selected leaf would open a child
column). Use it for a persistent inspector, or to animate the panel in
and out.
Styling contract
34 CSS custom properties on .primitiv-miller-columns — mode-agnostic. These names are the stable surface; the values are not.
The rows share a single tab stop (a roving tabindex). Movement is within the focused column, and the horizontal arrows step between columns — into a branch's child column and back to its parent — the WAI-ARIA tree model, mirrored under dir="rtl". Selection is single only: the selection is the path, so each column exists because exactly one item in the column before it is chosen. The MillerColumns.ResizeHandle is a separate WAI-ARIA window splitter — focus it and the arrow keys resize the column (bounded by minWidth / maxWidth, stepped by step).
Key
Behaviour
ArrowDown / ArrowUp
Move focus within the current column.
Home / End
Focus the first / last item in the column.
ArrowRight
Open a branch and step into its child column. A no-op on a leaf.
ArrowLeft
Return focus to the parent column.
Enter / Space
Select the focused item.
character
Typeahead — jump to the next item in the column whose label starts with the typed characters.
Data attributes
Emitted automatically by the headless primitive — style against these rather than adding your own state classes. Grouped by the part that emits them, since most are emitted by more than one.
MillerColumns
className:.primitiv-miller-columns
Attribute
Value
When
data-miller-columns-strip
""
always — a structural hook the component's own CSS uses to find this part, and yours may too
data-miller-columns-slot
""
always — a structural hook the component's own CSS uses to find this part, and yours may too
data-orientation
horizontal
always
MillerColumns.Root
Attribute
Value
When
data-miller-columns-strip
""
always — a structural hook the component's own CSS uses to find this part, and yours may too
data-miller-columns-slot
""
always — a structural hook the component's own CSS uses to find this part, and yours may too
data-orientation
horizontal
always
MillerColumnsColumn
className:.primitiv-miller-columns__column
Attribute
Value
When
data-miller-columns-column
""
always — a structural hook the component's own CSS uses to find this part, and yours may too
data-depth
{n}
always
data-empty
""
the column holds no items
MillerColumns.Column
Attribute
Value
When
data-miller-columns-column
""
always — a structural hook the component's own CSS uses to find this part, and yours may too
always — a structural hook the component's own CSS uses to find this part, and yours may too
data-dragging
""
a pointer drag is in progress
MillerColumns.ResizeHandle
Attribute
Value
When
data-miller-columns-resize-handle
""
always — a structural hook the component's own CSS uses to find this part, and yours may too
data-dragging
""
a pointer drag is in progress
MillerColumnsPreviewPanel
className:.primitiv-miller-columns__preview
Attribute
Value
When
data-miller-columns-preview
""
always — a structural hook the component's own CSS uses to find this part, and yours may too
data-empty
""
nothing is selected
MillerColumns.PreviewPanel
Attribute
Value
When
data-miller-columns-preview
""
always — a structural hook the component's own CSS uses to find this part, and yours may too
data-empty
""
nothing is selected
Accessibility
MillerColumns.Root renders the scroll strip and, inside it, the role="tree" widget that holds the columns — so aria-label / aria-labelledby land on the inner tree, which is what names the widget. Each item is a role="treeitem" carrying aria-level and aria-expanded.
The rows share one tab stop. Tab moves into the widget and then out of it; the arrow keys move within and between columns (right steps into a branch, left back to the parent). A grid of focusable rows would make a keyboard user Tab through everything — the roving tabindex is the tree pattern's answer.
MillerColumns.ResizeHandle is a WAI-ARIA window splitter with aria-valuemin / aria-valuemax / aria-valuenow — give it an aria-label, or it announces as an unnamed separator. It is operable by both pointer drag and the keyboard.
MillerColumns.ItemIndicator is a decorative aria-hidden chevron — the branch state is announced through aria-expanded, so a labelled chevron would say it twice.
The MillerColumns.PreviewPanel sits outside the role="tree" (a tree may own only tree items and groups), so its content is not announced as part of the tree. Drive it from useMillerColumnsSelection and give it its own structure and labelling.
Related: reach for Tree when the hierarchy should show its depth in one vertical list rather than a strip of columns. Miller Columns is the wide, drill-in view where each level gets its own column.
Examples
Every example below reacts to the density control. Currently showing Styled mode.
A file browser
The canonical column view: selecting a folder reveals its contents in the next column. The tree is authored by recursive composition — an item is a branch when it nests a MillerColumns.Column, a leaf when it does not (Archive nests an empty column, so it opens a blank one). defaultValue is the selection path — an array of ids, root first. Every column carries a MillerColumns.ResizeHandle: drag the seam between columns, or focus it and use the arrow keys.
Add a MillerColumns.PreviewPanel after the columns and it fills the strip's remaining width — Finder's rightmost pane. It is content-agnostic: read the current selection with useMillerColumnsSelection (from the headless package — the registry surface does not re-export it) and render whatever the selection warrants — here a thumbnail for an image file. The pane mounts for a selected leaf; pick another file or drill into a folder to see it update (a document shows just its details).
import{MillerColumns,MillerColumnsColumn,MillerColumnsItem,MillerColumnsItemIndicator,MillerColumnsResizeHandle,MillerColumnsPreviewPanel}from"@/components/ui/miller-columns";import{ useMillerColumnsSelection }from"@primitiv-ui/react";// nodeById: your own Map<id, node>, built from the tree data you render.functionFilePreview(){const{ selectedValue }=useMillerColumnsSelection();const node = selectedValue ? nodeById.get(selectedValue):undefined;if(!node)returnnull;return(<div>{node.image&&<imgsrc={node.image}alt=""/>}<strong>{node.label}</strong><span>{node.meta}</span></div>);}functionNode({ node }){return(<MillerColumnsItemvalue={node.id}>{node.label}{node.children?(<><MillerColumnsItemIndicator/><MillerColumnsColumn><MillerColumnsResizeHandlearia-label={`Resize ${node.label}`}/>{node.children.map((child)=><Nodekey={child.id}node={child}/>)}</MillerColumnsColumn></>):null}</MillerColumnsItem>);}<MillerColumnsaria-label="Files"defaultValue={["wallpaper"]}><MillerColumnsColumn><MillerColumnsResizeHandlearia-label="Resize column"minWidth={140}maxWidth={320}/>{tree.map((node)=><Nodekey={node.id}node={node}/>)}</MillerColumnsColumn><MillerColumnsPreviewPanel><FilePreview /></MillerColumnsPreviewPanel></MillerColumns>
import{MillerColumns, useMillerColumnsSelection }from"@primitiv-ui/react";// nodeById: your own Map<id, node>, built from the tree data you render.functionFilePreview(){const{ selectedValue }=useMillerColumnsSelection();const node = selectedValue ? nodeById.get(selectedValue):undefined;if(!node)returnnull;return(<div>{node.image&&<imgsrc={node.image}alt=""/>}<strong>{node.label}</strong><span>{node.meta}</span></div>);}functionNode({ node }){return(<MillerColumns.Itemvalue={node.id}>{node.label}{node.children?(<><MillerColumns.ItemIndicator/><MillerColumns.Column><MillerColumns.ResizeHandlearia-label={`Resize ${node.label}`}/>{node.children.map((child)=><Nodekey={child.id}node={child}/>)}</MillerColumns.Column></>):null}</MillerColumns.Item>);}<MillerColumns.Rootaria-label="Files"defaultValue={["wallpaper"]}><MillerColumns.Column><MillerColumns.ResizeHandlearia-label="Resize column"minWidth={140}maxWidth={320}/>{tree.map((node)=><Nodekey={node.id}node={node}/>)}</MillerColumns.Column><MillerColumns.PreviewPanel><FilePreview /></MillerColumns.PreviewPanel></MillerColumns.Root>
Selection path
The selection is the path — an array of ids, root first — so a controlled value hands you the full root-to-leaf trail directly (or read it from useMillerColumnsSelection().path in an uncontrolled strip). That is what you render as a breadcrumb of where the user is; here each crumb navigates back up when clicked. onValueChange fires with the whole new path on every change. There is no multi-select: the selection is the path, so a column can have only one chosen item.
import{MillerColumns,MillerColumnsColumn,MillerColumnsItem,MillerColumnsItemIndicator,MillerColumnsResizeHandle}from"@/components/ui/miller-columns";import{ useState }from"react";const[path, setPath]=useState(["documents","work","report"]);// nodeById: your own Map<id, node>, built from the tree data you render.{/* the path is the value — render it as a breadcrumb */}<navaria-label="File path">{path.map((id, i)=>(<buttonkey={id}onClick={()=>setPath(path.slice(0, i +1))}>{nodeById.get(id).label}</button>))}</nav><MillerColumnsaria-label="Files"value={path}onValueChange={setPath}><MillerColumnsColumn><MillerColumnsResizeHandlearia-label="Resize column"/>{tree.map((node)=><Nodekey={node.id}node={node}/>)}</MillerColumnsColumn></MillerColumns>
import{MillerColumns}from"@primitiv-ui/react";import{ useState }from"react";const[path, setPath]=useState(["documents","work","report"]);// nodeById: your own Map<id, node>, built from the tree data you render.{/* the path is the value — render it as a breadcrumb */}<navaria-label="File path">{path.map((id, i)=>(<buttonkey={id}onClick={()=>setPath(path.slice(0, i +1))}>{nodeById.get(id).label}</button>))}</nav><MillerColumns.Rootaria-label="Files"value={path}onValueChange={setPath}><MillerColumns.Column><MillerColumns.ResizeHandlearia-label="Resize column"/>{tree.map((node)=><Nodekey={node.id}node={node}/>)}</MillerColumns.Column></MillerColumns.Root>