Home Kate Morley on Mastodon

Tree views in CSS

A tree view (collapsible list) can be created using only HTML and CSS, without the need for JavaScript. Accessibility software will see the tree view as lists nested inside disclosure widgets, and the standard keyboard interaction is supported automatically.

The HTML

We begin with the HTML for simple nested lists:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
<ul>
  <li>
    Giant planets
    <ul>
      <li>
        Gas giants
        <ul>
          <li>Jupiter</li>
          <li>Saturn</li>
        </ul>
      </li>
      <li>
        Ice giants
        <ul>
          <li>Uranus</li>
          <li>Neptune</li>
        </ul>
      </li>
    </ul>
  </li>
</ul>

We then add a class to the outermost <ul> element, and for each list item that contains a nested list, we put the contents of the list item inside <details> and <summary> elements, using the open attribute to control which nested lists are initially expanded:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<ul class="tree">
  <li>
    <details open>
      <summary>Giant planets</summary>
      <ul>
        <li>
          <details>
            <summary>Gas giants</summary>
            <ul>
              <li>Jupiter</li>
              <li>Saturn</li>
            </ul>
          </details>
        </li>
        <li>
          <details>
            <summary>Ice giants</summary>
            <ul>
              <li>Uranus</li>
              <li>Neptune</li>
            </ul>
          </details>
        </li>
      </ul>
    </details>
  </li>
</ul>

Without any styling, this HTML produces:

The browser implements the <details> element as a disclosure widget, giving the ability to expand and collapse the nested lists, but the combination of bullet points and disclosure arrows produces a confusing user interface.

Custom properties

There are two dimensions that affect the layout of the tree view: the spacing between lines (which equals the line height of the text) and the radius of the markers. We begin by creating CSS custom properties for these dimensions:

1
2
3
4
.tree{
  --spacing : 1.5rem;
  --radius  : 10px;
}

While we would usually use relative units to scale user interface controls based on the text size, for the markers this can lead to controls that are too small or excessively large, so we instead use a reasonable fixed size.

Padding

We then style the list items and nested lists to make space for the lines and markers:

 6
 7
 8
 9
10
11
12
13
14
15
.tree li{
  display      : block;
  position     : relative;
  padding-left : calc(2 * var(--spacing) - var(--radius) - 2px);
}

.tree ul{
  margin-left  : calc(var(--radius) - var(--spacing));
  padding-left : 0;
}

Line 7 removes the bullet points from list items. Line 8 establishes a new stacking context and containing block that we will use to position the lines and markers.

Line 9 indents the list items. The indentation is equal to twice the spacing, minus the marker radius, minus the two-pixel line width. The result is that the text in a list item will align with the left side of the marker below it.

Line 13 uses a negative margin to compensate for the indentation introduced by line 9, ensuring nested lists are indented by only the desired spacing. Line 14 removes the default padding that browsers apply to lists.

On a tree view with all nested lists initially expanded, applying this styling produces:

Vertical lines

Next we add the vertical lines that form part of the lines joining the marker of each list item to the markers of its nested lists:

17
18
19
20
21
22
23
.tree ul li{
  border-left : 2px solid #ddd;
}

.tree ul li:last-child{
  border-color : transparent;
}

We use a border to create the line, and hide it on the final item in each list as the line shouldn’t continue past this item’s marker. Making the border transparent, rather than removing it completely, avoids the need to increase the padding to compensate.

Applying this styling produces:

Horizontal lines

We use generated content to add the horizontal lines that join the vertical lines to the markers of each list item:

25
26
27
28
29
30
31
32
33
34
35
.tree ul li::before{
  content      : '';
  display      : block;
  position     : absolute;
  top          : calc(var(--spacing) / -2);
  left         : -2px;
  width        : calc(var(--spacing) + 2px);
  height       : calc(var(--spacing) + 1px);
  border       : solid #ddd;
  border-width : 0 0 2px 2px;
}

This code also creates short vertical lines, as the vertical lines created previously don’t extend all the way to the markers at their top and bottom ends.

Lines 26 and 27 generate a block, and lines 28 to 30 position it to start at the midpoint of the preceding line of text, overlapping the vertical line to its left.

Lines 31 and 32 set the size of the block. It needs to be two pixels wider than the spacing as it overlaps the vertical line to its left, and one pixel taller than the spacing as half the width of the horizontal line lies below the midpoint of the line of text. Note that we are assuming the use of border box sizing, so these dimensions include the border.

Lines 33 and 34 create a border on the left and bottom sides of the block.

Applying this styling produces:

Summaries

Next we remove the default styling from summaries:

37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
.tree summary{
  display : block;
  cursor  : pointer;
}

.tree summary::marker,
.tree summary::-webkit-details-marker{
  display : none;
}

.tree summary:focus{
  outline : none;
}

.tree summary:focus-visible{
  outline : 1px dotted #000;
}

Lines 38 and 44 remove the disclosure arrows. Line 44 is needed for Safari, with the two selectors on lines 42 and 43 covering different versions of the browser. Line 39 changes the cursor to indicate that the summary can be clicked to interact with it.

Safari shows a focus indicator around summaries, even when using a pointer rather than keyboard navigation, so we remove the focus styling on line 48 and then use the :focus-visible pseudo-class to add it back for visitors using keyboard navigation on line 52.

Applying this styling produces:

Markers

We use generated content again to create the markers:

55
56
57
58
59
60
61
62
63
64
65
66
.tree li::after,
.tree summary::before{
  content       : '';
  display       : block;
  position      : absolute;
  top           : calc(var(--spacing) / 2 - var(--radius));
  left          : calc(var(--spacing) - var(--radius) - 1px);
  width         : calc(2 * var(--radius));
  height        : calc(2 * var(--radius));
  border-radius : 50%;
  background    : #ddd;
}

Note that we generate markers both for <li> elements (for list items that don’t contain nested lists) and for <summary elements, allowing list items that contain nested lists to have different marker styling depending on whether the nested list is expanded or collapsed.

Lines 57 and 58 generate a block, and lines 59 to 61 center it over the corner where the horizontal and vertical lines meet. The top is positioned at the midpoint of the line of text, minus the radius. The left is positioned at the edge of the vertical line, minus the radius, minus the one pixel corresponding to half the line width.

Lines 62 and 63 set the size of the block, and lines 64 and 65 style it as a circle.

Applying this styling produces:

Expand and collapse buttons

Finally, we add the expand and collapse buttons:

68
69
70
71
72
73
74
75
.tree summary::before{
  z-index    : 1;
  background : #696 url('expand-collapse.svg') 0 0;
}

.tree details[open] > summary::before{
  background-position : calc(-2 * var(--radius)) 0;
}

Line 69 causes the button to be displayed on top of the marker created previously. As the marker was created using ::after it would otherwise be displayed on top of the button.

Lines 70 and 74 show plus and minus signs in the buttons. You can download expand-collapse.svg (199 bytes) for use on your own site.

Applying this styling produces the finished tree view:

The finished code

Combining all of the above leads to the finished code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
.tree{
  --spacing : 1.5rem;
  --radius  : 10px;
}

.tree li{
  display      : block;
  position     : relative;
  padding-left : calc(2 * var(--spacing) - var(--radius) - 2px);
}

.tree ul{
  margin-left  : calc(var(--radius) - var(--spacing));
  padding-left : 0;
}

.tree ul li{
  border-left : 2px solid #ddd;
}

.tree ul li:last-child{
  border-color : transparent;
}

.tree ul li::before{
  content      : '';
  display      : block;
  position     : absolute;
  top          : calc(var(--spacing) / -2);
  left         : -2px;
  width        : calc(var(--spacing) + 2px);
  height       : calc(var(--spacing) + 1px);
  border       : solid #ddd;
  border-width : 0 0 2px 2px;
}

.tree summary{
  display : block;
  cursor  : pointer;
}

.tree summary::marker,
.tree summary::-webkit-details-marker{
  display : none;
}

.tree summary:focus{
  outline : none;
}

.tree summary:focus-visible{
  outline : 1px dotted #000;
}

.tree li::after,
.tree summary::before{
  content       : '';
  display       : block;
  position      : absolute;
  top           : calc(var(--spacing) / 2 - var(--radius));
  left          : calc(var(--spacing) - var(--radius) - 1px);
  width         : calc(2 * var(--radius));
  height        : calc(2 * var(--radius));
  border-radius : 50%;
  background    : #ddd;
}

.tree summary::before{
  z-index    : 1;
  background : #696 url('expand-collapse.svg') 0 0;
}

.tree details[open] > summary::before{
  background-position : calc(-2 * var(--radius)) 0;
}