Tags-group

Unlimited (no cap)

React TypeScript CSS Vite Vitest HTML

Capped to 2 rows

React TypeScript CSS Vite Vitest HTML Node.js PostgreSQL Docker Kubernetes GraphQL Webpack

Capped to 1 row

Featured New Updated Beta Alpha Internal Experimental

  
/*
 * Tags-group — element (display)
 *
 * Horizontal/wrapping collection of Tags with runtime-driven overflow:
 * authors specify data-max-rows, the runtime measures wrapped layout
 * and hides tags beyond the row count, appending a "+N more" Tag at
 * the end.
 *
 *   <div class="tags-group" data-max-rows="2">
 *     <span class="tag">React</span>
 *     <span class="tag">TypeScript</span>
 *     ... many tags ...
 *   </div>
 *
 * Without data-max-rows the group simply wraps without trimming.
 */

@layer malevich.components {
  .tags-group {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-gap-elements-s);
  }

  /* While the runtime is measuring, keep contents in DOM but visually
   * accurate. Hidden overflow tags get a data-tags-hidden marker.
   */
  .tags-group [data-tags-hidden="true"] {
    display: none;
  }

  /* The +N overflow tag is appended by the runtime; consumers can also
   * write it manually for the static case.
   */
  .tags-group__overflow {
    /* Inherits .tag styles when also given the .tag class; this rule
     * is a no-op placeholder for authors who want extra targeting.
     */
  }
}
// Tags-group — runtime overflow.
//
// When the host carries data-max-rows="N", measure wrapped layout and
// hide tags whose offsetTop puts them beyond row N. Append (or reuse)
// a .tag.-muted "+K more" indicator with class .tags-group__overflow.
//
// Re-runs on ResizeObserver so layout follows container resizes.

const READY_ATTR = "data-malevich-ready";
const HIDDEN_ATTR = "data-tags-hidden";
const OVERFLOW_CLASS = "tags-group__overflow";

interface State {
  observer?: ResizeObserver;
}

const stateMap = new WeakMap<HTMLElement, State>();

function getMaxRows(host: HTMLElement): number | null {
  const raw = host.getAttribute("data-max-rows");
  if (!raw) return null;
  const n = parseInt(raw, 10);
  return Number.isFinite(n) && n > 0 ? n : null;
}

function tagSelector(): string {
  return `.tag:not(.${OVERFLOW_CLASS})`;
}

function reset(host: HTMLElement): void {
  for (const tag of host.querySelectorAll<HTMLElement>(`[${HIDDEN_ATTR}]`)) {
    tag.removeAttribute(HIDDEN_ATTR);
  }
  const overflow = host.querySelector<HTMLElement>(`.${OVERFLOW_CLASS}`);
  if (overflow && overflow.dataset.autogen === "true") overflow.remove();
}

function layout(host: HTMLElement): void {
  const maxRows = getMaxRows(host);
  reset(host);
  if (maxRows === null) return;

  const tags = Array.from(host.querySelectorAll<HTMLElement>(tagSelector()));
  if (tags.length === 0) return;

  // Group tags by row using offsetTop. The first row's top becomes the
  // reference; subsequent unique tops are additional rows.
  const rowTops: number[] = [];
  const tagRows: number[] = tags.map((t) => {
    const top = t.offsetTop;
    let idx = rowTops.indexOf(top);
    if (idx === -1) {
      rowTops.push(top);
      rowTops.sort((a, b) => a - b);
      idx = rowTops.indexOf(top);
    }
    return idx;
  });

  if (rowTops.length <= maxRows) return;

  // Find the first tag that lives beyond maxRows-1.
  const firstHiddenIdx = tagRows.findIndex((r) => r >= maxRows);
  if (firstHiddenIdx === -1) return;

  // Append (or reuse) the overflow tag and try fitting it on the last
  // visible row. We need to retry: adding the overflow tag may push
  // the last visible tag onto a new row.
  let overflow = host.querySelector<HTMLElement>(`.${OVERFLOW_CLASS}`);
  if (!overflow) {
    overflow = document.createElement("span");
    overflow.className = `tag -muted ${OVERFLOW_CLASS}`;
    overflow.dataset.autogen = "true";
    overflow.setAttribute("aria-label", "");
    host.appendChild(overflow);
  }

  // First pass: hide everything from firstHiddenIdx onwards, count.
  let cut = firstHiddenIdx;
  while (cut > 0) {
    for (let i = 0; i < tags.length; i++) {
      if (i >= cut) tags[i].setAttribute(HIDDEN_ATTR, "true");
      else tags[i].removeAttribute(HIDDEN_ATTR);
    }
    overflow.textContent = `+${tags.length - cut} more`;
    overflow.setAttribute("aria-label", `and ${tags.length - cut} more`);

    // Did adding overflow push past row count?
    const overflowRowTop = overflow.offsetTop;
    if (rowTops.indexOf(overflowRowTop) === -1) {
      rowTops.push(overflowRowTop);
      rowTops.sort((a, b) => a - b);
    }
    const overflowRowIdx = rowTops.indexOf(overflowRowTop);
    if (overflowRowIdx < maxRows) {
      return;
    }
    cut -= 1;
  }

  // Fallback: cut hides everything. Should not happen with sensible
  // inputs but keeps behavior stable.
  for (const t of tags) t.setAttribute(HIDDEN_ATTR, "true");
  overflow.textContent = `+${tags.length} more`;
}

/**
 * Initialize tags-group overflow on a single host.
 */
export function initTagsGroup(host: HTMLElement): void {
  if (host.getAttribute(READY_ATTR) === "true") return;

  layout(host);

  if (typeof ResizeObserver !== "undefined") {
    const observer = new ResizeObserver(() => layout(host));
    observer.observe(host);
    stateMap.set(host, { observer });
  }

  host.setAttribute(READY_ATTR, "true");
}

export function initTagsGroups(root: ParentNode = document): void {
  for (const host of root.querySelectorAll<HTMLElement>(".tags-group[data-max-rows]")) {
    initTagsGroup(host);
  }
}
# Tags-Group — AGENTS.md

> Auto-generated from `tags-group.docs.md` + the modifier applicability matrix.
> Edit those source files; this file regenerates on build.

## Identity

- **Component:** `tags-group`
- **Layer:** elements
- **Status:** stable
- **Last updated:** 2026-05-19

## Summary

- Skill stacks, applied filters, label lists where space is bounded.

## Variants

| Variant | Class                          | Use for                              |
|---------|--------------------------------|--------------------------------------|
| No-cap  | `.tags-group`                  | All tags visible, wraps naturally    |
| Capped  | `.tags-group[data-max-rows="N"]` | Hide tags past row N, show +K more |

## Modifier applicability

Per ADR-0007. Modifiers attach via `data-{category}="value"` attributes.

| Category | Accepts |
|---|---|
| `background` | — |
| `surface` | — |
| `effect` | — |
| `shader` | — |
| `rhythm` | `compact`, `regular` |
| `motion` | — |
| `behavior` | — |

## Anatomy

```html
<!-- Unlimited wrap -->
<div class="tags-group">
  <span class="tag -muted">React</span>
  <span class="tag -muted">TypeScript</span>
  <span class="tag -muted">CSS</span>
</div>

<!-- Trim to 2 rows -->
<div class="tags-group" data-max-rows="2">
  <span class="tag -muted">React</span>
  <span class="tag -muted">TypeScript</span>
  <span class="tag -muted">CSS</span>
  <span class="tag -muted">Vite</span>
  <span class="tag -muted">Vitest</span>
  <!-- … many more … -->
</div>
```

The runtime (`initTagsGroup`, auto-registered) measures wrapped
layout, hides tags whose row index ≥ N, and appends or updates a
`.tag.-muted.tags-group__overflow` element with text `+K more`. On
container resize (via `ResizeObserver`), it re-runs.

For environments without JS, the cap is a no-op and all tags remain
visible — the page still works.

## Tokens consumed

- `--space-gap-elements-s` — gap between tags

Tag styling is inherited from the `Tag` component.

## Accessibility contract

- The overflow tag has `aria-label="and K more"` so screen readers
  announce the truncated count.
- Hidden tags use `display: none` (via `data-tags-hidden="true"`), so
  they are removed from the accessibility tree. Their content is not
  read.
- For full-list access, pair the group with a Show all button or
  Popover that reveals the hidden tags.

## Guidance

### Do

- Set `data-max-rows` only when you have a bounded space.
- Provide an alternative way to see the full list when capping
  (button → Popover with all tags, or Sheet on small screens).

### Don't

- Don't manually count tags to truncate. Let the runtime measure.
- Don't apply CSS `max-height` to the group — overflow is handled by
  the runtime, not CSS clipping.