diff --git a/.changeset/curly-birds-argue.md b/.changeset/curly-birds-argue.md
new file mode 100644
index 00000000000..24a6b9ea1cd
--- /dev/null
+++ b/.changeset/curly-birds-argue.md
@@ -0,0 +1,5 @@
+---
+"@primer/react": patch
+---
+
+Add draft TreeView component
diff --git a/docs/content/TreeView.mdx b/docs/content/TreeView.mdx
new file mode 100644
index 00000000000..5d463a0a195
--- /dev/null
+++ b/docs/content/TreeView.mdx
@@ -0,0 +1,173 @@
+---
+title: TreeView
+componentId: tree_view
+status: Draft
+description: A hierarchical list of items where nested items can be expanded and collapsed.
+---
+
+## Examples
+
+### File tree navigation without directory links
+
+```jsx live drafts
+
+```
+
+### File tree navigation with directory links
+
+```jsx live drafts
+
+```
+
+## Props
+
+### TreeView
+
+
+
+ {/* */}
+
+
+### TreeView.Item
+
+
+
+
+
+ {/* */}
+
+
+### TreeView.LinkItem
+
+
+
+
+ The URL that the item navigates to. href is passed to the underlying{' '}
+ <a> element. If as is specified, the component may need
+ different props. If the item contains a sub-nav, the item is rendered as a{' '}
+ <button> and href is ignored.
+ >
+ }
+ />
+
+
+ {/* */}
+
+
+### TreeView.SubTree
+
+
+
+ {/* */}
+
+
+
+
+
+
+
+
+## Status
+
+
diff --git a/src/TreeView/TreeView.stories.tsx b/src/TreeView/TreeView.stories.tsx
new file mode 100644
index 00000000000..711655ab888
--- /dev/null
+++ b/src/TreeView/TreeView.stories.tsx
@@ -0,0 +1,81 @@
+import {Meta, Story} from '@storybook/react'
+import {TreeView} from './TreeView'
+import React from 'react'
+import Box from '../Box'
+
+const meta: Meta = {
+ title: 'Composite components/TreeView',
+ component: TreeView,
+ parameters: {
+ layout: 'fullscreen'
+ }
+}
+
+export const FileTreeWithDirectoryLinks: Story = () => (
+
+
+
+)
+
+export const FileTreeWithoutDirectoryLinks: Story = () => (
+
+
+
+)
+
+export default meta
diff --git a/src/TreeView/TreeView.test.tsx b/src/TreeView/TreeView.test.tsx
new file mode 100644
index 00000000000..5fa55c0f674
--- /dev/null
+++ b/src/TreeView/TreeView.test.tsx
@@ -0,0 +1,50 @@
+import {render} from '@testing-library/react'
+import React from 'react'
+import {TreeView} from './TreeView'
+
+it('uses tree role', () => {
+ const {queryByRole} = render(
+
+ Item 1
+ Item 2
+ Item 3
+
+ )
+
+ const root = queryByRole('tree')
+
+ expect(root).toHaveAccessibleName('Test tree')
+})
+
+it('uses treeitem role', () => {
+ const {queryAllByRole} = render(
+
+ Item 1
+ Item 2
+ Item 3
+
+ )
+
+ const items = queryAllByRole('treeitem')
+
+ expect(items).toHaveLength(3)
+})
+
+it('hides subtrees by default', () => {
+ const {queryByRole} = render(
+
+
+ Parent
+
+ Child
+
+
+
+ )
+
+ const parentItem = queryByRole('treeitem', {name: 'Parent'})
+ const subtree = queryByRole('group')
+
+ expect(parentItem).toHaveAttribute('aria-expanded', 'false')
+ expect(subtree).toBeNull()
+})
diff --git a/src/TreeView/TreeView.tsx b/src/TreeView/TreeView.tsx
new file mode 100644
index 00000000000..28e90096b2d
--- /dev/null
+++ b/src/TreeView/TreeView.tsx
@@ -0,0 +1,217 @@
+import {ChevronDownIcon, ChevronRightIcon} from '@primer/octicons-react'
+import React from 'react'
+import Box from '../Box'
+
+// ----------------------------------------------------------------------------
+// Context
+
+const ItemContext = React.createContext<{
+ level: number
+ isExpanded: boolean
+}>({
+ level: 1,
+ isExpanded: false
+})
+
+// ----------------------------------------------------------------------------
+// TreeView
+
+export type TreeViewProps = {
+ 'aria-label'?: React.AriaAttributes['aria-label']
+ 'aria-labelledby'?: React.AriaAttributes['aria-labelledby']
+ children: React.ReactNode
+}
+
+const Root: React.FC = ({'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, children}) => {
+ return (
+
+ {children}
+
+ )
+}
+
+// ----------------------------------------------------------------------------
+// TreeView.Item
+
+export type TreeViewItemProps = {
+ children: React.ReactNode
+ onSelect?: (event: React.MouseEvent | React.KeyboardEvent) => void
+ onToggle?: (isExpanded: boolean) => void
+}
+
+const Item: React.FC = ({onSelect, onToggle, children}) => {
+ const {level} = React.useContext(ItemContext)
+ const [isExpanded, setIsExpanded] = React.useState(false)
+ const {hasSubTree, subTree, childrenWithoutSubTree} = useSubTree(children)
+
+ // Expand or collapse the subtree
+ function toggle() {
+ onToggle?.(!isExpanded)
+ setIsExpanded(!isExpanded)
+ }
+
+ return (
+
+ {
+ if (event.key === ' ' || event.key === 'Enter') {
+ onSelect?.(event)
+ }
+ }}
+ >
+ {
+ if (onSelect) {
+ onSelect(event)
+ } else {
+ toggle()
+ }
+ }}
+ sx={{
+ display: 'grid',
+ gridTemplateColumns: `calc(${level - 1} * 8px) 16px 1fr`,
+ gridTemplateAreas: `"spacer toggle content"`,
+ width: '100%',
+ height: 32,
+ fontSize: 1,
+ color: 'fg.default',
+ borderRadius: 2,
+ cursor: 'pointer',
+ transition: 'background 33.333ms linear',
+ '&:hover': {
+ backgroundColor: 'actionListItem.default.hoverBg'
+ }
+ }}
+ >
+ {hasSubTree ? (
+ {
+ if (onSelect) {
+ toggle()
+ event.stopPropagation()
+ }
+ }}
+ sx={{
+ gridArea: 'toggle',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ height: '100%',
+ color: 'fg.muted',
+ borderTopLeftRadius: level === 1 ? 2 : 0,
+ borderBottomLeftRadius: level === 1 ? 2 : 0,
+ '&:hover': {
+ backgroundColor: onSelect ? 'actionListItem.default.hoverBg' : null
+ }
+ }}
+ >
+ {isExpanded ? : }
+
+ ) : null}
+
+ {childrenWithoutSubTree}
+
+
+ {subTree}
+
+
+ )
+}
+
+// ----------------------------------------------------------------------------
+// TreeView.LinkItem
+
+export type TreeViewLinkItemProps = TreeViewItemProps & {
+ href?: string
+}
+
+// TODO: Use an element to enable native browser behavior like opening links in a new tab
+const LinkItem: React.FC = ({href, onSelect, ...props}) => {
+ return (
+ - {
+ // Navigate by clicking or pressing enter
+ if (event.type === 'click' || ('key' in event && event.key === 'Enter')) {
+ window.open(href)
+ }
+
+ onSelect?.(event)
+ }}
+ {...props}
+ />
+ )
+}
+
+// ----------------------------------------------------------------------------
+// TreeView.SubTree
+
+export type TreeViewSubTreeProps = {
+ children?: React.ReactNode
+}
+
+const SubTree: React.FC = ({children}) => {
+ const {isExpanded} = React.useContext(ItemContext)
+ return (
+
+ {children}
+
+ )
+}
+
+function useSubTree(children: React.ReactNode) {
+ return React.useMemo(() => {
+ const subTree = React.Children.toArray(children).find(
+ child => React.isValidElement(child) && child.type === SubTree
+ )
+
+ const childrenWithoutSubTree = React.Children.toArray(children).filter(
+ child => !(React.isValidElement(child) && child.type === SubTree)
+ )
+
+ return {
+ subTree,
+ childrenWithoutSubTree,
+ hasSubTree: Boolean(subTree)
+ }
+ }, [children])
+}
+
+// ----------------------------------------------------------------------------
+// Export
+
+export const TreeView = Object.assign(Root, {
+ Item,
+ LinkItem,
+ SubTree
+})
diff --git a/src/TreeView/index.ts b/src/TreeView/index.ts
new file mode 100644
index 00000000000..e1e4aea33b1
--- /dev/null
+++ b/src/TreeView/index.ts
@@ -0,0 +1 @@
+export * from './TreeView'
diff --git a/src/drafts/index.ts b/src/drafts/index.ts
index c375241e03a..23bd8a7d9ba 100644
--- a/src/drafts/index.ts
+++ b/src/drafts/index.ts
@@ -4,10 +4,7 @@
* But, they are published on npm and you can import them for experimentation/feedback.
* example: import {ActionList} from '@primer/react/drafts
*/
-export * from '../NavList'
export * from '../Dialog/Dialog'
-export * from '../SegmentedControl' // TODO: remove from drafts bundle in next major release
-export * from '../SplitPageLayout'
export {default as InlineAutocomplete} from './InlineAutocomplete'
export type {
@@ -27,3 +24,10 @@ export * from './MarkdownEditor'
export * from '../UnderlineNav2'
export * from './hooks'
+
+export * from '../TreeView'
+
+// TODO: Remove these components from the drafts bundle in the next major release
+export * from '../NavList'
+export * from '../SegmentedControl'
+export * from '../SplitPageLayout'