La Élume Beauté · Chapter 6 of 7

A team tree you can zoom

Jovanus Hartono2026

On this page

Some members of the store have a team, and the team page lists everyone on it in a table. Next to the table, "Team Hierarchy" opens a map of the same people: a nearly full-screen view with you at the top, as "YOU", and your team below, joined by thin lines. You can drag it around and zoom it with a mouse, a scroll wheel or two fingers. Down the left edge there's a label for each level of the tree, "Level 0", "Level 1" and so on, which stays pinned to the edge while the tree moves underneath it. I built the map because a table is a bad way to see who sits under whom.

The Team Hierarchy view: a tree with YOU at the top, three people on the next level and more below them, each shown as an avatar and a name, joined by thin lines. Level 0, Level 1 and Level 2 labels run down the left edge.
Part of the team map, with made-up names. The figures each card shows are hidden here.

A tree made of flexbox

The tree is drawn with CSS alone: no graph library, no SVG, and nothing measured. It's flexbox all the way down. Each person is a column: their card, a short line down, then a row holding their children, each of which is a column too. The component draws itself for every child, so one component draws the whole tree, however deep it goes:

const TreeNode = ({ person }: { person: Person }) => (
  <div className="column">
    <Card person={person} />
    {person.children.length > 0 && (
      <>
        <div className="stem" />
        <div className="row">
          {person.children.map((child) => (
            <div className="column" key={child.id}>
              <div className="connector">
                <div className="line" />
                <div className="drop" />
              </div>
              <TreeNode person={child} />
            </div>
          ))}
        </div>
      </>
    )}
  </div>
);

The part that looks hard is the bracket that joins a parent to its children. The trick is that nobody draws the whole bracket. Each child draws its own piece of it, in a small box at the top of its column that's as wide as the column.

ParentChildfirstChildmiddleChildlast
Each child draws its own piece of the bracket above it: the first from its centre to the right, the middle ones all the way across, the last from the left to its centre. The pieces line up without anything being measured.

The first child draws from its centre to its right edge, the last from its left edge to its centre, and every child in between draws all the way across. Side by side, the pieces line up into a bracket. A short line down from the centre of each box reaches the card. Every line is a plain element one pixel thick. In CSS, the three cases are three rules:

.connector { position: relative; height: 32px; }
.line { position: absolute; top: 0; left: 0; right: 0; height: 1px; }
.column:first-child > .connector > .line { left: 50%; } /* centre to right edge */
.column:last-child > .connector > .line { right: 50%; } /* left edge to centre */
.drop { position: absolute; top: 0; bottom: 0; left: 50%; width: 1px; }

Rows are never allowed to wrap, however wide a team gets, so a wide team is simply a wide canvas. That's what zooming out is for. The view goes from 15% up to 200%.

Labels that follow the zoom

The pan and zoom come from react-zoom-pan-pinch. My first version put the level labels inside the zoomable canvas, beside the tree. That looked right until you dragged sideways, when the labels slid off the screen along with everything else.

I rewrote it before that version ever left my machine. The labels moved out of the canvas into their own layer, pinned to the left edge. That layer ignores taps and clicks, with pointer-events: none, so your drag goes straight through it to the tree.

Pinned labels still have to move up and down, and grow and shrink, so each one stays level with its row as you pan and zoom. For every level, the store works out where that row's cards are on screen from the canvas position and the zoom, and moves the label there.

Zooming in, dragging and zooming out, rendered locally with made-up names and the figures on each card hidden. The level labels stay on the left edge and move with their rows.

None of that goes through React. The zoom library doesn't use React state either. It moves the canvas by writing a CSS transform straight onto it, then reports the new position and zoom through a callback, on every step of a gesture. If that callback set React state, the whole dialog and the entire tree below it would React running a component again to draw its latest state on the screen. on every step of a drag. So the callback writes a transform directly onto each label too:

const syncLabels = (canvasY: number, scale: number) => {
  labels.forEach((label, level) => {
    const rowTop = canvasY + (TOP_PADDING + level * ROW_HEIGHT) * scale;
    label.style.transform = `translateY(${rowTop}px) scale(${scale})`;
  });
};

<TransformWrapper
  onTransformed={(_ref, { positionY, scale }) => syncLabels(positionY, scale)}
>
  <TransformComponent>{tree}</TransformComponent>
</TransformWrapper>;
Drag or pinchmouse, wheel or touchPan and zoomthe zoom libraryTree canvasCSS flex, 1px linestransformposition, scaleLabel syncon every stepLevel labelspinned to the left edgetransformNo React state changes, so the tree never re-renders mid-gesture.
Two things move on every step of a gesture, and neither of them goes through React. The tree never re-renders while you drag it.

That needs to know how tall a row is, which is why the card started with a fixed height. When the cards gained two more lines of figures, the fixed height went, and the label maths now works from an estimated row height.

Starting at the top

A big tree is easy to get lost in, so the view doesn't start at its top-left corner. When the tree loads, it jumps straight to your own card at 60% zoom, with no animation. It waits one frame for the canvas to exist, zooms, then waits one more before lining the labels up with where the zoom left them:

requestAnimationFrame(() => {
  zoom.zoomToElement("you", 0.6, 0); // your card, 60%, no animation
  requestAnimationFrame(() => syncLabels(zoom.state.positionY, zoom.state.scale));
});

Each wait is a requestAnimationFrame, which runs the code just before the browser draws the next frame.

The tree is only fetched while the dialog is open, and it's kept for five minutes after that. On phones, dragging and pinching come from the same library, and nothing else needed changing. The dialog leaves a small margin around the screen's edge, and the button that opens it gets a shorter label.