Managing dynamic z-index in component-based UI architecture

This article is a response to an article published in Smashing Magazine recently that discusses the difficulties of managing z-index in a complex UI application. Practically speaking, z-index determines the “stacking order” of a DOM element i.e. whether one element appears above another when their spatial extents overlap. Despite this fairly straightforward functionality, z-index can be tricky to work with in practice.

One example that I frequently encounter while developing interactive graphs and charts is that of adjacent interactive DOM elements that have mutually overlapping hover tooltips. For example, a graph that provides a hover tooltip for every data point can appear next to a legend that also has its own hover tooltip and we want either hover tooltip to be fully visible when they are shown.

Below is a live demonstration (with the code here).1 The viewport is divided into 3 adjacent areas, labeled A, B and C and with different background-colors. They each should respond to hover interactions by increasing their background-color’s opacity and showing a tooltip that points toward their centers.

Here’s the problem. Because the z-index of the sibling areas are ascendingly ordered A, B to C, only C’s tooltip is unobstructed visually by the others because it has the highest z-index: C's tooltip is unobstructed by A or B whereas B’s tooltip appears below C but above A: B's tooltip is obstructed by C but appears above A

This demo highlights how z-index can be tricky to managed in an interactive, complex UI application:

  • z-index only takes effect among siblings within the same stacking context. The implication is that if you are working on the area A, you must ensure that all sibling elements (B and C) that might visually intersect with A be included in the same stacking context. Practically, this requires making sure all sibling elements are relatively positioned and are assigned their own z-index value. These values should also be grouped together in a single place to avoid conflicts and “z-index wars” and to ensure the right order between these siblings. The demo’s implementation follows both suggestions.
  • z-index is hierarchical i.e. an element’s children inherits the stacking order of its parent. If you want A’s tooltip to be unobstructed by B, you need to assign A a higher z-index value than that of B. Regardless of how large the z-index value of A’s tooltip is, if the z-index of A is less than that of B, A’s tooltip will be obstructed by B or B’s descendants. The implication is that if you’re working on A’s hover tooltips, you might have to manage the z-indexes of UI components (A, B and C) several levels above. The demo’s implementation doesn’t take this into account.
  • z-index sometimes needs to be dynamic if sibling elements or their descendants could mutually obstruct each other visually. In the above demo, A’s and B’s tooltips will always appear behind at least one other area. The demo’s implementation also doesn’t take this into account.

Below is my solution in the context of an application that uses React, CSS-in-JS (emotion in this case but should apply equally well to other libraries) and TypeScript.2 The general idea is that when the mouse enters each area, it “promotes” itself i.e. increases its own z-index relative to the other siblings in order for its hover tooltip to not be obstructed by them. You can play with the solution below:

The main part of the solution is a custom React hook (code here), called usePromotableZIndex here, that takes in:

  • The regular z-index order.
  • The value of the “promoted” z-index i.e. what z-index a DOM element should have if it’s promoted.

The hook returns three callbacks:

  • getZIndex returns what z-index a sibling should have, taking into account promotion.
  • promoteZIndex allows each sibling to promote its z-index.
  • restoreZIndex allows each element to restore its regular z-index.

This is an example of how the hook is used in the solution. The parent is the App component. Within App, the variable zIndices holds the regular z-index order for the siblings named A, B and C. Each sibling (the Partition component) binds its name to the callbacks exposed by the usePromotableZIndex hook.

/**
 * https://github.com/huy-nguyen/z-index-management/blob/problem-simple-solution/src/App.tsx
 */
// ...
const App = () => {
  const zIndices = {
    A: 10,
    B: 20,
    C: 30,
  }
  const {getZIndex, promoteZIndex, restoreZIndex} = usePromotableZIndex({
    normalZIndices: zIndices,
    promotedZIndex: 100,
  })
  return (
    <Root>
      <Partition
      // ...
        getZIndex={() => getZIndex('A')}
        promoteZIndex={() => promoteZIndex('A')}
        restoreZIndex={restoreZIndex}
      />
      <Partition
      // ...
        getZIndex={() => getZIndex('B')}
        promoteZIndex={() => promoteZIndex('B')}
        restoreZIndex={restoreZIndex}
      />
      <Partition
      // ...
        getZIndex={() => getZIndex('C')}
        promoteZIndex={() => promoteZIndex('C')}
        restoreZIndex={restoreZIndex}
      />
    </Root>
  );
}
// ...

Within each sibling’s implementation, they commit to querying the getZIndex callback to get their assigned z-index. In return for following that contract, they can call promoteZIndex just before showing the hover tooltip and restoreZIndex after hiding the tooltip. By following this contract, all siblings live harmoniously together z-index-wise.

/**
 * https://github.com/huy-nguyen/z-index-management/blob/problem-simple-solution/src/Partition.tsx
 */
// ...
const Partition = (props: Props) => {
// ...
  const [isTooltipShown, setIsTooltipShown] = useState<boolean>(false);
  const onMouseEnter = () => {
    promoteZIndex();
    setIsTooltipShown(true);
  }
  const onMouseLeave = () => {
    setIsTooltipShown(false);
    restoreZIndex();
  }
  // ...
  return (
    <Root
      css={css`
        background-color: ${color};
        grid-area: ${gridArea};
        z-index: ${getZIndex()};
      `}
      onMouseEnter={onMouseEnter}
      onMouseLeave={onMouseLeave}
    >
      {tooltip}
      {label}
    </Root>
  )
}
// ...

The advantages of this approach are:

  • TypeScript’s type checking ensures that the getZIndex and promoteZIndex can only be called with valid sibling names. TypeScript’s autocomplete also provides suggestions of valid sibling names as shown in this screenshot: Example of TypeScript's autocomplete suggestions of valid sibling names
  • Only the hook has access to the actual z-index values, acting as a gatekeeper to encourage adherence to the z-index promotion contract.
  • The z-index promotion behavior is entirely contained within a hook, making it re-usable across all levels of the component tree.
  • The three callbacks can be passed arbitrarily deep down a component tree, allowing any descendant to control the z-index of the ancestor it needs in order to display itself correctly.

  1. React is not necessary to demonstrate the problem but I want to minimize the diff against the solution.

  2. emotion and TypeScript are not absolutely necessary but they make the solution nicer to work with. emotion can be replaced with the inline style attribute and TypeScript provides typechecking to ensure we only request the z-index of valid sibling names.