Mobile

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ChevronDown, ChevronRight, ChevronUp, RotateCcw } from "lucide-react"
import { cn } from "@/lib/utils"

export interface ChipScrollerItem {
  value: string
  label: string
  /** Result count printed after the label, tabular so the chip never jitters. */
  count?: number
  /** Decorative leading glyph; it is aria-hidden and never part of the name. */
  icon?: React.ReactNode
  /**

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chip-scroller.json

Prompt

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 proximity nudges it to a chip edge, and scroll-padding-inline-start makes 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. disabled would blur a control the user is standing on and silence the one explanation worth hearing.

On This Page