DEV Community

Cover image for Keep Dynamic Icon Groups from Breaking Your List Layout
Svg/icons
Svg/icons

Posted on

Keep Dynamic Icon Groups from Breaking Your List Layout

A list looks tidy when every row starts with one icon. Then one item gets three icons, another gets five, and the labels stop lining up.

The tempting fix is to adjust the gap. That works for one row and breaks another. The gap was consistent already; the space before it was changing.

Give every row the same leading slot, then put a limit on what can occupy it. In this React example, that means a shared CSS Grid column, a bounded icon stack, and an explicit rule for overflow.

Animated illustration of icon group alignment before and after using a fixed-width slot

Why the labels drift

Suppose a row contains an icon group followed by its label:

<li className="source-row">
  <IconGroup members={item.members} />
  <div className="row-content">{item.label}</div>
</li>
Enter fullscreen mode Exit fullscreen mode

With a content-sized group in a flex row, the label follows the group's width. One badge takes less space than three overlapping badges. Even with identical icon sizes and gaps, the labels start at different positions.

That is reasonable flex behavior. It becomes a problem when the design expects a common label column across independent rows.

A concrete instance appears in Magpie issue #2498: model rows have a single provider icon, while routing groups have stacked icons. The issue describes reserving a common leading width. This tutorial uses that reported problem as a starting point, with an independent teaching implementation.

Define the stack policy before choosing the width

A fixed column alone does not stop its children from overflowing. Decide how many positions the component can display first.

For this example:

Number of sources Visible content
0 Empty slot, with the label still aligned
1–3 All source icons
4 or more First two icons and a counter for the remaining sources

Five sources therefore render as two icons and +3. The counter occupies the third position; it is not appended as a fourth item.

Use 28px badges, with each overlapping badge starting 16px after the previous one. Three positions occupy:

28 + (3 - 1) × 16 = 60px
Enter fullscreen mode Exit fullscreen mode

A 64px slot leaves 4px of spare space. With a 16px root font size, those dimensions are 1.75rem, 1rem, and 4rem.

Choose a width from the actual badge geometry and overflow policy. A 3rem slot, for example, would be too small for this particular stack.

Build the React component

The example uses three Tabler SVGs retrieved through the SVGicons catalogue:

They are by Paweł Kuna and distributed under the MIT licence. Keep the copyright and licence notice with redistributed assets.

Save them in your application's public directory so they are available at /icons/folder.svg, /icons/bell.svg, and /icons/file-settings.svg.

The layout does not require a particular icon library. Using local image files keeps SVG markup out of the component's layout logic.

function IconGroup({ members }) {
  const visible = members.length > 3 ? members.slice(0, 2) : members;
  const hidden = members.length - visible.length;

  return (
    <span className="icon-slot" aria-hidden="true">
      <span className="icon-stack">
        {visible.map((member, index) => (
          <span
            className="badge"
            key={member.id}
            style={{ '--index': index }}
          >
            <img src={member.src} alt="" width="18" height="18" />
          </span>
        ))}

        {hidden > 0 && (
          <span className="badge count" style={{ '--index': 2 }}>
            {hidden > 99 ? '99+' : `+${hidden}`}
          </span>
        )}
      </span>
    </span>
  );
}

function SourceList({ items }) {
  return (
    <ul className="source-list">
      {items.map((item) => (
        <li className="source-row" key={item.id}>
          <IconGroup members={item.members} />

          <div className="row-content">
            <span className="row-label">{item.label}</span>
            <span className="sr-only">
              {item.members.length
                ? `. Sources: ${item.members.map((m) => m.name).join(', ')}`
                : '. No connected sources'}
            </span>
          </div>
        </li>
      ))}
    </ul>
  );
}
Enter fullscreen mode Exit fullscreen mode

Each member needs a stable, unique id, a readable name, and a trusted local src. For example:

const files = {
  id: 'files',
  name: 'Files',
  src: '/icons/folder.svg',
};

const alerts = {
  id: 'alerts',
  name: 'Alerts',
  src: '/icons/bell.svg',
};

const config = {
  id: 'config',
  name: 'Configuration',
  src: '/icons/file-settings.svg',
};

const archive = {
  id: 'archive',
  name: 'Archive',
  src: '/icons/folder.svg',
};

const monitoring = {
  id: 'monitoring',
  name: 'Monitoring',
  src: '/icons/bell.svg',
};

const items = [
  {
    id: 'documents',
    label: 'Documents',
    members: [files],
  },
  {
    id: 'activity',
    label: 'Project activity',
    members: [files, alerts],
  },
  {
    id: 'overview',
    label: 'Workspace overview',
    members: [files, alerts, config],
  },
  {
    id: 'operations',
    label: 'Operations across all connected workspaces',
    members: [files, alerts, config, archive, monitoring],
  },
  {
    id: 'empty',
    label: 'No connected sources yet',
    members: [],
  },
];

export default function App() {
  return <SourceList items={items} />;
}
Enter fullscreen mode Exit fullscreen mode

The source names are exposed once as text. The overlapping images and counter are hidden from assistive technology, avoiding a second announcement of the same source information.

This approach suits a static list. If users need to inspect or select individual sources, provide a visible details view with equivalent keyboard access. A tiny overlapped badge should not be the only way to reach an action.

Localize the source sentence and counter formatting for your product.

Reserve the column and contain the stack

Apply these styles:

* {
  box-sizing: border-box;
}

.source-list {
  --badge-size: 1.75rem;
  --stack-step: 1rem;
  --icon-slot: 4rem;

  list-style: none;
  margin: 0;
  padding: 0;
}

.source-row {
  display: grid;
  grid-template-columns: var(--icon-slot) minmax(0, 1fr);
  column-gap: .75rem;
  align-items: start;

  padding-block: 1rem;
  border-block-start: 1px solid #e3e8f1;
}

.icon-slot {
  min-inline-size: 0;
  inline-size: var(--icon-slot);
}

.icon-stack {
  display: block;
  position: relative;
  block-size: var(--badge-size);
}

.badge {
  position: absolute;
  inset-inline-start: calc(var(--index) * var(--stack-step));
  inset-block-start: 0;

  inline-size: var(--badge-size);
  block-size: var(--badge-size);

  display: grid;
  place-items: center;

  background: #dce8ff;
  border: 2px solid white;
  border-radius: 50%;
}

.badge:nth-child(2) {
  background: #dcf5ec;
}

.badge:nth-child(3) {
  background: #efe3ff;
}

.badge img {
  display: block;
  inline-size: 1.125rem;
  block-size: 1.125rem;
}

.count {
  font-size: .625rem;
  font-weight: 800;
  color: #17233b;
}

.row-content {
  min-inline-size: 0;
}

.row-label {
  display: block;
  overflow-wrap: anywhere;
}

.sr-only {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
Enter fullscreen mode Exit fullscreen mode

There are two separate constraints here:

  • The shared --icon-slot keeps the label position consistent across rows.
  • The component's three-position limit keeps the stack within that slot.

An empty group keeps its wrapper, so zero icons does not become another alignment exception.

The absolutely positioned badges need an explicit stack height because they do not contribute to their parent's normal flow height. Later badges paint over earlier ones in this simple stack.

The label track uses minmax(0, 1fr) so its minimum track size does not default to a content-based minimum. min-inline-size: 0 lets the content wrapper shrink, while overflow-wrap: anywhere allows long unbroken identifiers to wrap.

These choices address different parts of overflow handling. Replacing the icon column alone will not fix every long-text case. See MDN's references for grid track sizing and minimum widths.

If labels must stay on one line, use a deliberate truncation design instead. Make the full text available through an accessible details interaction; do not rely only on a mouse tooltip.

Keep the same rule on narrow screens and in RTL

As the container narrows, the fixed slot stays put and the text wraps in the remaining track. Top alignment keeps a multi-line label beside the top of its icon stack.

If a compact design needs a smaller slot, change its geometry for the entire list. For example, 24px badges with a 12px step occupy 48px across three positions:

.source-list.compact {
  --badge-size: 1.5rem;
  --stack-step: .75rem;
  --icon-slot: 3rem;
}
Enter fullscreen mode Exit fullscreen mode

Never make this choice independently for each row, or the common column disappears again. Check that the counter remains readable in the smaller badges.

For right-to-left layout, place dir="rtl" on the list or an ancestor. Grid's inline direction places the leading column on the right. inset-inline-start moves the badge stack from that same side.

<div dir="rtl">
  <SourceList items={items} />
</div>
Enter fullscreen mode Exit fullscreen mode

Use real translated labels when reviewing the result. Changing direction on English text checks the layout rule, but does not verify Arabic or Hebrew typography, punctuation, or mixed-direction identifiers.

Test the relationship that matters

A screenshot can catch an obvious regression. A geometry assertion can also express the actual requirement: every label container has the same leading edge.

Here is a Playwright test for a page containing one .source-list. Serve the component on the / route and configure Playwright's baseURL to point to your development server.

import { test, expect } from '@playwright/test';

for (const width of [420, 1100]) {
  for (const direction of ['ltr', 'rtl']) {
    test(`label alignment: ${width}px ${direction}`, async ({ page }) => {
      await page.setViewportSize({
        width,
        height: 1000,
      });

      await page.goto('/');

      await page.locator('.source-list').evaluate((list, dir) => {
        list.dir = dir;
      }, direction);

      const edges = await page.locator('.row-content').evaluateAll(
        (nodes, dir) => nodes.map((node) => {
          const rect = node.getBoundingClientRect();
          return dir === 'rtl' ? rect.right : rect.left;
        }),
        direction
      );

      expect(edges.length).toBeGreaterThan(1);

      expect(
        Math.max(...edges) - Math.min(...edges)
      ).toBeLessThan(1);
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Also check that every badge stays within its slot, images have loaded, and the document has no horizontal overflow. A component can keep its labels aligned while its icons still collide with them.

Use fixtures with:

  • Zero, one, two, three and five sources.
  • A very large source count.
  • A multi-line label.
  • A long unbroken identifier.
  • Larger text.
  • Both LTR and RTL directions.

Check keyboard navigation if the rows contain links or buttons. Keep screenshot baselines only after reviewing the initial renders, and rerun the checks in your supported browser engines.

The useful invariant is simple: source count may change the content of the icon slot, but it must not change where the label begins. Once that rule is explicit, new data has much less room to rearrange the list.

Top comments (0)