All components subtlesolidxssmmd
Tree View
Nested hierarchies with expandable branches, indent guides, and depth-aware padding.
- button.tsx
- card.tsx
- package.json
- README.md
123456789101112131415161718192021222324252627import { TreeView } from "#components/ui/base-ui/tree-view.tsx";
export function Example() { return ( // Base UI has no tree-view machine; the component carries the expanded // state (expandedValue / defaultExpandedValue / onExpandedChange) and // tracks depth for the recipe's --depth indentation itself <TreeView.Root defaultExpandedValue={["src"]} size="md"> <TreeView.Tree aria-label="Files"> <TreeView.Branch value="src"> <TreeView.BranchControl> <TreeView.BranchIndicator> <TreeView.Icon viewBox="0 0 16 16">{/* chevron */}</TreeView.Icon> </TreeView.BranchIndicator> <TreeView.BranchText>src</TreeView.BranchText> </TreeView.BranchControl> <TreeView.BranchContent> <TreeView.BranchIndentGuide /> <TreeView.Item> <TreeView.ItemText>index.ts</TreeView.ItemText> </TreeView.Item> </TreeView.BranchContent> </TreeView.Branch> </TreeView.Tree> </TreeView.Root> );}Variants
- button.tsx
- card.tsx
- package.json
- README.md
- button.tsx
- card.tsx
- package.json
- README.md
Sizes
- button.tsx
- card.tsx
- package.json
- README.md
- button.tsx
- card.tsx
- package.json
- README.md
- button.tsx
- card.tsx
- package.json
- README.md
Install
1npx stylexblocks add tree-viewSource
components/ui/base-ui/tree-view.tsx
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587import * as stylex from "@stylexjs/stylex";import { createContext, useContext, useMemo, useState } from "react";import type { CSSProperties } from "react";import { mergeProps } from "@base-ui-components/react/merge-props";import { useRender } from "@base-ui-components/react/use-render";import { createSlotRecipeContext } from "#lib/base-ui/create-slot-recipe-context.tsx";import { defineSlotRecipe, resolveSlotStyles, variantDataAttrs, type WithStyleOverrides,} from "#lib/recipe-props.ts";import { treeViewTreeIconVars } from '@stylexblocks/ui/slot-recipes/tree-view.stylex.ts'
import { colorPalette } from '@stylexblocks/ui/color-palette.stylex.ts'import { durations } from '@stylexblocks/ui/tokens/durations.stylex.ts'import { fontSizes } from '@stylexblocks/ui/tokens/font-sizes.stylex.ts'import { fontWeights } from '@stylexblocks/ui/tokens/font-weights.stylex.ts'import { lineHeights } from '@stylexblocks/ui/tokens/line-heights.stylex.ts'import { semanticColors } from '@stylexblocks/ui/semantic-tokens/colors.stylex.ts'import { semanticRadii } from '@stylexblocks/ui/semantic-tokens/radii.stylex.ts'import { sizes } from '@stylexblocks/ui/tokens/sizes.stylex.ts'import { spacing } from '@stylexblocks/ui/tokens/spacing.stylex.ts'
const treeViewRoot = stylex.create({ base: { width: sizes.full, display: 'flex', flexDirection: 'column', gap: spacing._2, },})
const treeViewTree = stylex.create({ base: { display: 'flex', flexDirection: 'column', '--tree-item-gap': spacing._2, '--tree-indentation': 'calc(var(--tree-indent-size) + var(--tree-icon-size) * 0.5)', [treeViewTreeIconVars.width]: 'var(--tree-icon-size)', [treeViewTreeIconVars.height]: 'var(--tree-icon-size)', },})
const treeViewTreeSizes = stylex.create({ md: { fontSize: fontSizes.sm, lineHeight: lineHeights.sm, '--tree-indent-size': spacing._4, '--tree-icon-size': spacing._4, '--tree-padding-inline': spacing._3, '--tree-padding-block': spacing._1_5, }, sm: { fontSize: fontSizes.sm, lineHeight: lineHeights.sm, '--tree-indent-size': spacing._4, '--tree-icon-size': spacing._3, '--tree-padding-inline': spacing._3, '--tree-padding-block': spacing._1, }, xs: { fontSize: fontSizes.xs, lineHeight: lineHeights.xs, '--tree-indent-size': spacing._4, '--tree-icon-size': spacing._3, '--tree-padding-inline': spacing._2, '--tree-padding-block': spacing._1, },})
const treeViewTreeIcon = stylex.create({ base: { width: treeViewTreeIconVars.width, height: treeViewTreeIconVars.height, },})
const treeViewLabel = stylex.create({ base: { fontWeight: fontWeights.medium, fontSize: fontSizes.sm, lineHeight: lineHeights.sm, },})
const treeViewBranch = stylex.create({ base: { position: 'relative', },})
const treeViewBranchContent = stylex.create({ base: { position: 'relative', },})
const treeViewBranchContentAnimateContents = stylex.create({ true: { overflow: 'hidden', animationName: { default: null, ':is([data-open], [data-state="open"])': 'expand-height, fade-in', ':is([data-closed], [data-state="closed"])': 'collapse-height, fade-out', }, animationDuration: { default: null, ':is([data-open], [data-state="open"])': durations.moderate, ':is([data-closed], [data-state="closed"])': durations.moderate, }, },})
const treeViewBranchIndentGuide = stylex.create({ base: { height: '100%', width: '1px', backgroundColor: semanticColors.border, position: 'absolute', '--tree-depth': 'calc(var(--depth) - 1)', '--tree-indentation-offset': 'calc(var(--tree-indentation) * var(--tree-depth))', '--tree-offset': 'calc(var(--tree-padding-inline) + var(--tree-indentation-offset))', insetInlineStart: 'calc(var(--tree-offset) + var(--tree-icon-size) * 0.5)', zIndex: 1, },})
const treeViewBranchIndicator = stylex.create({ base: { color: semanticColors.fgMuted, transformOrigin: 'center', transitionDuration: durations.moderate, transitionProperty: 'transform', transitionTimingFunction: 'ease-in-out', },})
const treeViewBranchTrigger = stylex.create({ base: { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', },})
const treeViewBranchControl = stylex.create({ base: { display: 'flex', alignItems: 'center', gap: 'var(--tree-item-gap)', borderRadius: semanticRadii.controlMd, userSelect: 'none', position: 'relative', '--tree-depth': 'calc(var(--depth) - 1)', '--tree-indentation-offset': 'calc(var(--tree-indentation) * var(--tree-depth))', '--tree-offset': 'calc(var(--tree-padding-inline) + var(--tree-indentation-offset))', paddingInlineStart: 'var(--tree-offset)', paddingInlineEnd: 'var(--tree-padding-inline)', paddingBlock: 'var(--tree-padding-block)', outlineWidth: { default: null, ':focus-visible': 'var(--focus-ring-width, 0)', }, outlineOffset: { default: null, ':focus-visible': '0px', }, outlineStyle: { default: null, ':focus-visible': 'solid', }, outlineColor: { default: null, ':focus-visible': colorPalette.focusRing, }, '--focus-ring-color': semanticColors.borderEmphasized, '--focus-ring-width': '2px', opacity: { default: null, ':is(:disabled, [disabled], [data-disabled])': 0.5, }, cursor: { default: null, ':is(:disabled, [disabled], [data-disabled])': 'not-allowed', }, },})
const treeViewBranchControlVariants = stylex.create({ subtle: {}, solid: {},})
const treeViewItem = stylex.create({ base: { display: 'flex', alignItems: 'center', gap: 'var(--tree-item-gap)', borderRadius: semanticRadii.controlMd, userSelect: 'none', position: 'relative', '--tree-depth': 'calc(var(--depth) - 1)', '--tree-indentation-offset': 'calc(var(--tree-indentation) * var(--tree-depth))', '--tree-offset': 'calc(var(--tree-padding-inline) + var(--tree-indentation-offset))', paddingInlineStart: 'var(--tree-offset)', paddingInlineEnd: 'var(--tree-padding-inline)', paddingBlock: 'var(--tree-padding-block)', outlineWidth: { default: null, ':focus-visible': 'var(--focus-ring-width, 0)', }, outlineOffset: { default: null, ':focus-visible': '0px', }, outlineStyle: { default: null, ':focus-visible': 'solid', }, outlineColor: { default: null, ':focus-visible': colorPalette.focusRing, }, '--focus-ring-color': semanticColors.borderEmphasized, '--focus-ring-width': '2px', opacity: { default: null, ':is(:disabled, [disabled], [data-disabled])': 0.5, }, cursor: { default: null, ':is(:disabled, [disabled], [data-disabled])': 'not-allowed', }, },})
const treeViewItemVariants = stylex.create({ subtle: {}, solid: {},})
const treeViewItemText = stylex.create({ base: { flex: 1, },})
const treeViewBranchText = stylex.create({ base: { flex: 1, },})
const treeViewNodeCheckbox = stylex.create({ base: { display: 'inline-flex', },})
export type TreeViewSize = keyof typeof treeViewTreeSizes
export type TreeViewVariant = keyof typeof treeViewBranchControlVariants
export type TreeViewAnimateContent = keyof typeof treeViewBranchContentAnimateContents
// What the generated recipe doesn't carry: list/button browser resets,// interactive row affordances, and the expanded-indicator rotation. The// recipes indent rows via the `--depth` custom property, which the Ark// machine normally sets; here the component tracks depth through context and// sets it inline per level.const treeStatic = stylex.create({ list: { listStyle: "none", margin: 0, padding: 0 }, row: { cursor: "pointer", color: semanticColors.fg, backgroundColor: { default: "transparent", ":hover": semanticColors.bgMuted }, }, button: { width: "100%", borderWidth: 0, textAlign: "start" }, indicatorExpanded: { transform: { default: null, ':is([data-state="open"])': "rotate(90deg)", }, },});
const treeViewRecipe = defineSlotRecipe({ slots: { root: { styles: treeViewRoot }, tree: { styles: treeViewTree, sizes: treeViewTreeSizes }, label: { styles: treeViewLabel }, branch: { styles: treeViewBranch }, branchContent: { styles: treeViewBranchContent }, branchIndentGuide: { styles: treeViewBranchIndentGuide }, branchIndicator: { styles: treeViewBranchIndicator }, branchTrigger: { styles: treeViewBranchTrigger }, branchControl: { styles: treeViewBranchControl, variants: treeViewBranchControlVariants, }, branchText: { styles: treeViewBranchText }, item: { styles: treeViewItem, variants: treeViewItemVariants }, itemText: { styles: treeViewItemText }, nodeCheckbox: { styles: treeViewNodeCheckbox }, }, defaultVariants: { size: "md", variant: "subtle" },});
const { withProvider, withContext, VariantContext } = createSlotRecipeContext(treeViewRecipe);
interface TreeState { expanded: string[]; toggle: (value: string) => void;}
const TreeContext = createContext<TreeState>({ expanded: [], toggle: () => {},});
const BranchContext = createContext<string>("");
const DepthContext = createContext(1);
function depthStyle(depth: number): CSSProperties { return { "--depth": depth } as CSSProperties;}
/** * TreeView — the generated recipe composed through Base UI's `useRender` / * `mergeProps` primitives with controlled expanded state. `<TreeView.Root * variant size>` resolves the recipe once and every part inherits it through * context; `expandedValue` / `defaultExpandedValue` / `onExpandedChange` * control which branches (keyed by `<TreeView.Branch value>`) are open. * Composition per part via `render`. */export type TreeViewRootProps = WithStyleOverrides<useRender.ComponentProps<"div">> & { variant?: "subtle" | "solid"; size?: "md" | "sm" | "xs"; /** Controlled expanded branch values. */ expandedValue?: string[]; /** Initial expanded branch values when uncontrolled. */ defaultExpandedValue?: string[]; onExpandedChange?: (details: { expandedValue: string[] }) => void;};
function RootNode({ render, expandedValue, defaultExpandedValue, onExpandedChange, ...rest}: Omit<TreeViewRootProps, "variant" | "size" | "palette" | "style">) { const v = useContext(VariantContext); const [innerExpanded, setInnerExpanded] = useState( defaultExpandedValue ?? [], ); const expanded = expandedValue ?? innerExpanded; const state = useMemo<TreeState>( () => ({ expanded, toggle: (value) => { const next = expanded.includes(value) ? expanded.filter((v2) => v2 !== value) : [...expanded, value]; setInnerExpanded(next); onExpandedChange?.({ expandedValue: next }); }, }), // eslint-disable-next-line react-hooks/exhaustive-deps [expanded], ); const element = useRender({ defaultTagName: "div", render: render as useRender.RenderProp, state: v, props: mergeProps({ ...variantDataAttrs(v) }, rest), }); return <TreeContext.Provider value={state}>{element}</TreeContext.Provider>;}
function TreeNode(allProps: useRender.ComponentProps<"ul">) { const { render, ...rest } = allProps; return useRender({ defaultTagName: "ul", render: render as useRender.RenderProp, state: {}, props: mergeProps({ role: "tree" }, rest), });}
export type TreeViewBranchProps = WithStyleOverrides<useRender.ComponentProps<"li">> & { /** Unique value identifying this branch in the expanded set. */ value: string;};
function BranchNode({ render, value, ...rest}: Omit<TreeViewBranchProps, "palette" | "style">) { const tree = useContext(TreeContext); const element = useRender({ defaultTagName: "li", render: render as useRender.RenderProp, state: {}, props: mergeProps( { role: "treeitem", "aria-expanded": tree.expanded.includes(value) }, rest, ), }); return ( <BranchContext.Provider value={value}>{element}</BranchContext.Provider> );}
function BranchControlNode(allProps: useRender.ComponentProps<"button">) { const { render, style, ...rest } = allProps; const tree = useContext(TreeContext); const value = useContext(BranchContext); const depth = useContext(DepthContext); return useRender({ defaultTagName: "button", render: render as useRender.RenderProp, state: {}, props: mergeProps( { type: "button" as const, onClick: () => tree.toggle(value), style: { ...(style as CSSProperties), ...depthStyle(depth) }, }, rest, ), });}
function BranchContentNode(allProps: useRender.ComponentProps<"ul">) { const { render, ...rest } = allProps; const tree = useContext(TreeContext); const value = useContext(BranchContext); const depth = useContext(DepthContext); const element = useRender({ defaultTagName: "ul", render: render as useRender.RenderProp, state: {}, props: mergeProps({ role: "group" }, rest), }); if (!tree.expanded.includes(value)) return null; return ( <DepthContext.Provider value={depth + 1}>{element}</DepthContext.Provider> );}
function BranchIndentGuideNode(allProps: useRender.ComponentProps<"span">) { const { render, style, ...rest } = allProps; const depth = useContext(DepthContext); return useRender({ defaultTagName: "span", render: render as useRender.RenderProp, state: {}, props: mergeProps( { "aria-hidden": true, style: { ...(style as CSSProperties), ...depthStyle(depth - 1) }, }, rest, ), });}
function BranchIndicatorNode(allProps: useRender.ComponentProps<"span">) { const { render, ...rest } = allProps; const tree = useContext(TreeContext); const value = useContext(BranchContext); return useRender({ defaultTagName: "span", render: render as useRender.RenderProp, state: {}, props: mergeProps( { "aria-hidden": true, "data-state": tree.expanded.includes(value) ? "open" : "closed", }, rest, ), });}
function ItemNode(allProps: useRender.ComponentProps<"li">) { const { render, style, ...rest } = allProps; const depth = useContext(DepthContext); return useRender({ defaultTagName: "li", render: render as useRender.RenderProp, state: {}, props: mergeProps( { role: "treeitem", style: { ...(style as CSSProperties), ...depthStyle(depth) }, }, rest, ), });}
function IconNode(allProps: useRender.ComponentProps<"svg">) { const { render, ...rest } = allProps; return useRender({ defaultTagName: "svg", render: render as useRender.RenderProp, state: {}, props: mergeProps({ "aria-hidden": true }, rest), });}
/** * TreeView — the generated recipe composed through Base UI's `useRender` / * `mergeProps` primitives with controlled expanded state. `<TreeView.Root * variant size>` resolves the recipe once and every part inherits it through * context; `expandedValue` / `defaultExpandedValue` / `onExpandedChange` * control which branches (keyed by `<TreeView.Branch value>`) are open. * Composition per part via `render`. */export const TreeView = { Root: withProvider(RootNode, "root", "TreeViewRoot"), Tree: withContext( TreeNode, (v) => [ ...resolveSlotStyles(treeViewRecipe.slots.tree, v), treeStatic.list, ], "TreeViewTree", ), Label: withContext("div", "label", "TreeViewLabel"), Branch: withContext( BranchNode, () => [treeViewBranch.base, treeStatic.list], "TreeViewBranch", ), BranchControl: withContext( BranchControlNode, (v) => [ ...resolveSlotStyles(treeViewRecipe.slots.branchControl, v), treeStatic.row, treeStatic.button, ], "TreeViewBranchControl", ), BranchContent: withContext( BranchContentNode, () => [treeViewBranchContent.base, treeStatic.list], "TreeViewBranchContent", ), BranchIndentGuide: withContext( BranchIndentGuideNode, "branchIndentGuide", "TreeViewBranchIndentGuide", ), BranchIndicator: withContext( BranchIndicatorNode, () => [treeViewBranchIndicator.base, treeStatic.indicatorExpanded], "TreeViewBranchIndicator", ), BranchText: withContext("span", "branchText", "TreeViewBranchText"), Item: withContext( ItemNode, (v) => [ ...resolveSlotStyles(treeViewRecipe.slots.item, v), treeStatic.row, ], "TreeViewItem", ), ItemText: withContext("span", "itemText", "TreeViewItemText"), Icon: withContext(IconNode, () => [treeViewTreeIcon.base], "TreeViewIcon"),};