Layout
Overflow list
A single row that never wraps: items that do not fit hide behind a counter that lists them in a popover.
- Built on
Popover APIpopovertargetResizeObserverCSS anchor positioning- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 4.4 kB
- Dependencies
- React
- bug
- frontend
- p1
- needs-repro
- billing
- safari
Code
"use client";
import { OverflowItem, OverflowList } from "@/components/equip/overflow-list";
const chip = {
boxSizing: "border-box",
height: 20,
padding: "0 7px",
border: "1px solid var(--eq-line)",
borderRadius: "var(--eq-radius-s)",
background: "var(--eq-tint)",
font: "var(--eq-weight-strong) var(--eq-text-xs) / 18px var(--eq-font)",
whiteSpace: "nowrap",
} as const;
const labels = ["bug", "frontend", "p1", "needs-repro", "billing", "safari"];
export default function Demo() {
return (
<div style={{ width: 200 }}>
<OverflowList label="Labels" moreLabel={(n) => `${n} more labels`}>
{labels.map((name) => (
<OverflowItem key={name}>
<span style={chip}>{name}</span>
</OverflowItem>
))}
</OverflowList>
</div>
);
}Examples
Drag to resize
Drag the corner: items leave from the end and the count follows, without the row ever wrapping.
Code
"use client";
import { OverflowItem, OverflowList } from "@/components/equip/overflow-list";
const chip = {
boxSizing: "border-box",
height: 20,
padding: "0 7px",
border: "1px solid var(--eq-line)",
borderRadius: "var(--eq-radius-s)",
background: "var(--eq-tint)",
font: "var(--eq-weight-strong) var(--eq-text-xs) / 18px var(--eq-font)",
whiteSpace: "nowrap",
} as const;
const services = [
"checkout-api",
"search-indexer",
"billing-worker",
"auth-gateway",
"notifications",
"image-resizer",
"audit-log",
"feature-flags",
];
export default function Example() {
return (
<div
style={{
width: "min(340px, 100%)",
minWidth: 80,
maxWidth: "100%",
padding: 12,
resize: "horizontal",
overflow: "hidden",
border: "1px solid var(--eq-line-strong)",
borderRadius: "var(--eq-radius-m)",
}}
>
<OverflowList label="Affected services" moreLabel={(n) => `${n} more services`}>
{services.map((name) => (
<OverflowItem key={name}>
<span style={chip}>{name}</span>
</OverflowItem>
))}
</OverflowList>
</div>
);
}Plain text items
Items can be anything; here the separator lives inside each item, so a hidden name takes its comma with it.
Reviewers
- Priya Raman,
- Tomás Ferreira,
- Ingrid Holm,
- Kwame Mensah,
- Aiko Tanaka
Code
"use client";
import { OverflowItem, OverflowList } from "@/components/equip/overflow-list";
const reviewers = ["Priya Raman", "Tomás Ferreira", "Ingrid Holm", "Kwame Mensah", "Aiko Tanaka"];
export default function Example() {
return (
<div style={{ display: "grid", gridTemplateColumns: "auto minmax(0, 220px)", alignItems: "center", gap: 12, fontSize: 13 }}>
<span style={{ color: "var(--eq-ink-2)" }}>Reviewers</span>
<OverflowList label="Reviewers" moreLabel={(n) => `${n} more reviewers`}>
{reviewers.map((name, index) => (
<OverflowItem key={name}>
{name}
{index < reviewers.length - 1 ? "," : ""}
</OverflowItem>
))}
</OverflowList>
</div>
);
}When everything fits
With room to spare the counter is hidden from sight and from assistive tech, and the list is just a list.
- docs
- good first issue
Code
import { OverflowItem, OverflowList } from "@/components/equip/overflow-list";
const chip = {
boxSizing: "border-box",
height: 20,
padding: "0 7px",
border: "1px solid var(--eq-line)",
borderRadius: "var(--eq-radius-s)",
background: "var(--eq-tint)",
font: "var(--eq-weight-strong) var(--eq-text-xs) / 18px var(--eq-font)",
whiteSpace: "nowrap",
} as const;
export default function Example() {
return (
<div style={{ width: "100%", maxWidth: 320 }}>
<OverflowList label="Labels">
<OverflowItem>
<span style={chip}>docs</span>
</OverflowItem>
<OverflowItem>
<span style={chip}>good first issue</span>
</OverflowItem>
</OverflowList>
</div>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
children | ReactNode | none | OverflowItem elements in priority order; the last ones are the first to hide. |
label | string | none | Accessible name of the list, such as Labels or Reviewers. |
moreLabel | (n: number) => string | (n) => `${n} more` | Accessible name of the counter and of the list it opens. Passing it needs a client component. |
...rest | Omit<ComponentProps<"ul">, "children"> | none | Every native ul attribute, including id and ref. |
OverflowItem ...rest | ComponentProps<"li"> | none | Every native li attribute. Its children are what shows in the row and in the popover. |
Anatomy
| root | .eq-overflow-list | A ul with role list, laid out as one row that clips sideways. |
|---|---|---|
| item | .eq-overflow-list-item | An li. Carries data-overflow when it does not fit. |
| more | .eq-overflow-list-more | The last li, holding the counter and its popover. |
| counter | .eq-overflow-list-counter | A button showing +N that opens the popover. |
| popover | .eq-overflow-list-popover | The floating surface, in the top layer. |
| menu | .eq-overflow-list-menu | The list of hidden items inside the popover. |
Keyboard
| Tab | Moves to the counter, after any focusable content in the visible items. |
|---|---|
| EnterSpace | Opens or closes the list of hidden items. |
| Escape | Closes the list and returns focus to the counter when focus was inside it. |
Notes
- Hidden items leave the row and the accessibility tree; the counter names how many there are, such as 3 more labels.
- The popover holds copies of the hidden items, so content with ids or its own state should not be put in an item.
- The server and the first frame show every item clipped at the edge; the count appears once the row has been measured, before paint.
- The row takes the width of its container and recounts when that width or an item's size changes; inside a flex row give it flex: 1 and min-width: 0, or it shrinks to its items and hides more than it needs to.
- Give items a sensible order: the first ones stay visible longest.
- CSS anchor positioning places and flips the popover under the counter where the engine has anchor-name, position-area and position-try-fallbacks (Chromium 125, Safari 26 and Firefox 147 on). Elsewhere a small effect sets fixed coordinates from the counter's box on open and follows scroll and resize while open. The exit fade needs transition of overlay, which only Chromium has; elsewhere it closes at once.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/overflow-list.tsx221 lines
"use client";
import {
Children,
cloneElement,
isValidElement,
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
type ComponentProps,
type ReactElement,
type ReactNode,
type Ref,
type ToggleEvent,
} from "react";
import "./overflow-list.css";
export type OverflowListProps = Omit<ComponentProps<"ul">, "children"> & {
/** OverflowItem elements, in priority order: the first ones stay visible longest. */
children: ReactNode;
/** Accessible name of the list, such as Labels or Reviewers. */
label: string;
/** Accessible name of the counter and of the list it opens, for n hidden items. */
moreLabel?: (n: number) => string;
};
/* Must match the @supports condition in overflow-list.css exactly. */
const ANCHOR_SUPPORT =
"(anchor-name: --a) and (position-area: block-end) and (position-try-fallbacks: flip-block)";
function anchorSupported() {
return typeof CSS !== "undefined" && CSS.supports(ANCHOR_SUPPORT);
}
/** Fallback for engines without anchor positioning: fixed coordinates from the counter's box. */
function place(trigger: Element, surface: HTMLElement) {
const box = trigger.getBoundingClientRect();
const viewWidth = document.documentElement.clientWidth;
const viewHeight = document.documentElement.clientHeight;
const gap = parseFloat(getComputedStyle(surface).marginTop) || 0;
let top = false;
// The counter sits at the end of the row, so the list lines up with its end edge.
let end = true;
const height = surface.offsetHeight + gap;
const width = surface.offsetWidth;
if (surface.offsetHeight > 0) {
if (box.bottom + height > viewHeight && box.top - height >= 0) top = true;
if (box.right - width < 0 && box.left + width <= viewWidth) end = false;
}
const style = surface.style;
style.top = top ? "auto" : `${box.bottom}px`;
style.bottom = top ? `${viewHeight - box.top}px` : "auto";
style.left = end ? "auto" : `${box.left}px`;
style.right = end ? `${viewWidth - box.right}px` : "auto";
}
function setRefs<T>(node: T, ...refs: (Ref<T> | undefined)[]) {
for (const ref of refs) {
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
}
}
/** Half a pixel of slack absorbs subpixel rounding between widths. */
const SLACK = 0.5;
export function OverflowList({
children,
label,
moreLabel = (n) => `${n} more`,
className,
ref,
...rest
}: OverflowListProps) {
const auto = useId();
const popoverId = `${auto}more`;
const anchorName = `--eq-overflow-list-${auto.replace(/[^\w-]/g, "")}`;
const listRef = useRef<HTMLUListElement | null>(null);
const moreRef = useRef<HTMLLIElement | null>(null);
const popoverRef = useRef<HTMLDivElement | null>(null);
const counterRef = useRef<HTMLButtonElement | null>(null);
const mergedRef = useCallback((node: HTMLUListElement | null) => setRefs(node, listRef, ref), [ref]);
const [open, setOpen] = useState(false);
// null until measured: the server and the first client frame show every item, clipped.
const [fit, setFit] = useState<number | null>(null);
const items = Children.toArray(children).filter(isValidElement) as ReactElement<ComponentProps<"li">>[];
const shown = fit === null ? items.length : Math.min(fit, items.length);
const hidden = items.length - shown;
const measure = useCallback(() => {
const list = listRef.current;
const more = moreRef.current;
if (!list || !more) return;
// Hidden items stay in the DOM out of flow at their natural width, so they can be measured.
const rows = Array.from(list.children).filter((el) => el !== more);
const widths = rows.map((el) => el.getBoundingClientRect().width);
const style = getComputedStyle(list);
const gap = parseFloat(style.columnGap) || 0;
const available =
list.getBoundingClientRect().width -
(parseFloat(style.paddingInlineStart) || 0) -
(parseFloat(style.paddingInlineEnd) || 0);
let next = widths.length;
const total = widths.reduce((sum, w) => sum + w, 0) + gap * Math.max(0, widths.length - 1);
if (total > available + SLACK) {
let used = more.getBoundingClientRect().width;
next = 0;
for (const width of widths) {
used += width + gap;
if (used > available + SLACK) break;
next++;
}
}
setFit((current) => (current === next ? current : next));
}, []);
// Every commit: the counter's width depends on its number, so a change can need a second pass.
useLayoutEffect(() => {
measure();
});
// The row's own size and each item's size (fonts arriving, content changing) trigger a remeasure.
useEffect(() => {
const list = listRef.current;
if (!list || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => measure());
observer.observe(list);
for (const child of Array.from(list.children)) observer.observe(child);
return () => observer.disconnect();
}, [measure, items.length]);
// Nothing left to list: close the surface rather than show it empty.
useEffect(() => {
const surface = popoverRef.current;
if (hidden === 0 && surface?.matches(":popover-open")) surface.hidePopover();
}, [hidden]);
// Fallback positioning follows the counter while the page scrolls.
useEffect(() => {
if (!open || anchorSupported()) return;
function update() {
if (counterRef.current && popoverRef.current) place(counterRef.current, popoverRef.current);
}
window.addEventListener("scroll", update, true);
window.addEventListener("resize", update);
return () => {
window.removeEventListener("scroll", update, true);
window.removeEventListener("resize", update);
};
}, [open]);
function handleBeforeToggle(event: ToggleEvent<HTMLDivElement>) {
if (event.newState === "open" && counterRef.current && !anchorSupported()) {
place(counterRef.current, event.currentTarget);
}
}
function handleToggle(event: ToggleEvent<HTMLDivElement>) {
const isOpen = event.newState === "open";
setOpen(isOpen);
// Second pass now that the surface has a size, so the fallback can flip.
if (isOpen && counterRef.current && !anchorSupported()) place(counterRef.current, event.currentTarget);
}
const name = moreLabel(hidden);
return (
<ul
role="list"
aria-label={label}
{...rest}
ref={mergedRef}
className={["eq-overflow-list", className].filter(Boolean).join(" ")}
>
{items.map((item, index) =>
index < shown ? item : cloneElement(item, { "aria-hidden": true, "data-overflow": "" } as ComponentProps<"li">),
)}
<li ref={moreRef} className="eq-overflow-list-more" data-empty={hidden === 0 || undefined}>
<button
ref={counterRef}
type="button"
className="eq-overflow-list-counter"
style={{ anchorName }}
popoverTarget={popoverId}
aria-label={name}
aria-expanded={open}
aria-controls={popoverId}
>
{/* Before measuring it holds the widest count it could need. */}
{`+${fit === null ? items.length : hidden}`}
</button>
<div
ref={popoverRef}
id={popoverId}
popover="auto"
className="eq-overflow-list-popover"
style={{ positionAnchor: anchorName }}
onBeforeToggle={handleBeforeToggle}
onToggle={handleToggle}
>
<ul role="list" className="eq-overflow-list-menu" aria-label={name}>
{items.slice(shown).map((item, index) => cloneElement(item, { key: `more-${index}` }))}
</ul>
</div>
</li>
</ul>
);
}
export type OverflowItemProps = ComponentProps<"li">;
export function OverflowItem({ className, ...rest }: OverflowItemProps) {
return <li {...rest} className={["eq-overflow-list-item", className].filter(Boolean).join(" ")} />;
}components/equip/overflow-list.css190 lines
@layer equip {
/* A block-level row: in normal flow its width comes from its container, not its items, so
hiding items does not change the width that decided to hide them. As a flex item it needs
flex: 1 from the caller for the same reason. */
.eq-overflow-list {
position: relative;
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: var(--eq-space-1);
min-width: 0;
margin: 0;
padding: 0;
list-style: none;
/* Clips only sideways, so focus rings above and below stay whole. Before the first
measurement (and on the server) every item is shown and the excess is cut off here. */
overflow-x: clip;
overflow-clip-margin: var(--eq-space-1);
}
.eq-overflow-list-item {
display: flex;
flex: none;
align-items: center;
min-width: 0;
}
/* An item that does not fit leaves the row and the accessibility tree, but keeps its natural
width out of flow so the row can measure it again when it grows. */
.eq-overflow-list > .eq-overflow-list-item[data-overflow] {
position: absolute;
inset-block-start: 0;
inset-inline-start: 0;
width: max-content;
visibility: hidden;
pointer-events: none;
}
.eq-overflow-list-more {
display: flex;
flex: none;
align-items: center;
}
/* Still laid out when there is nothing to count, so its width is known for the next pass. */
.eq-overflow-list-more[data-empty] {
visibility: hidden;
}
.eq-overflow-list-counter {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 24px;
margin: 0;
padding: 0 calc(var(--eq-space-2) - 1px);
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-s);
background: transparent;
color: var(--eq-ink-2);
font: var(--eq-weight-strong) var(--eq-text-xs) / 1 var(--eq-font);
font-variant-numeric: tabular-nums;
white-space: nowrap;
cursor: pointer;
user-select: none;
transition:
background-color var(--eq-dur) var(--eq-ease),
color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
.eq-overflow-list-counter:active {
scale: var(--eq-press);
}
.eq-overflow-list-counter[aria-expanded="true"] {
background: var(--eq-tint);
color: var(--eq-ink);
}
@media (hover: hover) {
.eq-overflow-list-counter:hover {
background: var(--eq-tint);
color: var(--eq-ink);
}
}
.eq-overflow-list-counter:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-overflow-list-popover {
--_shift: -4px;
box-sizing: border-box;
/* Undo the UA's centring: the surface is placed next to the counter. */
position: fixed;
inset: auto;
margin: var(--eq-space-1) 0;
width: max-content;
max-width: min(320px, calc(100vw - 2 * var(--eq-space-4)));
max-height: min(320px, calc(100dvh - 2 * var(--eq-space-4)));
overflow: auto;
padding: var(--eq-space-4);
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-l);
background: var(--eq-raised);
color: var(--eq-ink);
box-shadow: var(--eq-shadow);
-webkit-backdrop-filter: var(--eq-blur);
backdrop-filter: var(--eq-blur);
font: var(--eq-weight) var(--eq-text-m) / 1.5 var(--eq-font);
/* Grows out of the counter, which sits above its end edge. */
transform-origin: top right;
opacity: 0;
scale: 0.96;
translate: 0 var(--_shift);
/* Leaving is quicker than arriving. */
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease),
translate var(--eq-dur) var(--eq-ease);
}
.eq-overflow-list-popover:dir(rtl) {
transform-origin: top left;
}
/* Arriving, it lands on the spring. */
.eq-overflow-list-popover:popover-open {
opacity: 1;
scale: 1;
translate: none;
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur-move) var(--eq-spring),
translate var(--eq-dur-move) var(--eq-spring);
}
@starting-style {
.eq-overflow-list-popover:popover-open {
opacity: 0;
scale: 0.96;
translate: 0 var(--_shift);
}
}
.eq-overflow-list-menu {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--eq-space-2);
margin: 0;
padding: 0;
list-style: none;
}
/* Keeping the surface in the top layer while it fades out needs overlay, which only some
engines can transition. Elsewhere it closes at once rather than fade outside the top layer. */
@supports (overlay: auto) {
.eq-overflow-list-popover {
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease),
translate var(--eq-dur) var(--eq-ease),
display var(--eq-dur) allow-discrete,
overlay var(--eq-dur) allow-discrete;
}
.eq-overflow-list-popover:popover-open {
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur-move) var(--eq-spring),
translate var(--eq-dur-move) var(--eq-spring),
display var(--eq-dur-move) allow-discrete,
overlay var(--eq-dur-move) allow-discrete;
}
}
/* Anchor positioning where the engine has it. overflow-list.tsx uses the same condition.
The counter ends the row, so the list lines up with its end edge and grows backwards. */
@supports (anchor-name: --a) and (position-area: block-end) and (position-try-fallbacks: flip-block) {
.eq-overflow-list-popover {
position-area: block-end span-inline-start;
position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
}
}
}