Tree View

  • sprucecss
    • scss
      • spruce.scss
    • package.json
    • README.md
@use 'sprucecss/scss/spruce' as *;

.tree-view {
  @include clear-list;
  font-family: config('font-family-mono', $typography);
  font-size: config('font-size-sm', $typography);

  ul {
    @include clear-list;
    border-inline-start: config('border-width-base', $display) solid color('border');
    margin-inline-start: spacer('s');
    padding-inline-start: spacer('xs');
  }

  li {
    margin: 0 !important;

    &:focus {
      outline: none;
    }

    &:focus-visible > .tree-view__label {
      outline: 2px solid color('focus-ring');
      outline-offset: -2px;
    }

    &[aria-expanded='false'] > ul {
      display: none;
    }

    &[aria-selected='true'] > .tree-view__label {
      background-color: color-alpha('primary', 0.12);
      color: color('primary');
    }
  }

  &__label {
    align-items: center;
    border-radius: radius('sm');
    color: color('heading');
    cursor: pointer;
    display: flex;
    gap: spacer('xxs');
    padding: spacer('xxs') spacer('xs');

    &:hover {
      background-color: color('surface-muted');
    }

    &::before {
      background-color: currentcolor;
      block-size: 0.9em;
      content: '';
      flex-shrink: 0;
      inline-size: 0.9em;
      mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m9 18 6-6-6-6'/%3E%3C/svg%3E") center / contain no-repeat;
      @include transition($property: 'rotate');
    }

    &--file::before {
      mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z'/%3E%3Cpath d='M14 2v6h6'/%3E%3C/svg%3E");
      opacity: 0.6;
    }
  }

  [aria-expanded='true'] > &__label::before {
    rotate: 90deg;
  }
}

[dir='rtl'] .tree-view__label:not(.tree-view__label--file)::before {
  scale: -1 1;
}
<ul class="tree-view" role="tree" aria-label="Project files" data-tree>
    <li role="treeitem" aria-expanded="true" tabindex="0">
        <span class="tree-view__label">sprucecss</span>
        <ul role="group">
            <li role="treeitem" aria-expanded="true" tabindex="-1">
                <span class="tree-view__label">scss</span>
                <ul role="group">
                    <li role="treeitem" aria-expanded="false" tabindex="-1">
                        <span class="tree-view__label">config</span>
                        <ul role="group">
                            <li role="treeitem" tabindex="-1"><span class="tree-view__label tree-view__label--file">_color.scss</span></li>
                            <li role="treeitem" tabindex="-1"><span class="tree-view__label tree-view__label--file">_style.scss</span></li>
                        </ul>
                    </li>
                    <li role="treeitem" aria-expanded="false" tabindex="-1">
                        <span class="tree-view__label">mixin</span>
                        <ul role="group">
                            <li role="treeitem" tabindex="-1"><span class="tree-view__label tree-view__label--file">_layout.scss</span></li>
                            <li role="treeitem" tabindex="-1"><span class="tree-view__label tree-view__label--file">_theme.scss</span></li>
                        </ul>
                    </li>
                    <li role="treeitem" tabindex="-1" aria-selected="true"><span class="tree-view__label tree-view__label--file">spruce.scss</span></li>
                </ul>
            </li>
            <li role="treeitem" tabindex="-1"><span class="tree-view__label tree-view__label--file">package.json</span></li>
            <li role="treeitem" tabindex="-1"><span class="tree-view__label tree-view__label--file">README.md</span></li>
        </ul>
    </li>
</ul>
(() => {
  document.querySelectorAll('[data-tree]').forEach((tree) => {
    const visibleItems = () => [...tree.querySelectorAll('[role="treeitem"]')].filter((item) => !item.parentElement.closest('[aria-expanded="false"]'));
    const label = (item) => item.querySelector('.tree-view__label').textContent.trim().toLowerCase();

    const focus = (item) => {
      tree.querySelectorAll('[role="treeitem"]').forEach((other) => { other.tabIndex = -1; });
      item.tabIndex = 0;
      item.focus();
    };

    const select = (item) => {
      tree.querySelectorAll('[aria-selected]').forEach((other) => other.removeAttribute('aria-selected'));
      item.setAttribute('aria-selected', 'true');
    };

    const toggle = (item, force) => {
      if (item.hasAttribute('aria-expanded')) {
        item.setAttribute('aria-expanded', String(force ?? item.getAttribute('aria-expanded') !== 'true'));
      }
    };

    tree.addEventListener('click', (event) => {
      const item = event.target.closest('[role="treeitem"]');

      if (item && event.target.closest('.tree-view__label')) {
        event.stopPropagation();
        focus(item);
        item.hasAttribute('aria-expanded') ? toggle(item) : select(item);
      }
    });

    tree.addEventListener('keydown', (event) => {
      const item = event.target.closest('[role="treeitem"]');
      const items = visibleItems();
      const index = items.indexOf(item);
      const isRtl = getComputedStyle(tree).direction === 'rtl';
      const open = isRtl ? 'ArrowLeft' : 'ArrowRight';
      const closeKey = isRtl ? 'ArrowRight' : 'ArrowLeft';
      const isExpanded = item.getAttribute('aria-expanded') === 'true';

      if (event.key === 'ArrowDown' && items[index + 1]) focus(items[index + 1]);
      else if (event.key === 'ArrowUp' && items[index - 1]) focus(items[index - 1]);
      else if (event.key === 'Home') focus(items[0]);
      else if (event.key === 'End') focus(items[items.length - 1]);
      else if (event.key === open && item.hasAttribute('aria-expanded')) isExpanded ? focus(item.querySelector('[role="treeitem"]')) : toggle(item, true);
      else if (event.key === closeKey) isExpanded ? toggle(item, false) : item.parentElement.closest('[role="treeitem"]') && focus(item.parentElement.closest('[role="treeitem"]'));
      else if (event.key === 'Enter' || event.key === ' ') item.hasAttribute('aria-expanded') ? toggle(item) : select(item);
      else if (event.key.length === 1 && /\S/.test(event.key)) {
        const next = [...items.slice(index + 1), ...items.slice(0, index)].find((other) => label(other).startsWith(event.key.toLowerCase()));
        if (next) focus(next);
      } else return;

      event.preventDefault();
    });
  });
})();

Tree View

On this page

A hierarchical tree for file browsers, nested navigation or category pickers.

Technical Details

  • It implements the WAI-ARIA Tree View pattern: tree, treeitem and group roles, aria-expanded on parent items, aria-selected on the chosen item and a roving tabindex (one tab stop).
  • Keyboard: ↓/↑ move between visible items, → opens a folder or moves into it, ← closes it or moves to the parent (both mirrored in RTL), Home/End jump, Enter/Space select, and typing a letter jumps to the next matching item.
  • The indentation guides use border-inline-start, so the tree mirrors in RTL.
  • For simple, link-based navigation, the Sidebar Navigation with details is lighter.

Documentation

Learn about Spruce CSS through our extensive documentation.

Components

Explore our UI library of accessible components built with Spruce CSS.

Blocks and Pages

Full-width page blocks and complete page templates, ready to copy.

Find us on GitHub

Did you find a bug? Have an idea or a question? Please open an issue to help us develop the project.