Folders and files, or any list that nests, opened and closed level by level: one row each, with a caret, an icon and the label.
- components
- button
- button.css
- card
- card.css
- manifest.ts
- button
- README.md
Options
The tree view has no choices: it is drawn one way.
Examples
Usage
A project's files with the edited file chosen. Tab reaches the tree once; the arrow keys move inside it, Right opens a folder and Left closes it.
- simbolik
- components
- button
- button.css
- manifest.ts
- card
- card.css
- manifest.ts
- button
- tokens
- color.tokens.json
- space.tokens.json
- components
- README.md
Counts and changes
A count at the end of a folder's row, and a dot for a changed file and the folder that holds it. The dot is said in words after the label.
- simbolik
- components
- button
- button.css
- manifest.ts
- cardchanged
- card.csschanged
- manifest.ts
- button
- tokens
- color.tokens.json
- space.tokens.json
- components
- README.md
Any icons
A site's pages, each row with an icon of its own.
- Home
- Work
- A studio's new identity
- An online shop's checkout
- About
- Contact
- Work
Open only
Layers that open and close, with no row chosen. A folder with nothing inside it yet still has its caret.
- Page
- Header
- Logo
- Navigation
- Main
- Footer
- Header
In a narrow space
A long name wraps onto a second line rather than being cut short.
- docs
- guides
- setting-up-the-design-system-in-a-new-project.md
- tokens.md
- guides
Guidelines
Purpose
Shows a hierarchy the reader opens level by level: folders and files, sections and pages, categories and the things in them. Each folder opens to show the rows inside it, and the reader can choose a row to act on it. It differs from the sidebar, which is the site's or the application's navigation, and from the accordion, which opens sections of content rather than rows.
When to use
- Files and folders: a project's files, a library's assets, a document's parts.
- A hierarchy of three levels or more that the reader browses and chooses from, beside a pane that shows the chosen item.
- A structure the reader edits, such as the layers of a design or the parts of a page.
When not to use
- The site's or the application's main navigation. Use the sidebar or the header; a tree is for browsing a structure, and a list of links that opens in sections suits most sites better.
- Two levels with a few items each. Use a list with headings, or an accordion.
- Content the reader reads rather than chooses from. Use an accordion.
- Many columns of data per row. Use a table.
Usage rules
Do
- Name the tree for what it shows, such as "Files".
- Open the folders on the way to the chosen row, so it can be seen.
- Keep labels short; the path is in the levels.
Don't
- Put a button or a link inside a row; use the row itself, and the page's own commands.
- Use a tree for a list one level deep.
- Use the changed dot or the meta for something the reader must not miss; say it in the pane beside the tree as well.
- components
- card
- card.css
- card
Open the folders on the way to the chosen row, so the reader sees where it is.
- button.css
- card.css
- input.css
A tree one level deep is a list: the carets and lines add nothing.
Anatomy
- The block: the tree, a list of rows, with a name.
item: one row's list item, a file or a folder. A folder holdsgroup, the rows inside it, shown while it is open.row: what the reader sees and points at, 32 tall. Each level steps it 16 further in, and a fine line runs down under each open folder's caret beside the rows inside it.toggle: the caret's room, holdingcareton a folder (pointing on while closed, down while open) and kept empty on a file, so labels line up.icon: a folder or a file, or any icon the row is given.label: the row's words, in Body SM; a weight step bolder on the chosen row.meta: an optional count or short note at the row's end, in Body XS.changed: an optional dot at the row's end for a changed item, withhiddenwords saying so.
Behavior
The tree keeps which folders are open and which row is chosen, or the page controls both. Clicking a row, or pressing Enter on it, chooses it and opens or closes it if it is a folder; Space chooses it without opening it. The page acts on the chosen row, such as opening the file in the pane beside the tree. A tree can also be open-only, with no row chosen.
A closed folder's rows are hidden, and the page keeps them in place. The caret turns as a folder opens, at once under reduced motion. The chosen row's label is one weight bolder, and every label keeps the room of its bolder self, so nothing beside it moves as it turns bold (markup written by hand repeats the label's words in its data-label attribute). A long label wraps onto a second line rather than being cut short. The tree indents to any depth; past four or five levels, rethink the structure.
Accessibility
What it does for you
- It is a tree: the list has the tree role and a name, each row the tree item role with its level, each folder's rows a group, and each folder says whether it is open. The chosen row says it is selected. A screen reader hears "tree", its name, then each row's label, its level and its state.
- It is one Tab stop for the whole tree: Tab lands on the row focused last, else the chosen one, else the first.
- The focus ring is drawn just inside the row, so a tree in a scrolling panel never cuts it off.
- The chosen row is shown by a fill and a bolder label, not by color alone.
- The changed dot is said in words after the label ("changed", unless the page gives its own words).
- The meta is read after the label too.
- Each row is at least 32 tall.
What you must do
- Write the meta so it makes sense after the label, such as "12 files", or add hidden words to it.
- Give the page's own words for the changed dot if "changed" does not fit.
- In hand-written markup, give the list the tree role and a name, each row the tree item role,
aria-level,aria-selectedwhere rows can be chosen,aria-expandedon each folder only, a group around each folder's rows,--smbk-tree-levelbeside its level (one less: the top rows are 0), and move focus with script; without script the tree cannot be used with the keyboard.
How to test
- Tab: focus enters the tree once, on the last focused row, else the chosen one, else the first, with a focus ring just inside the row. Use the keys in the table to move, open and close folders, and choose rows; the next Tab leaves the tree.
- With a screen reader, it is read as a tree by its name, each row by its label, level and state, each folder as open or closed, and the chosen row as selected.
- In a right-to-left page, Left and Right swap.
- With text at 200% and the page 320 pixels wide, a long label wraps onto a second line rather than being cut short.
Keyboard
| Key | What it does |
|---|---|
| Tab | Moves focus into the tree, onto the row focused last, else the chosen one, else the first; pressed again, leaves the tree. |
| Down ArroworUp Arrow | Moves to the next or previous row the reader can see. |
| Right Arrow | Opens a closed folder, or moves into an open one's first row. |
| Left Arrow | Closes an open folder, or moves out to the folder a row is in. In a right-to-left page Left and Right swap. |
| HomeorEnd | Moves to the first or the last row. |
| Enter | Acts on the row, and opens or closes it if it is a folder. |
| Space | Chooses the row without opening it. |
| * | Opens every folder beside the row. |
| Any character | Typing a row's first letters moves to it. |
Source
The files the tree view is made of. Its folder on GitHub (opens in a new tab) holds them with its wrapper and its document.
Stylesheet
/* smbk-tree-view — Tree view. Figma "Tree view" 7607:118417 and "Tree item" 7580:1383.
* Folders and files, or any list that nests, one row each. A row is 32 tall: a caret on a folder (the same room
* kept empty on a file), an icon, the label, then an optional count and a dot for a changed item. Each level steps
* the row 16 further in, and a fine line runs down under each open folder's caret beside the rows inside it. Under
* the pointer a row takes the hover fill; the chosen row takes the selected fill and its label a weight step bolder, so
* it is not shown by color alone. The tree is one Tab stop; the arrow keys move inside it. A folder's rows are a
* group that is hidden while it is closed. The level comes from the item's --smbk-tree-level, written beside its
* aria-level (one less: the top rows are level 0). */
.smbk-tree-view,
.smbk-tree-view__group {
display: flex;
flex-direction: column;
min-width: 0;
margin: 0;
padding: 0;
list-style: none;
}
.smbk-tree-view__group[hidden] {
display: none;
}
.smbk-tree-view__item {
--smbk-tree-level: 0;
display: block;
min-width: 0;
outline: none;
}
.smbk-tree-view__row {
box-sizing: border-box;
position: relative;
display: flex;
align-items: center;
gap: var(--smbk-space-6);
min-height: var(--smbk-size-control-sm);
padding-block: var(--smbk-space-4);
padding-inline: calc(var(--smbk-space-8) + var(--smbk-tree-level) * var(--smbk-space-16)) var(--smbk-space-8);
border-radius: var(--smbk-radius-control-sm);
color: var(--smbk-color-text-default);
cursor: default;
touch-action: manipulation;
}
/* The lines down the levels: one under each ancestor's caret, as fine as a hairline and centred on it. */
.smbk-tree-view__row::before {
content: '';
position: absolute;
inset-block: 0;
inset-inline-start: var(--smbk-space-8);
width: calc(var(--smbk-tree-level) * var(--smbk-space-16));
background-image: linear-gradient(
to right,
transparent round(down, calc((var(--smbk-size-icon-xs) - var(--smbk-border-width-hairline)) / 2), 1px),
var(--smbk-color-border-subtle) 0,
var(--smbk-color-border-subtle) calc(round(down, calc((var(--smbk-size-icon-xs) - var(--smbk-border-width-hairline)) / 2), 1px) + var(--smbk-border-width-hairline)),
transparent 0
);
background-size: var(--smbk-space-16) 100%;
background-repeat: repeat-x;
pointer-events: none;
}
.smbk-tree-view__row:dir(rtl)::before {
transform: scaleX(-1);
}
.smbk-tree-view__toggle {
display: flex;
flex: none;
align-items: center;
justify-content: center;
width: var(--smbk-size-icon-xs);
height: var(--smbk-size-icon-xs);
font-size: var(--smbk-size-icon-xs);
color: var(--smbk-color-text-default);
}
.smbk-tree-view__caret {
font-size: var(--smbk-size-icon-xs);
}
.smbk-tree-view__icon {
font-size: var(--smbk-size-icon-sm);
color: var(--smbk-color-text-default);
}
.smbk-tree-view__label {
flex: 1 1 0;
min-width: 0;
overflow-wrap: anywhere;
font-family: var(--smbk-typography-body-sm-font-family);
font-size: var(--smbk-typography-body-sm-font-size);
font-weight: var(--smbk-typography-body-sm-font-weight);
line-height: var(--smbk-typography-body-sm-line-height);
letter-spacing: var(--smbk-typography-body-sm-letter-spacing);
}
/* The label is always as wide as its bolder self: an invisible copy of its words (`data-label`, which the wrapper
writes) at the chosen row's weight lies under it with no height, so a label that turns bold never pushes
anything beside it. The copy is generated content, hidden, with empty words for assistive technology, so the
label is read once; it wraps as the label does. */
.smbk-tree-view__label[data-label]::after {
content: attr(data-label);
content: attr(data-label) / "";
display: block;
height: 0;
overflow: hidden;
visibility: hidden;
font-family: var(--smbk-typography-body-sm-medium-font-family);
font-weight: var(--smbk-typography-body-sm-medium-font-weight);
letter-spacing: var(--smbk-typography-body-sm-medium-letter-spacing);
pointer-events: none;
user-select: none;
}
.smbk-tree-view__meta {
flex: none;
color: var(--smbk-color-text-subtlest);
font-family: var(--smbk-typography-body-xs-font-family);
font-size: var(--smbk-typography-body-xs-font-size);
font-weight: var(--smbk-typography-body-xs-font-weight);
line-height: var(--smbk-typography-body-xs-line-height);
letter-spacing: var(--smbk-typography-body-xs-letter-spacing);
font-variant-numeric: tabular-nums;
}
.smbk-tree-view__changed {
flex: none;
width: var(--smbk-size-6);
height: var(--smbk-size-6);
border-radius: var(--smbk-radius-full);
background: var(--smbk-color-feedback-warning-icon);
}
/* .smbk-tree-view__hidden: hidden from view, kept for screen readers and the keyboard, by the shared rule in visually-hidden/visually-hidden.css. */
/* ---- states: hover, chosen, focus ---- */
@media (hover: hover) {
.smbk-tree-view__item:not([aria-selected='true']) > .smbk-tree-view__row:hover {
background: var(--smbk-color-surface-hover);
}
}
.smbk-tree-view__item[aria-selected='true'] > .smbk-tree-view__row {
background: var(--smbk-color-surface-selected);
}
.smbk-tree-view__item[aria-selected='true'] > .smbk-tree-view__row .smbk-tree-view__label {
font-family: var(--smbk-typography-body-sm-medium-font-family);
font-weight: var(--smbk-typography-body-sm-medium-font-weight);
letter-spacing: var(--smbk-typography-body-sm-medium-letter-spacing);
}
/* The ring just inside the row's edge: rows touch, and a tree in a scrolling panel would cut one drawn outside. */
.smbk-tree-view__item:focus-visible > .smbk-tree-view__row {
outline: var(--smbk-focus-ring-width) solid var(--smbk-color-border-focus);
outline-offset: calc(var(--smbk-focus-ring-width) * -1);
}
/* ---- motion: a folder's caret turns as it opens; none under reduced motion ---- */
.smbk-tree-view__caret {
transition: transform var(--smbk-motion-state-duration) var(--smbk-motion-state-easing);
}
.smbk-tree-view__item[aria-expanded='true'] > .smbk-tree-view__row .smbk-tree-view__caret {
transform: rotate(90deg);
}
.smbk-tree-view__item[aria-expanded='false'] > .smbk-tree-view__row:dir(rtl) .smbk-tree-view__caret {
transform: scaleX(-1);
}
@media (prefers-reduced-motion: reduce) {
.smbk-tree-view__caret {
transition: none;
}
}
/* ---- forced colors: the chosen row framed, the lines and the changed dot in the text color ---- */
@media (forced-colors: active) {
.smbk-tree-view__item[aria-selected='true'] > .smbk-tree-view__row {
outline: var(--smbk-border-width-hairline) solid Highlight;
}
.smbk-tree-view__row::before {
forced-color-adjust: none;
background-image: linear-gradient(
to right,
transparent round(down, calc((var(--smbk-size-icon-xs) - var(--smbk-border-width-hairline)) / 2), 1px),
GrayText 0,
GrayText calc(round(down, calc((var(--smbk-size-icon-xs) - var(--smbk-border-width-hairline)) / 2), 1px) + var(--smbk-border-width-hairline)),
transparent 0
);
}
.smbk-tree-view__changed {
forced-color-adjust: none;
background: CanvasText;
}
.smbk-tree-view__item:focus-visible > .smbk-tree-view__row {
outline-color: CanvasText;
}
}
/* ---- touch: every row takes an invisible press area ----
On a touch screen an invisible area, centred on each row, takes the press out to the comfortable target where it
falls short of it. The rows touch: a row reaches up only if it is the tree's first, and its reach down lies under
the next row, which is drawn over it, so each row keeps its own whole box. What is drawn stays as it is. */
@media (pointer: coarse) {
.smbk-tree-view__row::after {
content: '';
position: absolute;
inset-inline: min(0%, (100% - var(--smbk-size-target-comfortable)) / 2);
inset-block-start: 0;
inset-block-end: min(0%, (100% - var(--smbk-size-target-comfortable)) / 2);
}
.smbk-tree-view > .smbk-tree-view__item:first-child > .smbk-tree-view__row::after {
inset-block-start: min(0%, (100% - var(--smbk-size-target-comfortable)) / 2);
}
}Tokens it reads
| Token and CSS | Value | Preview | Foundation |
|---|---|---|---|
| space.6--smbk-space-6 | 6px | Spacing | |
| size.control.sm--smbk-size-control-sm | comfortable: 32pxcompact: 28pxchanges with density | Size | |
| space.4--smbk-space-4 | 4px | Spacing | |
| space.8--smbk-space-8 | 8px | Spacing | |
| space.16--smbk-space-16 | 16px | Spacing | |
| radius.control.sm--smbk-radius-control-sm | 4px | Radius | |
| color.text.default--smbk-color-text-default | light: #232527dark: #e7ebf0changes with theme | Color | |
| size.icon.xs--smbk-size-icon-xs | 12px | Size | |
| border-width.hairline--smbk-border-width-hairline | 1px | Border width | |
| color.border.subtle--smbk-color-border-subtle | light: #c4cdd8dark: #2d3237changes with theme | Color | |
| size.icon.sm--smbk-size-icon-sm | 16px | Size | |
| typography.body-sm--smbk-typography-body-sm-font-family--smbk-typography-body-sm-font-size--smbk-typography-body-sm-font-weight--smbk-typography-body-sm-line-height--smbk-typography-body-sm-letter-spacing--smbk-typography-body-sm-text-transform | fontFamily: Geist, Geist Fallback, system-ui, sans-seriffontSize: 14pxfontWeight: 400lineHeight: 1.5letterSpacing: 0px | The quick brown fox | Typography |
| typography.body-sm-medium--smbk-typography-body-sm-medium-font-family--smbk-typography-body-sm-medium-font-size--smbk-typography-body-sm-medium-font-weight--smbk-typography-body-sm-medium-line-height--smbk-typography-body-sm-medium-letter-spacing--smbk-typography-body-sm-medium-text-transform | fontFamily: Geist, Geist Fallback, system-ui, sans-seriffontSize: 14pxfontWeight: 500lineHeight: 1.5letterSpacing: 0px | The quick brown fox | Typography |
| color.text.subtlest--smbk-color-text-subtlest | light: #606a75dark: #7c8692changes with theme | Color | |
| typography.body-xs--smbk-typography-body-xs-font-family--smbk-typography-body-xs-font-size--smbk-typography-body-xs-font-weight--smbk-typography-body-xs-line-height--smbk-typography-body-xs-letter-spacing--smbk-typography-body-xs-text-transform | fontFamily: Geist, Geist Fallback, system-ui, sans-seriffontSize: 12pxfontWeight: 400lineHeight: 1.5letterSpacing: 0px | The quick brown fox | Typography |
| size.6--smbk-size-6 | 6px | Size | |
| radius.full--smbk-radius-full | 9999px | Radius | |
| color.feedback.warning.icon--smbk-color-feedback-warning-icon | light: #b14904dark: #fa8349changes with theme | Color | |
| color.surface.hover--smbk-color-surface-hover | light: #373d4326dark: #606a754dchanges with theme | Color | |
| color.surface.selected--smbk-color-surface-selected | light: #373d431adark: #606a7533changes with theme | Color | |
| focus.ring.width--smbk-focus-ring-width | 2px | Focus | |
| color.border.focus--smbk-color-border-focus | light: #cc0d0ddark: #f34336changes with theme | Color | |
| motion.state.duration--smbk-motion-state-duration | 200ms | Motion | |
| motion.state.easing--smbk-motion-state-easing | cubic-bezier(0.65, 0, 0.35, 1) | Motion | |
| size.target.comfortable--smbk-size-target-comfortable | 44px | Size |
Manifest
{
"name": "tree-view",
"block": "smbk-tree-view",
"summary": "Folders and files, or any list that nests, opened and closed level by level: one row each, with a caret, an icon and the label.",
"adapt": "css",
"axes": {},
"flags": {},
"parts": [
"item",
"row",
"toggle",
"caret",
"icon",
"label",
"meta",
"changed",
"hidden",
"group"
],
"figmaNodeId": "7607:118417"
}