Build a priority+ navigation by measuring every item's width once, observing the nav container with ResizeObserver, and on each resize computing how many items fit (leaving room for the More button) with arithmetic on the cached widths — then hide the rest and mirror them in the menu, without re-measuring anything in the callback.

Problem / Scenario Context

A SaaS app's top navigation has twelve sections. On wide screens they all fit; on narrower ones the header wraps onto two lines and breaks the layout. The design calls for the priority+ pattern: show as many items as fit, in priority order, and put the rest in a "More" dropdown. The first implementation moves items into the menu one at a time inside a resize listener, re-checking scrollWidth after each move — which forces a layout per item, runs only on window resize (not when the sidebar collapses), and occasionally oscillates between two states at certain widths.

Priority+ is a textbook container-resize problem. The Element Resize Detection Patterns topic covers the approach; this page is the implementation.

Mechanics Explanation

Three facts make the naive version slow and unstable:

  • Measure-move-measure loops force layout. Moving an item and then reading scrollWidth requires the browser to lay out again — once per item moved.
  • Hiding an item changes the space available to others. Items typically sit in a flex row with gaps; the More button itself takes space only when something overflows. Deciding "does it fit?" by trying it makes the answer depend on the previous state, which is where oscillation comes from.
  • The container width changes for reasons other than the window. Sidebars, split panes and zoom all change it. Only an observer on the container sees every change.

The stable approach separates measurement from decision. Item widths do not depend on the container's width (their text does not change), so they can be measured once — at mount and whenever labels change — and cached. On each resize, the decision becomes pure arithmetic: accumulate widths plus gaps until the next item would exceed the available width, reserving the More button's width whenever at least one item will overflow.

Measure Once, Decide on Every ResizeFour boxes. At mount, every item is measured once and the widths and the More button width are cached. The ResizeObserver delivers the container's new width. Pure arithmetic over the cached widths decides how many items fit, reserving room for More when anything overflows. The result is applied by toggling hidden attributes, with no measurements in the callback.Measure onceitem widths, gap, MorewidthRO: container widthevery resize, any causeArithmetichow many fit, reserveMoreApplytoggle hidden, fill menu

Comparison Table: Implementation Approaches

Approach Layouts per resize Reacts to sidebar changes Stable at edges
resize listener, move one item at a time and re-measure 1 per moved item no no — can oscillate
RO, move items and re-measure 1 per moved item yes no
RO, cached widths + arithmetic 1 (the frame's own) yes yes
CSS-only wrapping into a second row 0 yes yes, but not priority+
CSS container queries hiding fixed items 0 yes yes, but breakpoints are guesses

Minimal Reproducible Example

TypeScript
function fit(nav: HTMLElement, more: HTMLElement): void {
  const items = [...nav.querySelectorAll<HTMLElement>('.nav-item')];
  items.forEach((i) => (i.hidden = false));
  while (nav.scrollWidth > nav.clientWidth && items.length) {   // forced layout per iteration
    items.pop()!.hidden = true;
  }
  more.hidden = !items.some((i) => i.hidden);
}
addEventListener('resize', () => fit(nav, more));

declare const nav: HTMLElement; declare const more: HTMLElement;

Collapse the app's sidebar: the nav gains 240 px but nothing changes until the window is resized.

Production-Safe Solution

TypeScript
interface PriorityNavOptions {
  nav: HTMLElement;              // the container with a fixed or flexible width
  items: HTMLElement[];          // in priority order: first = most important
  more: HTMLElement;             // the More button wrapper
  menu: HTMLElement;             // the dropdown list
}

export function priorityNav({ nav, items, more, menu }: PriorityNavOptions): () => void {
  let widths: number[] = [];
  let gap = 0;
  let moreWidth = 0;
  let lastVisible = -1;

  function measure(): void {
    // Show everything once to measure natural widths; this is the only layout-dependent step.
    items.forEach((i) => (i.hidden = false));
    more.hidden = false;
    widths = items.map((i) => i.getBoundingClientRect().width);
    moreWidth = more.getBoundingClientRect().width;
    gap = parseFloat(getComputedStyle(nav).columnGap) || 0;
    lastVisible = -1;
  }

  function decide(available: number): number {
    // Pass 1: does everything fit without a More button?
    const total = widths.reduce((sum, w, i) => sum + w + (i ? gap : 0), 0);
    if (total <= available) return widths.length;
    // Pass 2: something overflows, so reserve room for More and fit items into the rest.
    const budget = available - gap - moreWidth;
    let used = 0;
    for (let i = 0; i < widths.length; i++) {
      used += (i ? gap : 0) + widths[i];
      if (used > budget) return i;
    }
    return widths.length;
  }

  function apply(count: number): void {
    if (count === lastVisible) return;                        // nothing changed: no DOM writes
    lastVisible = count;
    items.forEach((item, i) => (item.hidden = i >= count));
    more.hidden = count === items.length;
    menu.replaceChildren(...items.slice(count).map((item) => {
      const li = document.createElement('li');
      const link = item.querySelector('a')!.cloneNode(true) as HTMLAnchorElement;
      li.append(link);
      return li;
    }));
  }

  const ro = new ResizeObserver(([e]) => apply(decide(e.contentBoxSize[0].inlineSize)));

  measure();
  ro.observe(nav);
  document.fonts?.ready.then(() => { measure(); apply(decide(nav.clientWidth)); });

  return () => ro.disconnect();
}

The decide function is pure: it depends only on the cached widths and the available width, so the same width always gives the same answer — no oscillation. The callback writes only when the number of visible items changes, and never reads layout, so the only layout per resize is the one the browser does anyway. Widths are re-measured after web fonts load, since font swaps change label widths.

The two passes in decide encode the one subtle rule of priority+: the More button only takes space when something overflows. Checking "does everything fit?" first, and only then fitting items into the width left after reserving More, avoids the classic off-by-one where the last item disappears into the menu even though, without the button, it would have fitted.

Items That Fit and Items in the More MenuA narrow navigation container with items shown in priority order until the available width minus the More button is used up. The first items are visible. The next item would exceed the budget, so it and every item after it move into the More menu. The More button takes the reserved space at the end of the row.items 1–5 visible, within budgetMore button, width reserveditems 6–12 in the More menuSolid blue frame: nav container.

Accessibility and Behaviour Details

  • Hidden items must be removed from the tab order and accessibility tree. The hidden attribute does both. Visually hiding with opacity or off-screen positioning leaves duplicate links for keyboard and screen-reader users.
  • The More button is a disclosure: a <button aria-expanded> controlling the menu, with the menu closing on Escape and on outside clicks.
  • The current page's link should stay visible if possible. Priority order can be adjusted at runtime by moving the active item's width to the front of the list, so the section you are in never disappears into the menu.
  • Keyboard focus inside the menu must not be lost when a resize moves an item between the bar and the menu. If the focused link is about to be hidden, move focus to the More button.

Keeping Focus Stable When an Item MovesFour steps. A resize decides that the item containing the focused link must move into the More menu. Before hiding it, the callback checks whether focus is inside it. If so, focus moves to the More button. The item is hidden and cloned into the menu, and the user can open the menu to reach it.1Resize moves item 6Decision says only five items fit.2Focus inside item 6?Check document.activeElement before hiding.3Move focus to MoreKeyboard users keep their place in the header.4Hide and mirrorItem 6 hidden in the bar, cloned into the menu.

Verification Steps

  • Drag the window and collapse the sidebar; the nav should update in both cases, one item at a time.
  • Hold the width exactly at a boundary and confirm there is no flicker between two states.
  • Record a trace while resizing; the callback should contain no Layout or Recalculate Style.
  • Tab through the nav at a narrow width and confirm hidden items are skipped and the More button works.
  • Switch languages with longer labels and confirm widths are re-measured.

Common Mistakes to Avoid

  • Re-measuring inside the resize callback. It forces layout and invites loops.
  • Using window.resize. Container changes from sidebars and panes are missed.
  • Forgetting the More button's width. The last visible item then overlaps it.
  • Hiding items visually but not semantically. Keyboard and screen-reader users get duplicate links.

FAQ

Why cache item widths instead of measuring on each resize?

Item widths depend on their labels, not on the container's width, so they do not change when the container resizes. Measuring once turns every resize into arithmetic with no layout cost.

What if labels change at runtime?

Call measure() again after the change, then apply the decision. Label changes are rare events, so an occasional re-measure is cheap.

Can CSS container queries do priority+?

Only approximately: container queries can hide specific items below fixed breakpoints, but they cannot compute how many variable-width items fit. For a fixed set of items with known widths, breakpoints are a reasonable CSS-only approximation.

Why does my nav flicker between two states at some widths?

Because the decision depends on the current state, typically by measuring after hiding items. A pure decision based on cached widths always gives the same answer for the same width.

Should the More menu contain clones or the original elements?

Clones of the links are simplest and keep the bar's structure stable. If items contain interactive widgets with state, move the originals instead and restore them when space returns.

How do I keep the current section visible?

Reorder the priority list so the active item comes first in the fitting calculation, while keeping visual order unchanged by setting the flex order property on the items.


↑ Back to Element Resize Detection Patterns