macbetter

Content

Outline

Browse a hierarchy in rows that expand, like Finder's list view.

Projects12 items
macbetter4 items
Design Spec.pdf2.4 MB
App Icons.png860 KB
Launch Plan.key18 MB
Budget 2027.numbers212 KB
Meeting Notes.md4 KB
Contract — Signed.pdf640 KB
"use client"

import {
  Outline,
  OutlineGroup,
  OutlineItem,
  OutlineItemDetail,
  OutlineItemIcon,
  OutlineItemLabel,
  OutlineItemText,
} from "macbetter/outline"
import { FileTextIcon, FolderIcon, ImageIcon, PresentationIcon, SheetIcon } from "lucide-react"

type Node = { value: string; name: string; detail: string; kind: "folder" | "doc" | "image" | "sheet" | "deck"; children?: Node[] }

const files: Node[] = [
  {
    value: "projects",
    name: "Projects",
    detail: "12 items",
    kind: "folder",
    children: [
      {
        value: "projects/macbetter",
        name: "macbetter",
        detail: "4 items",
        kind: "folder",
        children: [
          { value: "projects/macbetter/spec", name: "Design Spec.pdf", detail: "2.4 MB", kind: "doc" },
          { value: "projects/macbetter/icons", name: "App Icons.png", detail: "860 KB", kind: "image" },
          { value: "projects/macbetter/launch", name: "Launch Plan.key", detail: "18 MB", kind: "deck" },
        ],
      },
      { value: "projects/budget", name: "Budget 2027.numbers", detail: "212 KB", kind: "sheet" },
    ],
  },
  {
    value: "photos",
    name: "Photos",
    detail: "3 items",
    kind: "folder",
    children: [
      { value: "photos/offsite", name: "Team Offsite.heic", detail: "3.2 MB", kind: "image" },
      { value: "photos/headshot", name: "Headshot.jpg", detail: "1.1 MB", kind: "image" },
    ],
  },
  { value: "notes", name: "Meeting Notes.md", detail: "4 KB", kind: "doc" },
  { value: "contract", name: "Contract — Signed.pdf", detail: "640 KB", kind: "doc" },
]

const icons = { folder: FolderIcon, doc: FileTextIcon, image: ImageIcon, sheet: SheetIcon, deck: PresentationIcon }

function Items({ items }: { items: Node[] }) {
  return items.map((item) => {
    const Icon = icons[item.kind]
    return (
      <OutlineItem key={item.value} value={item.value} label={item.name}>
        <OutlineItemLabel>
          <OutlineItemIcon>
            <Icon aria-hidden="true" />
          </OutlineItemIcon>
          <OutlineItemText>{item.name}</OutlineItemText>
          <OutlineItemDetail>{item.detail}</OutlineItemDetail>
        </OutlineItemLabel>
        {item.children ? (
          <OutlineGroup>
            <Items items={item.children} />
          </OutlineGroup>
        ) : null}
      </OutlineItem>
    )
  })
}

export default function Example() {
  return (
    <Outline
      aria-label="Documents"
      defaultExpanded={["projects", "projects/macbetter"]}
      defaultValue="projects/macbetter/spec"
      style={{ width: "100%", maxWidth: 520 }}
    >
      <Items items={files} />
    </Outline>
  )
}

When to use it

Use an outline when items nest: folders and files, groups and their members, sections of a document. Each branch has a disclosure triangle that turns to show its children, and the rows stay in one column so people can scan them.

  • Keep a leading icon for each row so folders and files are told apart at a glance.
  • Put a short detail, like a size or an item count, at the trailing edge.
  • Use a Table when people compare several attributes of flat items, and a Disclosure to hide a few sections of content.

Usage

import {
  Outline,
  OutlineGroup,
  OutlineItem,
  OutlineItemDetail,
  OutlineItemIcon,
  OutlineItemLabel,
  OutlineItemText,
} from "macbetter/outline"

An OutlineItem with an OutlineGroup inside it is a branch. OutlineItemLabel is the row, and draws the disclosure triangle for you; leaves keep its space so their icons line up under their parent's.

Indentation guides

Guides are off by default, as in Finder. Turn them on with guides to draw a hairline down each open branch, which helps in a deep tree.

src
components
list.tsx
table.tsx
index.ts
package.json
"use client"

import {
  Outline,
  OutlineGroup,
  OutlineItem,
  OutlineItemIcon,
  OutlineItemLabel,
  OutlineItemText,
} from "macbetter/outline"
import { FileIcon, FolderIcon } from "lucide-react"

export default function Example() {
  return (
    <Outline aria-label="Source" guides mode="multiple" defaultExpanded={["src", "src/components"]} style={{ width: "100%", maxWidth: 360 }}>
      <OutlineItem value="src">
        <OutlineItemLabel>
          <OutlineItemIcon>
            <FolderIcon aria-hidden="true" />
          </OutlineItemIcon>
          <OutlineItemText>src</OutlineItemText>
        </OutlineItemLabel>
        <OutlineGroup>
          <OutlineItem value="src/components">
            <OutlineItemLabel>
              <OutlineItemIcon>
                <FolderIcon aria-hidden="true" />
              </OutlineItemIcon>
              <OutlineItemText>components</OutlineItemText>
            </OutlineItemLabel>
            <OutlineGroup>
              <OutlineItem value="src/components/list.tsx">
                <OutlineItemLabel>
                  <OutlineItemIcon>
                    <FileIcon aria-hidden="true" />
                  </OutlineItemIcon>
                  <OutlineItemText>list.tsx</OutlineItemText>
                </OutlineItemLabel>
              </OutlineItem>
              <OutlineItem value="src/components/table.tsx">
                <OutlineItemLabel>
                  <OutlineItemIcon>
                    <FileIcon aria-hidden="true" />
                  </OutlineItemIcon>
                  <OutlineItemText>table.tsx</OutlineItemText>
                </OutlineItemLabel>
              </OutlineItem>
            </OutlineGroup>
          </OutlineItem>
          <OutlineItem value="src/index.ts">
            <OutlineItemLabel>
              <OutlineItemIcon>
                <FileIcon aria-hidden="true" />
              </OutlineItemIcon>
              <OutlineItemText>index.ts</OutlineItemText>
            </OutlineItemLabel>
          </OutlineItem>
        </OutlineGroup>
      </OutlineItem>
      <OutlineItem value="package.json">
        <OutlineItemLabel>
          <OutlineItemIcon>
            <FileIcon aria-hidden="true" />
          </OutlineItemIcon>
          <OutlineItemText>package.json</OutlineItemText>
        </OutlineItemLabel>
      </OutlineItem>
    </Outline>
  )
}

Selection and actions

Selection is single by default, and it follows the arrow keys as in Finder; pass activation="manual" to move focus without selecting until Enter or Space. mode="multiple" allows Command-click and Shift-click ranges. onAction runs when a row is pressed or Enter is pressed on it, like opening a file. Set expandOnPress={false} so a click selects a folder without opening it, leaving that to the triangle and the arrows.

Platform behavior

  • macOS. 24-point rows with 13-point text, a 16-point indent per level and a small chevron for the triangle. The selection is the accent while the outline has focus and turns gray when focus leaves, as in Finder. Adjacent selected rows join into one block.
  • iOS and iPadOS. 44-point rows with 17-point text, a 20-point indent, and a pressed state on rows. The disclosure chevron sits at the trailing edge in the accent color and turns down when open, as UIKit's outline disclosure does. Consider a List that navigates into each folder on iPhone, where nesting is hard to read.
  • In right-to-left layouts the rows, indent and triangle mirror: a closed triangle points left and turns down when open.
  • The triangle turns with the system's snappy spring, and a branch's rows slide open as its height animates. Reduced motion skips the slide.

Accessibility

The outline is a WAI-ARIA tree, from ui-sdk. It has one tab stop.

KeyAction
Down / Up ArrowMove to the next or previous visible row and select it.
Right ArrowExpand a branch, or move into its first child.
Left ArrowCollapse a branch, or move to its parent.
Option+Right / Option+LeftExpand or collapse a branch and everything in it.
Home / EndMove to the first or last visible row.
EnterRun onAction on the row.
TypingMove to the next row whose label starts with the text.

Give the outline a name with aria-label. Icons are aria-hidden, so a row is named by its text and detail. Pass label to an item when its text isn't plain, so typing finds it.

API

Outline

Takes every prop of ui-sdk's Tree, including mode, value, defaultValue, expanded, defaultExpanded, onAction and expandOnPress, plus:

PropTypeDefaultDescription
guidesbooleanfalseDraws a hairline down each open branch.
activation"automatic" | "manual""automatic"Whether the arrow keys select as they move, in single selection.

OutlineItem

PropTypeDefaultDescription
valuestring—Identifies the item. Unique in the outline.
labelstring—The text typing matches, when the row's text isn't plain.
branchboolean—Makes it a branch whose children load later. Set for you when it holds an OutlineGroup.
disabledbooleanfalseStops it from being selected or expanded.

OutlineItemLabel, OutlineGroup, OutlineItemIcon, OutlineItemText and OutlineItemDetail take the props of the element they render.

Data attributes and CSS variables

Attribute or variableOnMeaning
data-guides.mb-outlineIndentation guides are on.
data-selected, data-expanded, data-branch, data-disabled.mb-outline-rowThe row's state, from ui-sdk.
--mb-outline-row-height, --mb-outline-indentplatformRow height and the indent per level.
--mb-outline-icon-color.mb-outlineThe icons' color. Defaults to the accent.
--mb-outline-indicator-order, --mb-outline-indicator-colorplatform0 leads the row with the triangle (macOS), 1 trails it (iOS); and its color.