Skip to main content

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.

Usage

registry/core/overflow-chip-mux/demo.tsx
"use client";

import { OverflowChipMux } from "./component";

export default function OverflowChipMuxDemo() {
  return (
    <main className="flex min-h-screen items-center justify-center bg-background p-6">
      <div className="w-full max-w-md">
        <p className="mb-4 font-mono text-xs tracking-widest text-ns-muted">
          ns-ui / overflow-chip-mux
        </p>
        <OverflowChipMux ariaLabel="Enemy tags" />
        <p className="mt-3 font-mono text-[11px] text-ns-muted">
          only 8 slots render at once — the rest round-robin in, NES
          sprite-multiplexing style
        </p>
      </div>
    </main>
  );
}

Component

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;

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/overflow-chip-mux.tsx, so point the import there.

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.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

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

PropTypeDefaultDescription
items?OverflowChipItem[]DEFAULT_ITEMScontending items — supply MORE than slotBudget to see multiplexing engage
slotBudget?numberSLOT_BUDGETvisible slot count — the real PPU per-scanline limit is 8
swapIntervalMs?numberSWAP_INTERVAL_MSms 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