GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

CSS Org Chart: 9 Tidy Tree Layout Demos

A CSS org chart looks like boxes and lines, yet without measured widths siblings overlap, boxes jump as a branch folds and parents' lines overlap after a

See All Nine Org Chart Scenes Running on This Page

Auto-plays · click a tile to jump to its section · all nine in one zip

CSS Org Chart: 9 Demos That Measure Branch Width First

All nine scenes share one eleven-box company. A root sits over three departments, the first has three teams under it, the second two and the third two, and the sum of those leaves decides how wide every branch must be. The canvas is 480 by 300 inside (W and H), every box, label and line is painted through the 2d context, and CSS only stretches the result at the same 16:10 ratio, which is why an org chart css responsive to its column needs no change to the layout math.

Two functions carry everything. measure adds widths from the leaves upward, and place lays each child group out from left to right and seats the parent midway between its first and last child. Scene 01 shows that skeleton bare, while 02 to 09 call the same pair and stack one behavior on top: line drawing, folding, path highlight, pan and zoom, a fan layout, reparenting, search and headcount totals. No force simulation is involved, so the result is the same on every run, unlike the spring-driven nodes in Force Directed Graph: 9 Network Layout Demos.

Time comes from requestAnimationFrame timestamps gathered into 16 ms steps (FIXED_DT), and a gap longer than 160 ms is cut to 160 so a tab that wakes up does not make the scene jump. No random numbers, Date, fetch, storage, library or outside address appear in any file. If your tree is a plain list of folders, 9 File Tree CSS Views — Folders That Really Open fits better than a diagram.

Code for Each Tree Layout Technique

Each snippet is a run of consecutive lines from the demo source, and the complete files are in the zip. Sizes and spacing are the same in all nine: NODE_W 56, GAP_X 8, LEVEL_DY 72 and TOP_Y 44.

01Seating Each Parent Over the Measured Width of Its Branch

measure starts every leaf at NODE_W and adds the child widths plus GAP_X on the way up, and place puts each parent midway between its first and last child. Seven leaves of 56 with six gaps of 8 give a top width of 440, and the three departments come out at 184, 120 and 120. Over 22 of 23 measured frames this scene accumulated 10.9% repaint area at an average intensity of 121.0.

Base Layout That Centers Each Parent Over Measured Branch Widths
Open in a new tab

01 · Base Layout That Centers Each Parent Over Measured Branch Widths — Measured area 10.878% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

GAP_XNODE_WMID_START_MSROOT_START_MSSETTLE_MS
      function measure(n) {
          const v = vis(n);
          if (v.length === 0) {
              n.w = n.bw;
              return n.w;
          }
          let sum = GAP_X * (v.length - 1);
          for (let i = 0; i < v.length; i += 1) {
              sum += measure(v[i]);
          }
          n.w = Math.max(n.bw, sum);
          return n.w;
      }
      function place(n, left) {
          const v = vis(n);
          n.left = left;
          if (v.length === 0) {
              n.tx = left + n.w / 2;
              return;
          }
          let span = GAP_X * (v.length - 1);
          for (let i = 0; i < v.length; i += 1) {
              span += v[i].w;
          }
          let cur = left + (n.w - span) / 2;
          for (let i = 0; i < v.length; i += 1) {
              place(v[i], cur);
              cur += v[i].w + GAP_X;
          }
          n.tx = (v[0].tx + v[v.length - 1].tx) / 2;
      }

02Elbow Connectors Drawn as a Share of Their Length

Each connector leaves the parent's bottom center, bends at the middle height and enters the child's top center, and setLineDash draws only the ratio share of its length over DRAW_MS (700 ms). A white dot with a percentage rides the tip, the box floats up once a line passes 70%, and every line retracts again from 2000 ms. Measured: 13.6% accumulated area at intensity 99.6 on 22/23 frames.

Elbow Connectors Drawn From Parent to Child
Open in a new tab

02 · Elbow Connectors Drawn From Parent to Child — Measured area 13.637% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

DRAW_MSKID_GAP_MSRETRACT_START_MSRETRACT_MS
      function strokeLine(q, ratio, col, lw) {
          if (ratio <= 0) {
              return;
          }
          const total = polyLen(q);
          ctx.beginPath();
          ctx.moveTo(q[0], q[1]);
          for (let i = 2; i < q.length; i += 2) {
              ctx.lineTo(q[i], q[i + 1]);
          }
          ctx.lineWidth = lw;
          ctx.strokeStyle = col;
          ctx.lineCap = 'round';
          ctx.lineJoin = 'round';
          ctx.setLineDash(ratio >= 1 ? [] : [total * ratio, total * 2]);
          ctx.stroke();
          ctx.setLineDash([]);
      }
      function elbow(p, c) {
          const ay = p.y + p.bh / 2;
          const dy = c.y - c.bh / 2;
          const my = (ay + dy) / 2;
          return [p.x, ay, p.x, my, c.x, my, c.x, dy];
      }

03Folding a Branch While Siblings Glide into the Gap

fold(n, open) calls layout() to re-measure the visible branches, and on every step each box closes MOVE_RATE (0.09) of its remaining distance to the new tx. Folded boxes carry a badge such as +3, and arrow keys move a ring while Enter or Space folds the box under it. The scene accumulated 21.6% area at intensity 82.8 and moved in 23 of 23 frames.

Reflow Where Remaining Boxes Glide To New Spots When a Branch Folds
Open in a new tab

03 · Reflow Where Remaining Boxes Glide To New Spots When a Branch Folds — Measured area 21.601% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

MOVE_RATEFADE_RATELEVEL_DY
      function settleNodes(rate) {
          for (let i = 0; i < nodes.length; i += 1) {
              const n = nodes[i];
              n.x += (n.tx - n.x) * rate;
              n.y += (n.ty - n.y) * rate;
              n.alpha += (n.talpha - n.alpha) * FADE_RATE;
          }
      function fold(n, open) {
          if (n.kids.length === 0) {
              return;
          }
          n.open = open;
          cursor = n;
          showCursor = true;
          layout();
          setStatus(n.label + ': ' + (open ? 'Open' : 'Folded'));
      }

04Lighting Only the Path from a Pointed Node to the Root

Pointing at a node starts a clock, and its k-th ancestor lights once that clock passes k × LIT_STEP_MS (220), so the glow climbs one level at a time while everything else drops to DIM_ALPHA (0.3). The status line spells out the path from the root to the pointed node, and Escape clears it. Over 22/23 frames it measured 13.8% accumulated area at intensity 74.8.

Highlight That Lights Only the Path From a Node to the Top
Open in a new tab

04 · Highlight That Lights Only the Path From a Node to the Top — Measured area 13.762% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

LIT_STEP_MSLIT_RATEDIM_ALPHA
      function litTarget() {
          for (let i = 0; i < nodes.length; i += 1) {
              const k = chainIndex(nodes[i]);
              nodes[i].tval = (focus && k >= 0 && litMs >= k * LIT_STEP_MS) ? 1 : 0;
          }
      }
      stepFn = function (dt) {
          litMs += dt;
          litTarget();
          for (let i = 0; i < nodes.length; i += 1) {
              nodes[i].lit += (nodes[i].tval - nodes[i].lit) * LIT_RATE;
          }
          dimNow += ((focus ? DIM_ALPHA : 1) - dimNow) * LIT_RATE;
      };

05Pan and Zoom That Keep the Spot Under the Pointer Still

zoomAt(px, py, k) clamps the new scale between ZOOM_MIN (1) and ZOOM_MAX (3.2) and solves the offset together with it, so the point of the tree under the pointer does not slide. Letting go of a drag keeps its speed, FRICTION (0.94) trims it on every step, and a small map in the corner shows the visible window. This scene measured 45.6% accumulated area at intensity 106.0 on 23/23 frames.

Pan and Zoom That Keep the Point Under the Pointer Fixed
Open in a new tab

05 · Pan and Zoom That Keep the Point Under the Pointer Fixed — Measured area 45.608% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ZOOM_RATEZOOM_MAXFRICTIONPAD_X
      function clampCam() {
          cam.ox = clamp(cam.ox, W - W * cam.s - PAD_X, PAD_X);
          cam.oy = clamp(cam.oy, H - H * cam.s - PAD_Y, PAD_Y);
      }
      // solve the offset so the world point under (px, py) stays put when the zoom becomes k times larger
      function zoomAt(px, py, k) {
          const ns = clamp(cam.s * k, ZOOM_MIN, ZOOM_MAX);
          const real = ns / cam.s;
          cam.ox = px - (px - cam.ox) * real;
          cam.oy = py - (py - cam.oy) * real;
          cam.s = ns;
          clampCam();
      }

06Switching Between a Row Layout and a Fan Along an Arc

Every node holds an (angle, radius) pair seen from one origin, and the fan spreads the seven leaves across FAN_SPAN (2.62 radians) with RING (78) per level. Angle and radius are lerped separately, so each box travels along an arc, and the connectors become cubic curves mixed by the same progress fan, which closes in by FAN_RATE (0.04) per step. It measured 21.8% accumulated area at intensity 98.5, with motion in 23 of 23 frames.

Polar Layout That Fans the Same Tree Out
Open in a new tab

06 · Polar Layout That Fans the Same Tree Out — Measured area 21.756% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

FAN_SPANRINGFAN_RATE
      function assignAngle(n) {
          if (n.kids.length === 0) {
              fanAngle[n.id] = -FAN_SPAN / 2 + (leafNo + 0.5) * FAN_SPAN / leafCount;
              leafNo += 1;
          }
          else {
              for (let i = 0; i < n.kids.length; i += 1) {
                  assignAngle(n.kids[i]);
              }
              fanAngle[n.id] = (fanAngle[n.kids[0].id] + fanAngle[n.kids[n.kids.length - 1].id]) / 2;
          }
          fanRadius[n.id] = n.depth * RING;
      }
      // express the row layout as (angle, radius) from the same origin too, and an arc joins the two layouts
      for (let i = 0; i < nodes.length; i += 1) {
          const dx = nodes[i].tx - ORIGIN_X, dy = nodes[i].ty - ORIGIN_Y;
          flatRadius[i] = Math.sqrt(dx * dx + dy * dy);
          flatAngle[i] = Math.atan2(dx, dy);
      }
      function polarX(r, a) { return ORIGIN_X + r * Math.sin(a); }
      function polarY(r, a) { return ORIGIN_Y + r * Math.cos(a); }

07Reparenting a Node and Measuring the Overlap It Leaves

reparent(n, to) changes only the links and leaves the box where it was, and measureOverlap totals how far horizontal lines of different parents overlap at the same height: 64px, 192px and 64px for the three moves, each falling to 0 once resolve() re-measures and the boxes flow over at REFLOW_RATE (0.1). Enter picks a node up, arrows choose a new parent, Enter drops it and Escape cancels. The scene accumulated 14.6% area at intensity 58.2 on 22/23 frames.

Move Where Lines and Boxes Reflow Together When the Parent Changes
Open in a new tab

07 · Move Where Lines and Boxes Reflow Together When the Parent Changes — Measured area 14.557% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

TANGLE_MSREFLOW_RATEGAP_X
      function measureOverlap(useTarget) {
          overlaps = [];
          overlapPx = 0;
          const spans = [];
          for (let i = 0; i < nodes.length; i += 1) {
              const p = nodes[i];
              const v = vis(p);
              if (v.length === 0) {
                  continue;
              }
              const px0 = useTarget ? p.tx : p.x;
              let lo = px0, hi = px0;
              for (let j = 0; j < v.length; j += 1) {
                  const vx0 = useTarget ? v[j].tx : v[j].x;
                  lo = Math.min(lo, vx0);
                  hi = Math.max(hi, vx0);
              }
              const py0 = useTarget ? p.ty : p.y;
              const vy0 = useTarget ? v[0].ty : v[0].y;
              spans.push([lo, hi, ((py0 + p.bh / 2) + (vy0 - v[0].bh / 2)) / 2]);
          }
          for (let a = 0; a < spans.length; a += 1) {
              for (let b = a + 1; b < spans.length; b += 1) {
                  if (Math.abs(spans[a][2] - spans[b][2]) > 1) {
                      continue;
                  }
                  const lo = Math.max(spans[a][0], spans[b][0]);
                  const hi = Math.min(spans[a][1], spans[b][1]);
                  if (hi - lo > 0.5) {
                      overlaps.push([lo, hi, spans[a][2]]);
                      overlapPx += hi - lo;
                  }
              }
          }
      }
      function resolve() {
          const before = overlapPx;
          layout();
          tangled = false;
          pendingLayout = false;
          // measure the same span again against the re-measured target spots
          measureOverlap(true);
          setStatus('Overlap ' + Math.round(before) + 'px > ' + Math.round(overlapPx) + 'px');
          overlaps = [];
      }

08Typing a Query and Flying the Camera to the Match

applyQuery(q) treats every label containing the query as a hit, aims the camera at the first hit with FOCUS_ZOOM (1.9) and closes in by CAM_RATE (0.08) per step, while misses fade to DIM_ALPHA (0.25) and the hit letters get a 2px underline measured with measureText. It is the only scene with an <input>, so typing stops the demo and Escape in the box clears the query. Scene 08 reached 53.9% accumulated area at intensity 58.9 on 22/23 frames, the largest area of the nine because the whole tree scales.

Search Focus Where the View Closes In on the Matching Node
Open in a new tab

08 · Search Focus Where the View Closes In on the Matching Node — Measured area 53.938% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

CAM_RATEFOCUS_ZOOMDIM_ALPHA
      function applyQuery(q) {
          query = q;
          matchFirst = null;
          matchCount = 0;
          for (let i = 0; i < nodes.length; i += 1) {
              if (matches(nodes[i])) {
                  matchCount += 1;
                  if (!matchFirst) {
                      matchFirst = nodes[i];
                  }
              }
          }
          if (matchFirst) {
              camS = FOCUS_ZOOM;
              camOx = W / 2 - matchFirst.tx * FOCUS_ZOOM;
              camOy = H / 2 - matchFirst.ty * FOCUS_ZOOM;
              setStatus('Found ' + matchCount);
          }
          else {
              camS = 1;
              camOx = 0;
              camOy = 0;
              setStatus(query === '' ? 'No query' : 'No match');
          }
      }

09Headcounts Rolling Up So Boxes Grow by Their Total

Each leaf holds a headcount, rollUp() re-sums every parent from the bottom on each step, and a box gets wider and taller with its total while its line gets thicker. Every change calls layout() again, so a growing box pushes its siblings aside, and + and - change the selected leaf. It measured 11.9% accumulated area at intensity 45.8, moving in 23 of 23 frames.

Hero Scene Rolling Headcount Upward Into Box Size
Open in a new tab

09 · Hero Scene Rolling Headcount Upward Into Box Size — Measured area 11.908% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

COUNT_RATEWIDTH_PERHEIGHT_PERLINE_PERWIDTH_MAX
      function rollUp() {
          for (let i = nodes.length - 1; i >= 0; i -= 1) {
              const n = nodes[i];
              if (n.kids.length === 0) {
                  n.total = n.val;
              }
              else {
                  let s = 0;
                  for (let j = 0; j < n.kids.length; j += 1) {
                      s += n.kids[j].total;
                  }
                  n.total = s;
              }
              n.bw = Math.min(WIDTH_MAX, WIDTH_BASE + n.total * WIDTH_PER);
              n.bh = HEIGHT_BASE + n.total * HEIGHT_PER;
          }
      }

Where the Values Change the Scene — Branch Gap, Glide Ratio, Fan Angle

Three constants decide most of what you see: the space between siblings (GAP_X in 01), the share of distance a box closes per step (MOVE_RATE in 03) and the angle the fan covers (FAN_SPAN in 06). Each is a plain constant at the top of its file, so editing it changes the very next calculation.

The Gap Sets the Width of the Whole Tree

At GAP_X 8 the top branch is 440 wide. Dropping it to 3 gives 410 and pulls the leaves together, and raising it to 16 gives 488, which no longer fits in 480. Branches with many siblings grow the most per step of gap.

The Ratio Sets How Long the Glide Lasts

MOVE_RATE 0.09 closes 9% of what is left on each step, so reaching 90% of the way takes about 24 steps, or 390 ms. At 0.2 it takes about 10 steps, 165 ms, and the same fold ends short and quick.

All nine measurements are in the table. Only 05 and 08 pass 30% accumulated area, because in the other scenes the boxes cover a small part of the canvas and 10% to 22% is normal there. Scene 01 has the smallest peak area, 3.5%, since one frame changes only a few bars and one parent.

Scene Accumulated area Average intensity Moved frames
01 Tidy layout 10.9% 121.0 22/23
02 Elbow drawing 13.6% 99.6 22/23
03 Fold and reflow 21.6% 82.8 23/23
04 Ancestor path 13.8% 74.8 22/23
05 Pointer zoom 45.6% 106.0 23/23
06 Polar fan 21.8% 98.5 23/23
07 Reparent 14.6% 58.2 22/23
08 Search camera 53.9% 58.9 22/23
09 Headcount rollup 11.9% 45.8 23/23

Where It Breaks — Deep Branches, Lopsided Trees, Unreadable Names

When the Width Passes the Canvas

The default tree of seven leaves is 440 and fits in 480, but an eighth leaf makes 56 × 8 plus 8 × 7, which is 504, and the right edge leaves the canvas. A GAP_X of 16 does the same, so work backward from the canvas width when you pick box width and gap.

When the Parent Is Not at the Center of Its Branch

The root sits midway between its first child (center 112) and its last (center 400), which is 256. The middle of the top branch is 240, so the root stands 16 to the right of it, because the first department is wide at 184 and the others are narrow at 120. A parent follows its children's boxes rather than the branch width, so a heavy side pulls it off center.

When the Ending Drags On

Scene 03 closes only a fixed share of the remaining distance, so the last pixel costs many steps even though 90% arrives fast. Two folds in a row set a new target before the first move ends, and the boxes can look as if they chase it.

When Two States Share One Screen

In 04, changing the pointed node makes the old path fade while the new one climbs, and for a moment both look lit. In 07, the only thing measured is how far horizontal lines of different parents overlap at the same height (64px, 192px, then 64px), and after resolve() all three read 0. Lines at different heights are never compared.

When a Query Hits Several Nodes

In 08 a one-letter query matches more nodes than you meant, and the camera follows only the first. Text typed through an input method can also fire input before a character is finished, so the matched node may flicker. To test these edges by editing constants, open the zip below with the password jksvbezb and change them in the file.

Three Variants and Accessibility

Name Changed value Feel
Change the branch gap 01 GAP_X 8 → 3 Narrowing the gap GAP_X between sibling boxes from 8 to 3 shrinks the whole top branch from 440 to 410 and packs the leaf boxes closer. Widening it to 16 gives 488, which overflows the 480 canvas.
Adjust the glide ratio 03 MOVE_RATE 0.09 → 0.2 Raising MOVE_RATE, the share of the remaining distance closed on each 16 ms step, from 0.09 to 0.2 cuts the time a box needs to cover 90% of its way from about 24 steps (390 ms) to about 10 steps (165 ms), so the glide ends short and quick.
Narrow the fan angle 06 FAN_SPAN 2.62 → 1.6 Narrowing the fan angle FAN_SPAN from 2.62 radians (about 150 degrees) to 1.6 radians (about 92 degrees) shortens the arc between the outermost leaves from about 58 px to about 36 px, narrower than the 56 px box width. RING has to grow with it.

With prefers-reduced-motion on, none of the nine files starts its requestAnimationFrame loop. The first scene is drawn at once, the button becomes a next-scene control, and each press runs the next event of the demo schedule and draws the final layout through snapFn with no in-between motion.

The tree lives on a canvas, so its boxes are not DOM nodes and no file carries role="tree". What a screen reader gets instead is the status text in an aria-live region, which names the selected node, plus a canvas that takes focus through tabindex. Keys differ by scene: arrow keys move between nodes only in 03, 04, 07 and 09, while in 05 they pan the view and +, - and 0 zoom or go home; in 09 + and - change the headcount instead, in 04 and 07 Escape clears the selection or drops the picked-up node, and in 08 Enter focuses the input and Escape clears the query. Scenes 01, 02 and 06 have no key selection. That is a real limit: it reads one node at a time and cannot walk the structure the way a tree widget can.

Are Flexbox and Lines Enough for a CSS Org Chart?

For a fixed picture, yes. The lightest html css org chart is a row of boxes with display: flex and lines drawn by pseudo-elements, and a tailwind css organization chart built from utility classes follows the same route; a company page that never folds or changes is better served by it. The calculation here earns its weight when a branch folds and its siblings must glide together, when a parent changes and the lines must be sorted out again, or when a hundred names need search and a camera.

This kind of tree appears in staff directories and reporting-line screens. With dozens of members, folding (03) and path highlighting (04) come first, and where departments change often, reparenting (07) and search (08) matter more.

The drawing calls are described in the Canvas API reference and the timing in the requestAnimationFrame reference.

Frequently Asked Questions Before Building a CSS Org Chart

Which Part Do I Edit to Fit My Own Team in an Html Css Org Chart?

Open scene 01 and replace the department names and parent links in the ORG array. If you have more than seven leaves, shrink the box width and GAP_X first and check that the top width still fits the canvas.

Do I Need an External Library or Image?

No. Every box, label and line comes from canvas 2d, and nothing calls an outside address, so a single scene file keeps working when you move it alone.

What Should I Fix When Boxes Overlap or a Branch Leaves the Screen?

For overlap, check that the width measure adds up is right. For a branch that leaves the screen, shrink NODE_W and GAP_X until the top width is inside the canvas. A parent that looks pushed to one side is not a bug, because it is placed by its children's positions.

Enter the archive password

The password is inside this article. You will find it as you read.