Chip Scroller
A one-line filter chip rail that snaps under the thumb, fades at whichever edge still has chips behind it, and pins the off-screen count where it stays reachable.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chip-scroller.jsonPrompt
Build a React + TypeScript + Tailwind "ChipScroller" component (lucide-react for four
glyphs, no other dependency). It is the one-line filter rail on a phone screen: chips
scroll horizontally under the thumb, the edges fade to admit the row continues, and
the count of what is off screen stays pinned within reach instead of scrolling away
with the very chips it is counting.
Contract
- export const ChipScroller = React.forwardRef<HTMLDivElement, ChipScrollerProps>,
plus the types ChipScrollerItem / ChipScrollerVariant / ChipScrollerSelection /
ChipScrollerLabels.
- ChipScrollerItem: { value, label, count?, icon?, disabled?, disabledReason? }.
- Props
- items?: ChipScrollerItem[] (default []).
- value? / defaultValue? / onValueChange?(next, item) — controlled and uncontrolled
both. Always a string[], even when selection is "single": one shape per state.
- selection?: "single" | "multiple" (default "single"). Single keeps at most one
chip on and pressing the live chip clears it — a toggle, not a radio.
- variant?: "fade" | "count" | "expand" (default "count").
- snap? (default true), autoScrollActive? (default true),
edgeInset?: number | string (default "0.75rem"), label? (default "Filters"),
emptyLabel?, labels?: Partial<ChipScrollerLabels> (six strings),
formatOverflow?(hidden) for the cap face, formatOverflowLabel?(hidden, total) for
its accessible name.
- The root spreads the rest of the native div props and merges className and style.
Behavior
- Three variants, three answers to "the row continues past the edge":
- "fade" — masked edges only. Nothing pinned, nothing to press.
- "count" — a cap pinned outside the scroll box reading "+N". Pressing it scrolls
one viewport forward minus 56px of context (at least 120px), and once the rail is
at its end the glyph flips and it wraps back to the first chip.
- "expand" — the same "+N", but pressing it unrolls the rail into a wrapped block
showing every chip, and pressing again rolls it back to one line.
- The count is the number of chips whose box is clipped at either edge, so it is only
zero when nothing is hidden — that is why the cap never blinks out mid-scroll. It is
measured in one rAF-throttled read pass driven by a passive scroll listener and a
ResizeObserver on both the scroll box and the row. While unrolled the pass is frozen
on purpose: recomputing would drop the cap to "+0" and make it jump back the instant
it rolls up.
- Scrolling is the browser's own. No custom drag gesture, no pointer capture, nothing
to fight: overflow-x-auto plus scroll-snap-type: x proximity (never mandatory — a
chip wider than the rail would make mandatory snapping inescapable) and
scroll-padding-inline-start equal to the edge inset, so a flick with momentum comes
to rest on a chip edge and not under the notch. touch-action is `manipulation`, not
`pan-x`: pan-x would eat the vertical page scroll a thumb happens to start on the
rail, and manipulation still drops the double-tap zoom delay.
- Every programmatic scroll writes scrollLeft on the rail only, via scrollTo with a
clamped target. Never scrollIntoView: it walks up the ancestors and would jump the
whole page to a chip nobody asked to see. `behavior` follows prefers-reduced-motion,
subscribed through matchMedia (never read once) and unsubscribed on unmount.
- Reveal pass: a chip just switched *on* slides into view; a mount or a roll-up
re-reveals the current selection instantly; a chip switched *off* moves nothing,
because nothing new appeared. Only three chips fit on a phone, so a selection
restored from a URL would otherwise sit off screen with nothing to say so.
- Keyboard, equal in power to every gesture: the rail is a role=toolbar with a roving
tabindex — one tab stop, so Tab does not walk fourteen chips. Arrow keys walk the
chips and drag the rail along (clamped at both ends, never wrapped: a rail that
teleports from the last chip to the first is disorienting when three of them are on
screen), Home/End jump to the ends, Space/Enter toggle, and Tab again reaches the
pinned cap. Pressing the cap hands focus to the first chip that lands in the new
view — computed against the target scroll position, so it does not wait for the
smooth scroll — which both reads the destination out loud and moves the tab stop
there.
- ARIA: role=toolbar aria-orientation=horizontal on the scroll box, aria-label from
`label`; each chip is a real <button> with aria-pressed (a toggle, deliberately not
radio/checkbox); the cap carries aria-expanded in the "expand" variant. An
unavailable chip is aria-disabled with its reason inside the button, so the reason
is part of the accessible name — never the native disabled attribute, which blurs a
control the user is standing on and cannot explain itself. Pressing it announces the
reason in a polite role=status region and changes nothing. That region also carries
the unroll / roll-up / wrap notices and is cleared after 2.4s so an identical
message can be said again; scrolling itself is never announced.
- Value hygiene: incoming value/defaultValue is normalized against items — unknown
values dropped, duplicates collapsed, "single" capped at one. A stale facet from a
URL must never survive as a filter no chip on screen can clear.
- Safe area: the leading and trailing inset are
max(var(--safe-area-inset-left|right, env(safe-area-inset-left|right, 0px)),
edgeInset). The leading inset lives on the row (inside the scroll box) so it scrolls
with the content; the trailing one lives on the row when there is no cap and on the
root when there is, because the cap sits outside the scroll box. The custom property
lets a device-frame preview simulate insets on hardware that reports zero.
- Edge cases: no items renders the row at full height with `emptyLabel`, so the header
never drops onto the list; one item shows no mask and no cap, because nothing is
hidden; a label longer than the screen is capped at 16rem and truncated for the eye
only, with the full text left in the DOM so the accessible name is never the
shortened one; the tab stop is re-derived every render and re-synced by every chip's
own onFocus, so an items list that loses the focused chip cannot leave the rail with
no way in. A rotation can make the rail fit and take the cap away underneath the
user: if it held focus at that moment, focus is handed to the last chip rather than
dropped on <body> — guarded twice, on the node really being detached and on nothing
else having claimed focus in the meantime.
- Cleanup: the measure rAF, the reveal rAF, the scroll listener, the ResizeObserver,
the notice timer and the matchMedia subscription are all released on unmount and on
every dependency change.
Rendering & styling
- Semantic tokens only: bg-card / text-foreground / bg-accent / text-muted-foreground /
border / ring-ring. The selected chip INVERTS (border-foreground bg-foreground
text-background) rather than taking a colour; an unavailable one keeps the same box
and goes dashed + muted. Nothing on this rail is tinted.
- The mask is one linear-gradient built from two booleans, so a side with nothing
behind it gets a 0px ramp — which is simply no fade. It is a mask, not a gradient
overlay, so the rail can sit on any surface without matching its background.
- Chips are h-11 (44px) rounded-lg with 13px/500 labels and 11px tabular-nums counts;
the cap is the same height and shape, semibold, with a chevron that flips to a
rewind glyph at the end of the rail. The row keeps py-1 so a focus ring is never
clipped by the scroll box.
- Nothing depends on hover; focus-visible rings everywhere; the press squash is
transform-only and disabled under motion-reduce, which changes nothing functional.
- cn() merges every className; consumer style wins over the component's own.
Customization levers
- Density: chip px-3.5 / gap-2 / text-[13px] is the phone default; px-3 and gap-1.5
fit roughly one more chip per screen. h-11 is the 44px floor — shrink the padding,
not the height.
- Shape: rounded-lg → rounded-full turns the rail into pills without touching any
other rule; the cap follows the same token.
- Overflow policy: FADE_PX (20) is the ramp width, PAGE_OVERLAP_PX (56) how much of
the old view survives a page, MIN_PAGE_PX (120) the floor on that travel, and
VIEW_PADDING_PX (12) the breathing room around a chip scrolled into view.
- Structure: variant picks how loud the overflow is; `snap={false}` gives free
scrolling for a rail of wildly uneven chip widths; `autoScrollActive={false}` if the
host would rather own the scroll position.
- Content: `icon` takes any 16px glyph, `count` any number; drop `count` entirely for a
navigation rail. Add a leading pinned "Filters" button by wrapping the component in
a flex row — it stays outside the scroll box the way the cap does.
- Colour: the rail is monochrome by design. If a facet family needs colour, spend it on
the chip icon and leave the inverted selected state alone — that inversion is the
only thing marking the live filter.
- i18n: all six strings live in `labels`, and both formatters cover the cap face and
its accessible name (plural rules, "99+" ceilings, right-to-left copy).Concepts
- Pinned overflow count — the one thing on a rail that must never scroll is the number saying how much has scrolled. The cap lives outside the scroll box, counts chips clipped at either edge, and so reads "+6" at the start, "+6" at the end, and zero only when the rail genuinely fits.
- Edge fade as the only scrollbar — a finger gets no scrollbar, so the masked ramp is the whole overflow affordance. It is a mask rather than a gradient overlay: no background colour to match, and a side with nothing behind it simply gets a 0px ramp.
- Proximity snapping — a flick carries inertia and lands wherever it lands;
scroll-snap-type: x proximitynudges it to a chip edge, andscroll-padding-inline-startmakes that edge the safe-area inset rather than the physical screen edge. Mandatory snapping is refused on purpose: one chip wider than the rail would trap the gesture. - Roving tabindex on a scroll box — the rail is a toolbar with a single tab stop, so Tab does not walk fourteen chips to leave the row. Arrows walk it instead, dragging the rail along with
focus({ preventScroll: true })plus a hand-written scroll, because the browser's own scroll-into-view would jump the whole page. - Reveal on selection, not on every change — turning a chip on slides it into view; turning one off moves nothing, because nothing new appeared. It is the difference between a rail that follows you and one that yanks itself back to the first selected chip every time you deselect.
- Refusal instead of removal — an unavailable chip stays focusable and keeps its reason inside its accessible name.
disabledwould blur a control the user is standing on and silence the one explanation worth hearing.
Mobile Search Bar
A phone search field that turns the screen into search mode — Cancel, a scope bar and recent searches — measured against the software keyboard and padded off the notch.
Inline Picker Row
A settings row that expands in place to reveal its picker — slide a thumb across the options and lift to choose, instead of pushing a whole screen.