ns-ui
Overflow Chip Mux
The list/tag overflow indicator, replaced with real NES PPU sprite multiplexing: instead of collapsing extra items into a static "+N" pill, only 8 chip slots (the hardware's real 8-sprites-per-scanline limit) are ever visible at once, round-robining which contending item occupies each slot on a throttled cadence, exactly how 8-bit console flicker actually worked.
Use when a tag/chip/filter row that has more items than fit and should replace the generic static "+N more" pill with a genuine, sourced hardware artifact — a fixed 8-slot budget that round-robins which contending items are visible on a throttled cadence, always backed by a full sr-only enumeration and a visible total count. Pick avatar-stack-flock instead when the overflow group is people/avatars and the resting motion should read as an ambient flocking mill rather than a slotted rotation; pick breadcrumb-overflow-menu instead when overflow items should be reachable via an explicit disclosure menu rather than any ambient/automatic rotation at all.
Install
npx shadcn add https://design.helpmarq.com /r/overflow-chip-mux.jsonSource
registry/core/overflow-chip-mux/component.tsx"use client";
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
// ---------------------------------------------------------------------------
// OverflowChipMux — the list/tag "+N more" overflow indicator, replaced with
// the real NES PPU hardware limit it is named after. The 2C02 PPU can only
// evaluate 8 hardware sprites per scanline; a 9th object contending for that
// scanline in a given frame is silently dropped from the frame entirely —
// a hardware limit, not a software choice. Games with more on-screen objects
// than that worked around it with SPRITE MULTIPLEXING: round-robin which
// subset of contending objects gets the 8 available slots each frame,
// cycling on a schedule so every object gets its turn some fraction of the
// time. That round-robin rotation — not a static "+N" pill — is this
// component's entire mechanic.
//
// SLOT_BUDGET = 8 fixed chip DOM nodes are rendered at all times (keyed by
// slot index, never by item identity); a swap changes only which item a
// slot currently shows. Everything about a swap — which slot is chosen, its
// content, and its leave/arrive animation — runs imperatively off refs, not
// React state, so ticking never re-renders the tree: no layout thrash, no
// accessibility-tree churn, and the leave/arrive transition is driven by
// real CSS transitions rather than a state machine racing a render.
//
// Cadence: real 8-bit multiplexing code cycled every few FRAMES (roughly
// 130ms at 60fps), but reproduced at that literal rate on a screen it reads
// as noise, not a legible mechanic, to anyone not already told what they're
// looking at — a real usability failure, not a stylistic choice. This
// component keeps the same round-robin/FIFO/pin-on-hover mechanic but at a
// cadence and per-swap animation slow enough to actually watch: one slot
// changes at a time, its old label visibly slides up and out, then the new
// label visibly slides up and in, before the row rests again.
//
// Round-robin model: each of the SLOT_BUDGET slots tracks an `age` in ticks
// since it last changed. Every SWAP_INTERVAL_MS, the single oldest
// non-pinned, currently-idle slot is evicted (the item it held goes to the
// back of a FIFO queue of every other contending item) and the item at the
// front of that queue takes its place — exactly one item rotates in/out per
// tick, not the whole overflow set reshuffling at once, so a full cycle
// back to the starting arrangement takes N * SWAP_INTERVAL_MS for N items.
// ---------------------------------------------------------------------------
export interface OverflowChipItem {
id: string;
label: string;
}
export interface OverflowChipMuxProps {
/** contending items — supply MORE than slotBudget to see multiplexing engage */
items?: OverflowChipItem[];
/** visible slot count — the real PPU per-scanline limit is 8 */
slotBudget?: number;
/** ms between round-robin swaps — slow enough to visually track one slot at a time */
swapIntervalMs?: number;
/** accessible label for the chip row's group role */
ariaLabel?: string;
/** extra classes merged onto the rendered root element */
className?: string;
/** inline styles merged onto the root element */
style?: CSSProperties;
}
interface Slot {
itemIndex: number;
pinned: boolean;
age: number;
phase: "idle" | "out" | "in";
}
const SLOT_BUDGET = 8; // NES PPU sprites-per-scanline hardware limit
const SWAP_INTERVAL_MS = 1100; // slow enough to watch one slot change at a time
const OUT_MS = 220; // outgoing label's visible leave
const IN_MS = 260; // incoming label's visible arrive
const DEFAULT_ITEMS: OverflowChipItem[] = [
"goomba", "koopa", "piranha-plant", "buzzy-beetle", "lakitu", "spiny",
"bullet-bill", "hammer-bro", "boo", "cheep-cheep", "podoboo", "blooper",
"para-goomba", "dry-bones",
].map((label, i) => ({ id: `${i}-${label}`, label }));
export function OverflowChipMux({
items = DEFAULT_ITEMS,
slotBudget = SLOT_BUDGET,
swapIntervalMs = SWAP_INTERVAL_MS,
ariaLabel = "Tags",
className = "",
style,
}: OverflowChipMuxProps) {
const budget = Math.max(1, Math.min(slotBudget, items.length || 1));
const slotsRef = useRef<Slot[]>([]);
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
const labelRefs = useRef<(HTMLSpanElement | null)[]>([]);
useEffect(() => {
if (items.length <= budget) {
slotsRef.current = [];
return; // no overflow — matches real hardware, which only drops the 9th sprite
}
const slots: Slot[] = Array.from({ length: budget }, (_, i) => ({
itemIndex: i,
pinned: false,
age: budget - i,
phase: "idle",
}));
slotsRef.current = slots;
let queue = items.slice(budget).map((_, i) => budget + i);
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
let interval = 0;
const outTimers: number[] = new Array(budget).fill(0);
const inTimers: number[] = new Array(budget).fill(0);
const rafs: number[] = new Array(budget).fill(0);
const setLabelText = (i: number, itemIndex: number) => {
const el = labelRefs.current[i];
const item = items[itemIndex];
if (el && item) el.textContent = item.label;
};
// three explicit visual states, driven by real CSS transitions rather
// than a keyframe, so "leave" and "arrive" are genuinely two separate,
// watchable motions rather than one blink standing in for both
const paint = (i: number, phase: "idle" | "out" | "in-start" | "in") => {
const el = labelRefs.current[i];
if (!el) return;
if (phase === "out") {
el.style.transition = `opacity ${OUT_MS}ms ease-in, transform ${OUT_MS}ms ease-in`;
el.style.opacity = "0";
el.style.transform = "translateY(-7px)";
} else if (phase === "in-start") {
el.style.transition = "none";
el.style.opacity = "0";
el.style.transform = "translateY(7px)";
} else if (phase === "in") {
void el.offsetHeight; // force the in-start style to commit before transitioning
el.style.transition = `opacity ${IN_MS}ms ease-out, transform ${IN_MS}ms ease-out`;
el.style.opacity = "1";
el.style.transform = "translateY(0)";
} else {
el.style.transition = "none";
el.style.opacity = "1";
el.style.transform = "translateY(0)";
}
};
const startSwap = (i: number) => {
const slot = slots[i];
if (!slot) return;
slot.phase = "out";
paint(i, "out");
outTimers[i] = window.setTimeout(() => {
const nextItemIndex = queue.shift();
if (nextItemIndex === undefined) return;
queue.push(slot.itemIndex);
slot.itemIndex = nextItemIndex;
setLabelText(i, nextItemIndex);
paint(i, "in-start");
rafs[i] = requestAnimationFrame(() => paint(i, "in"));
inTimers[i] = window.setTimeout(() => {
slot.phase = "idle";
slot.age = 0;
}, IN_MS);
}, OUT_MS);
};
const tick = () => {
let evictIdx = -1;
let maxAge = -1;
for (let i = 0; i < slots.length; i++) {
const s = slots[i];
if (s && s.phase === "idle" && !s.pinned) {
s.age += 1;
if (s.age > maxAge) {
maxAge = s.age;
evictIdx = i;
}
}
}
if (evictIdx === -1 || queue.length === 0) return; // nothing eligible this tick
startSwap(evictIdx);
};
const start = () => {
window.clearInterval(interval);
interval = window.setInterval(tick, swapIntervalMs);
};
const stop = () => window.clearInterval(interval);
if (!mq.matches) start();
const onReducedChange = () => {
if (mq.matches) stop();
else start();
};
mq.addEventListener("change", onReducedChange);
return () => {
stop();
mq.removeEventListener("change", onReducedChange);
for (let i = 0; i < budget; i++) {
window.clearTimeout(outTimers[i]);
window.clearTimeout(inTimers[i]);
const raf = rafs[i];
if (raf) cancelAnimationFrame(raf);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [items, budget, swapIntervalMs]);
const setPinned = (slotIdx: number, pinned: boolean) => {
const slot = slotsRef.current[slotIdx];
if (!slot) return;
slot.pinned = pinned;
if (!pinned) slot.age = 0;
const btn = buttonRefs.current[slotIdx];
if (btn) {
if (pinned) btn.dataset.pinned = "true";
else delete btn.dataset.pinned;
}
};
const visibleCount = Math.min(budget, items.length);
const total = items.length;
return (
<div className={`w-full ${className}`} style={style}>
<div role="group" aria-label={ariaLabel} className="flex flex-wrap gap-1.5">
{Array.from({ length: budget }, (_, i) => {
const item = items[i];
if (!item) return null;
return (
<button
key={i}
type="button"
tabIndex={0}
ref={(el) => {
buttonRefs.current[i] = el;
}}
onPointerEnter={() => setPinned(i, true)}
onPointerLeave={() => setPinned(i, false)}
onFocus={() => setPinned(i, true)}
onBlur={() => setPinned(i, false)}
className="ns-ocm-chip inline-flex items-center overflow-hidden rounded-full border border-border bg-background px-2.5 py-1 text-xs text-foreground transition-colors duration-150 hover:border-foreground/25 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent data-[pinned]:border-foreground/35"
>
<span
ref={(el) => {
labelRefs.current[i] = el;
}}
className="inline-block max-w-[16ch] truncate"
>
{item.label}
</span>
</button>
);
})}
</div>
<p className="mt-2 font-mono text-[11px] text-ns-muted">
{visibleCount} of {total} shown, {total} total
</p>
{/* always in the DOM: the true, fully enumerable list — this is what
the "+N more" pattern always guaranteed and this replaces */}
<ul className="sr-only">
{items.map((item) => (
<li key={item.id}>{item.label}</li>
))}
</ul>
</div>
);
}
OverflowChipMux.displayName = "OverflowChipMux";
export default OverflowChipMux;
Build spec
Build <OverflowChipMux items? slotBudget? swapIntervalMs? ariaLabel? className? style?>, a chip row that replaces the generic "+N more" overflow pattern with real NES PPU sprite multiplexing. The 2C02 PPU can only evaluate 8 hardware sprites per scanline; a 9th contending object in a frame is silently dropped from that frame entirely (a hardware limit, not a software choice), and games with more on-screen objects than that round-robinned which subset of contenders got the 8 available slots each frame, cycling on a throttled schedule so every object gets its turn some fraction of the time — the well-documented NES 'flicker' artifact. `SLOT_BUDGET` defaults to 8 (the real per-scanline limit) and is the count of chip DOM nodes ALWAYS rendered, keyed by fixed slot index 0..budget-1, never by item identity — a swap changes only which item's label a slot currently shows (text content + a one-shot CSS flicker keyframe replayed via a per-slot `flashKey` remounting an inner span), never which DOM node exists, so there is no layout thrash or accessibility-tree churn on every tick. Round-robin model: each slot tracks `age` (ticks since it last changed). Every `swapIntervalMs` (default 130ms, ~7.7Hz — a deliberately decimated cadence in the range real multiplexing code used, roughly every 8 frames at 60fps, specifically to stay legible instead of reading as an unreadable strobe), find the single oldest non-pinned slot, evict its item to the back of a FIFO queue holding every other contending item, and pull the item at the front of that queue into the freed slot with age reset to 0 — exactly one item rotates in/out per tick, never a full reshuffle, so a complete cycle back to the starting arrangement takes exactly `items.length * swapIntervalMs`. If `items.length <= slotBudget`, no interval starts at all and every item renders in its own slot with zero rotation — this correctly matches the real hardware, which only starts dropping sprites once a 9th contender shows up on the scanline; the shipped default seed is 14 items specifically so multiplexing is visibly engaged out of the box (a caller testing with a short list would otherwise see nothing alive, which is not a bug in the component). Interaction: each chip is a focusable `<button type="button">`; `onPointerEnter`/`onFocus` pins its slot (excluded from the eviction search, so it never rotates out while hovered/focused) and `onPointerLeave`/`onBlur` unpins it (age reset to 0, so it re-enters the rotation fresh rather than being evicted immediately for being 'old'). The pin affordance's `focus-visible` ring is the only place `--ns-accent` appears anywhere in this component — the swap/flicker mechanism itself, including the one-shot per-swap flicker keyframe, uses opacity only (0.25 to 1 over 90ms), zero hue, zero accent, exactly per the token rules. All chip fill/border comes from `--foreground`/`--ns-muted`/`--border` via ordinary Tailwind token classes (`border-border`, `bg-background`, `text-foreground`, `text-ns-muted`) — no canvas, no getComputedStyle, nothing to re-derive on theme change. Accessibility is required, not optional, and is what earns this component the right to replace '+N more' at all: a `sr-only` `<ul>` enumerating every single item (not just the currently-visible 8) is present in the DOM at all times regardless of overflow state, and a visible (non-sr-only) plain-text line below the row always reads `${visibleCount} of ${total} shown, ${total} total` so both sighted and non-sighted users have the true count without depending on the flicker ever being witnessed. `prefers-reduced-motion` never starts the interval at all — the component simply renders its initial build state (slots 0..budget-1 holding items 0..budget-1 in source order, round-robin index 0) and stays there, which is deliberately the SAME code path as the first frame of the animated version, not a separate branch, so the reduced-motion freeze is provably the 'first budget-full pass' frame the spec calls for. A `matchMedia` change listener starts/stops the interval live if the OS preference toggles mid-session. Interval is cleared on unmount and whenever `items`/`slotBudget`/`swapIntervalMs` change (which also rebuilds the round-robin queue from index 0). No dependencies.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| items? | OverflowChipItem[] | DEFAULT_ITEMS | contending items — supply MORE than slotBudget to see multiplexing engage |
| slotBudget? | number | SLOT_BUDGET | visible slot count — the real PPU per-scanline limit is 8 |
| swapIntervalMs? | number | SWAP_INTERVAL_MS | ms between round-robin swaps — slow enough to visually track one slot at a time |
| ariaLabel? | string | "Tags" | accessible label for the chip row's group role |
| className? | string | — | extra classes merged onto the rendered root element |
| style? | CSSProperties | — | inline styles merged onto the root element |