Skip to content
Primitiv home
Framework
Consumption mode

Miller Columns

stableSource

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

Density

Preview

Size
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.function FilePreview() {  const { selectedValue } = useMillerColumnsSelection();  const node = selectedValue ? nodeById.get(selectedValue) : undefined;  if (!node) return null;  return (    <div>      {node.image && <img src={node.image} alt="" />}      <strong>{node.label}</strong>      <span>{node.meta}</span>    </div>  );}
function Node({ node }) {  return (    <MillerColumnsItem value={node.id}>      {node.label}      {node.children ? (        <>          <MillerColumnsItemIndicator />          <MillerColumnsColumn>            <MillerColumnsResizeHandle aria-label={`Resize ${node.label}`} />            {node.children.map((child) => <Node key={child.id} node={child} />)}          </MillerColumnsColumn>        </>      ) : null}    </MillerColumnsItem>  );}
<MillerColumns aria-label="Files" size="md" defaultValue={["cover"]}>  <MillerColumnsColumn>    <MillerColumnsResizeHandle aria-label="Resize column" minWidth={140} maxWidth={320} />    {tree.map((node) => <Node key={node.id} node={node} />)}  </MillerColumnsColumn>  <MillerColumnsPreviewPanel><FilePreview /></MillerColumnsPreviewPanel></MillerColumns>

Density is set by a data-density ancestor — the Context system, not a MillerColumns prop.

Installation

npx primitiv add miller-columns

Import

import { MillerColumns } from "@/components/ui/miller-columns";

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

<MillerColumns>  <MillerColumnsColumn>    <MillerColumnsResizeHandle />    <MillerColumnsItem>          {/* a leaf */}    <MillerColumnsItem>          {/* a branch: */}      <MillerColumnsItemIndicator />      <MillerColumnsColumn>...</MillerColumnsColumn>   {/* projected beside its parent */}    </MillerColumnsItem>  </MillerColumnsColumn>  <MillerColumnsPreviewPanel />   {/* optional trailing pane */}</MillerColumns>

Props

MillerColumns.Root

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
children (required)ReactNode—headlessThe 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.
defaultValuestring[]—headlessInitial 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"headlessReading 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—headlessForbidden in uncontrolled mode — pass value + onValueChange to control the component. Called with the new selection path whenever the selection changes. Required in controlled mode.
valuestring[]—headlessForbidden 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"mdstyledRow 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.

PropTypeDefaultFromDescription
childrenReactNode—headlessThe 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.

PropTypeDefaultFromDescription
children (required)ReactNode—headlessThe cell content and, for a branch item, a single nested <MillerColumns.Column> (split out by partitionItemChildren).
value (required)string—headlessStable identifier used to match this item against the selection path. The item is selected when the path entry at its depth equals value.
asChildbooleanfalseheadlessRender 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.
disabledbooleanfalseheadlessWhen true, the item renders aria-disabled / data-disabled and is skipped by roving navigation — it cannot be selected, focused, or activated.
refRef<T>—headlessForwarded 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.

PropTypeDefaultFromDescription
childrenReactNode—headlessThe glyph to render (e.g. ▸). Optional.

MillerColumns.ResizeHandle

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
maxWidthnumberInfinityheadlessLargest 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.
minWidthnumber0headlessSmallest width the column can be dragged or stepped to, in pixels. Also published as aria-valuemin.
stepnumber10headlessPixels moved per arrow-key press.

MillerColumns.PreviewPanel

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
childrenReactNode—headlessThe preview content for the current selection (typically driven by useMillerColumnsSelection).
forceMountbooleanfalseheadlessKeep 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

Control frame

--primitiv-miller-columns-column-width--primitiv-miller-columns-column-min-width--primitiv-miller-columns-preview-width--primitiv-miller-columns-preview-padding--primitiv-miller-columns-resize-handle-width--primitiv-miller-columns-strip-radius--primitiv-miller-columns-height--primitiv-miller-columns-font-size--primitiv-miller-columns-line-height--primitiv-miller-columns-background--primitiv-miller-columns-border-color--primitiv-miller-columns-border-width--primitiv-miller-columns-seam-color--primitiv-miller-columns-foreground--primitiv-miller-columns-muted-foreground--primitiv-miller-columns-disabled-foreground--primitiv-miller-columns-resize-ink-width--primitiv-miller-columns-resize-ink-width-active--primitiv-miller-columns-resize-ink-color--primitiv-miller-columns-resize-ink-color-hover--primitiv-miller-columns-resize-ink-color-dragging--primitiv-miller-columns-resize-cursor--primitiv-miller-columns-transition-duration--primitiv-miller-columns-transition-easing--primitiv-miller-columns-label-ink-slack

Item

--primitiv-miller-columns-item-height--primitiv-miller-columns-item-padding-inline--primitiv-miller-columns-item-gap--primitiv-miller-columns-item-icon-size--primitiv-miller-columns-item-focus-ring-radius--primitiv-miller-columns-item-bg-hover--primitiv-miller-columns-item-bg-ancestor--primitiv-miller-columns-item-bg-terminal--primitiv-miller-columns-item-inset

Keyboard

KeyBehaviour
ArrowDown / ArrowUpMove focus within the current column.
Home / EndFocus the first / last item in the column.
ArrowRightOpen a branch and step into its child column. A no-op on a leaf.
ArrowLeftReturn focus to the parent column.
Enter / SpaceSelect the focused item.
characterTypeahead — jump to the next item in the column whose label starts with the typed characters.

Data attributes

MillerColumns

className: .primitiv-miller-columns

AttributeValueWhen
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-orientationhorizontalalways

MillerColumnsColumn

className: .primitiv-miller-columns__column

AttributeValueWhen
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

MillerColumnsItem

className: .primitiv-miller-columns__item

AttributeValueWhen
data-stateselected | unselectedon the active path / not on the active path
data-terminal""the deepest selected item
data-depth{n}always
data-has-children""the item is a branch
data-disabled""disabled

MillerColumnsItemIndicator

className: .primitiv-miller-columns__item-indicator

AttributeValueWhen
data-stateselectedthe parent item is selected
data-has-children""always

MillerColumnsResizeHandle

className: .primitiv-miller-columns__resize-handle

AttributeValueWhen
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

AttributeValueWhen
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

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.

Density
import { MillerColumns, MillerColumnsColumn, MillerColumnsItem, MillerColumnsItemIndicator, MillerColumnsResizeHandle } from "@/components/ui/miller-columns";
function Node({ node }) {  return (    <MillerColumnsItem value={node.id}>      {node.label}      {node.children ? (        <>          <MillerColumnsItemIndicator />          <MillerColumnsColumn>            <MillerColumnsResizeHandle aria-label={`Resize ${node.label}`} />            {node.children.map((child) => <Node key={child.id} node={child} />)}          </MillerColumnsColumn>        </>      ) : null}    </MillerColumnsItem>  );}
<MillerColumns aria-label="Files" defaultValue={["documents", "work"]}>  <MillerColumnsColumn>    <MillerColumnsResizeHandle aria-label="Resize column" minWidth={140} maxWidth={320} />    {tree.map((node) => <Node key={node.id} node={node} />)}  </MillerColumnsColumn></MillerColumns>

With a preview pane

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).

Density
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.function FilePreview() {  const { selectedValue } = useMillerColumnsSelection();  const node = selectedValue ? nodeById.get(selectedValue) : undefined;  if (!node) return null;  return (    <div>      {node.image && <img src={node.image} alt="" />}      <strong>{node.label}</strong>      <span>{node.meta}</span>    </div>  );}
function Node({ node }) {  return (    <MillerColumnsItem value={node.id}>      {node.label}      {node.children ? (        <>          <MillerColumnsItemIndicator />          <MillerColumnsColumn>            <MillerColumnsResizeHandle aria-label={`Resize ${node.label}`} />            {node.children.map((child) => <Node key={child.id} node={child} />)}          </MillerColumnsColumn>        </>      ) : null}    </MillerColumnsItem>  );}
<MillerColumns aria-label="Files" defaultValue={["wallpaper"]}>  <MillerColumnsColumn>    <MillerColumnsResizeHandle aria-label="Resize column" minWidth={140} maxWidth={320} />    {tree.map((node) => <Node key={node.id} node={node} />)}  </MillerColumnsColumn>  <MillerColumnsPreviewPanel><FilePreview /></MillerColumnsPreviewPanel></MillerColumns>

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.

Density
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 */}<nav aria-label="File path">  {path.map((id, i) => (    <button key={id} onClick={() => setPath(path.slice(0, i + 1))}>      {nodeById.get(id).label}    </button>  ))}</nav>
<MillerColumns aria-label="Files" value={path} onValueChange={setPath}>  <MillerColumnsColumn>    <MillerColumnsResizeHandle aria-label="Resize column" />    {tree.map((node) => <Node key={node.id} node={node} />)}  </MillerColumnsColumn></MillerColumns>