Responsive grid

Keyboard navigation for a responsive CSS grid, with the column count read from the layout on every keypress so breakpoints stay in CSS.

Set data-keyrove-cols="auto" on the grid container to read its column count from CSS on every keypress. Navigation then follows layout changes without copying breakpoints into JavaScript.

This demo uses a container query. Drag the corner of the dashed frame or narrow the window, then try the arrows. With six columns, ↓ moves from January to July; with two, it moves to March.

Live · month picker
<div id="month-picker">
  <style>
    #month-picker {
      container-type: inline-size;
    }

    #months {
      --cols: 2;
      display: grid;
      grid-template-columns: repeat(var(--cols), minmax(0, 1fr));
      gap: 0.375rem;
    }

    @container (min-width: 20rem) {
      #months {
        --cols: 3;
      }
    }

    @container (min-width: 28rem) {
      #months {
        --cols: 4;
      }
    }

    @container (min-width: 36rem) {
      #months {
        --cols: 6;
      }
    }
  </style>

  <div id="months" data-keyrove-root data-keyrove-cols="auto">
    <button data-keyrove-item tabindex="0">January</button>
    <button data-keyrove-item tabindex="0">February</button>
    <button data-keyrove-item tabindex="0">March</button>
    <!-- … -->
    <button data-keyrove-item tabindex="0">December</button>
  </div>
</div>

Or, where the markup is not yours, as an option:

keyRove(e, { items: '.month', cols: 'auto' });

The demo's grid carries data-keyrove-root only because the site's listener sits on the panel around it. With the listener on the grid itself, as above, the attribute is not needed.

Counted on every keypress

auto reads the root's computed grid-template-columns during each call. No resize listener or observer is needed to keep the count current.

The stylesheet owns the breakpoints

The panel declares itself a container with container-type: inline-size; the grid inside sets --cols and lays itself out with grid-template-columns: repeat(var(--cols), minmax(0, 1fr)); each @container rule changes the property and nothing else. Add a breakpoint, or move one, and navigation follows without a JavaScript edit.

The query measures the panel's width, so the grid also adapts when placed in a sidebar. The panel is the query container because an element cannot query its own size.

When the count is implicit

A grid built on repeat(auto-fill, minmax(8rem, 1fr)) never states a count; the browser decides how many tracks fit. auto needs nothing more for it: the computed value lists the tracks the browser made, so they are counted the same way.

What auto counts

  • The root must be the grid container, with one item per cell in DOM order. Each item must span one track. Spanning items, subgrids and row wrappers do not match this navigation model, including when items selects descendants.
  • Named lines, such as [full-start], are not counted as columns.
  • If the computed value does not resolve to a list of pixel track sizes, the count falls back to one and the group navigates as a list. This includes a flex container or a grid that is not laid out.

Layouts that are not grids

A flex-wrap row has no track list to count, so auto sees one column. Copy the count in yourself instead, right before the call. Here the stylesheet publishes it as --cols:

months.addEventListener('keydown', (e) => {
  months.setAttribute(
    'data-keyrove-cols',
    getComputedStyle(months).getPropertyValue('--cols'),
  );
  keyRove(e);
});

keyrove reads the attribute fresh on every keypress, so refreshing it in the same handler is all the synchronization this needs. If something else needs the attribute between keypresses, such as a test asserting on it, a ResizeObserver on the container can make the same copy whenever the size changes.

Search documentation

↑ ↓ to selectEnter to openEsc to close