Overview
Multi-filter chips that wrap with layout. Selected state springs in; single-select mode uses a shared layoutId highlight. Press 0.96, min hit 40px. Different from Segmented Control — free-width wrapping chips, not equal cells.
Installation
Use the CLI to install the component automatically:
$ pnpm dlx shadcn@latest add https://ui.sanjid.in/r/filter-bar.jsonInstall dependencies:
$ pnpm add motionAdd the utility function for class merging:
lib/utils.ts
tsx
1import { ClassValue, clsx } from "clsx";2import { twMerge } from "tailwind-merge";3 4export function cn(...inputs: ClassValue[]) {5 return twMerge(clsx(inputs));6}
Copy the component code into your project:
tsx
1"use client";2 3import React, {4 useCallback,5 useEffect,6 useLayoutEffect,7 useRef,8 useState,9} from "react";10import { cn } from "@/lib/utils";11 12export type FilterBarOption = {13 id: string;14 label: string;15};16 17export type FilterBarProps = {18 options: FilterBarOption[];19 value?: string[];20 defaultValue?: string[];21 onChange?: (value: string[]) => void;22 multi?: boolean;23 className?: string;24 "aria-label"?: string;25};26 27function useAvatarShifts(rootRef: React.RefObject<HTMLElement | null>) {28 return useCallback((activeIdx: number | null, phase: "in" | "out") => {29 if (!rootRef.current) return;30 const cs = getComputedStyle(document.documentElement);31 const num = (name: string, fb: number) => {32 const v = parseFloat(cs.getPropertyValue(name));33 return Number.isFinite(v) ? v : fb;34 };35 const ease = (name: string, fb: string) =>36 cs.getPropertyValue(name).trim() || fb;37 38 const lift = num("--avatar-lift", -4);39 const falloff = num("--avatar-falloff", 0.45);40 const scale = num("--avatar-scale", 1.05);41 const tf =42 phase === "out"43 ? ease("--avatar-ease-out", "cubic-bezier(0.34, 3.85, 0.64, 1)")44 : ease("--avatar-ease-in", "cubic-bezier(0.22, 1, 0.36, 1)");45 46 rootRef.current.querySelectorAll<HTMLElement>(".t-avatar").forEach((el, i) => {47 el.style.transitionTimingFunction = tf;48 if (activeIdx == null) {49 el.style.setProperty("--shift", "0px");50 el.style.setProperty("--scale-active", "1");51 return;52 }53 const d = Math.abs(i - activeIdx);54 el.style.setProperty(55 "--shift",56 (lift * Math.pow(falloff, d)).toFixed(3) + "px",57 );58 el.style.setProperty(59 "--scale-active",60 i === activeIdx ? String(scale) : "1",61 );62 });63 }, [rootRef]);64}65 66export function FilterBar({67 options,68 value: valueProp,69 defaultValue = [],70 onChange,71 multi = true,72 className,73 "aria-label": ariaLabel = "Filters",74}: FilterBarProps) {75 const listRef = useRef<HTMLDivElement>(null);76 const pillRef = useRef<HTMLSpanElement>(null);77 const animatePill = useRef(false);78 const isControlled = valueProp !== undefined;79 const [uncontrolled, setUncontrolled] = useState(defaultValue);80 const selected = isControlled ? valueProp : uncontrolled;81 82 const setSelected = useCallback(83 (next: string[], animate = false) => {84 animatePill.current = animate;85 if (!isControlled) setUncontrolled(next);86 onChange?.(next);87 },88 [isControlled, onChange],89 );90 91 const toggle = (id: string, animate = false) => {92 if (multi) {93 setSelected(94 selected.includes(id)95 ? selected.filter((x) => x !== id)96 : [...selected, id],97 animate,98 );99 } else {100 setSelected(selected.includes(id) ? [] : [id], animate);101 }102 };103 104 const setShifts = useAvatarShifts(listRef);105 106 const moveTo = useCallback((tab: HTMLElement | null, animate: boolean) => {107 const pill = pillRef.current;108 if (!pill) return;109 if (!tab) {110 const prev = pill.style.transition;111 pill.style.transition = "none";112 pill.style.transform = "translateX(0)";113 pill.style.width = "0px";114 void pill.offsetWidth;115 pill.style.transition = prev;116 return;117 }118 if (!animate) {119 const prev = pill.style.transition;120 pill.style.transition = "none";121 pill.style.transform = `translateX(${tab.offsetLeft}px)`;122 pill.style.width = `${tab.offsetWidth}px`;123 void pill.offsetWidth;124 pill.style.transition = prev;125 } else {126 pill.style.transform = `translateX(${tab.offsetLeft}px)`;127 pill.style.width = `${tab.offsetWidth}px`;128 }129 }, []);130 131 const activeTabEl = useCallback(() => {132 const bar = listRef.current;133 if (!bar) return null;134 return bar.querySelector<HTMLElement>('.t-tab[aria-selected="true"]');135 }, []);136 137 // Single-select: sliding pill (16)138 useLayoutEffect(() => {139 if (multi) return;140 moveTo(activeTabEl(), animatePill.current);141 animatePill.current = false;142 }, [multi, selected, options, moveTo, activeTabEl]);143 144 useEffect(() => {145 if (multi) return;146 const onResize = () => moveTo(activeTabEl(), false);147 window.addEventListener("resize", onResize);148 return () => window.removeEventListener("resize", onResize);149 }, [multi, moveTo, activeTabEl]);150 151 // Multi-select: avatar-group hover falloff (11)152 if (multi) {153 return (154 <div155 ref={listRef}156 role="group"157 aria-label={ariaLabel}158 className={cn(159 "t-avatar-group flex flex-wrap gap-2",160 className,161 )}162 onMouseLeave={() => setShifts(null, "out")}163 >164 {options.map((opt, i) => {165 const on = selected.includes(opt.id);166 return (167 <button168 key={opt.id}169 type="button"170 aria-pressed={on}171 onClick={() => toggle(opt.id)}172 onMouseEnter={() => setShifts(i, "in")}173 className={cn(174 "t-avatar relative min-h-10 min-w-10 overflow-hidden rounded-full px-3.5 text-sm font-medium outline-none",175 "focus-visible:ring-2 focus-visible:ring-[var(--color-focus)]",176 "active:scale-[0.96]",177 on178 ? "bg-[var(--color-ink)] text-[var(--color-paper)]"179 : "text-[var(--color-ink)] ring-1 ring-[var(--color-rule)]",180 )}181 >182 <span className="relative z-[1]">{opt.label}</span>183 </button>184 );185 })}186 </div>187 );188 }189 190 return (191 <div192 ref={listRef}193 role="tablist"194 aria-label={ariaLabel}195 className={cn(196 "t-tabs relative inline-flex flex-wrap items-center gap-1 rounded-full p-1",197 "bg-[var(--color-paper-2)]",198 className,199 )}200 style={201 {202 "--tabs-bar-bg": "var(--color-paper-2)",203 "--tabs-pill-bg": "var(--color-ink)",204 "--tabs-text-muted": "var(--color-ink-muted)",205 "--tabs-text-active": "var(--color-paper)",206 } as React.CSSProperties207 }208 >209 <span210 ref={pillRef}211 className="t-tabs-pill rounded-full bg-[var(--color-ink)]"212 style={{ top: 4, height: 32 }}213 aria-hidden="true"214 />215 {options.map((opt) => {216 const on = selected.includes(opt.id);217 return (218 <button219 key={opt.id}220 type="button"221 role="tab"222 aria-selected={on}223 onClick={() => toggle(opt.id, true)}224 className={cn(225 "t-tab relative z-1 min-h-8 rounded-full px-3.5 text-sm font-medium outline-none",226 "focus-visible:ring-2 focus-visible:ring-[var(--color-focus)]",227 "active:scale-[0.96]",228 on229 ? "text-[var(--color-paper)]"230 : "text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]",231 )}232 style={{ height: 32, background: "transparent" }}233 >234 {opt.label}235 </button>236 );237 })}238 </div>239 );240}
Adjust import paths according to your folder structure.
Basic Usage
tsx
1import { FilterBar } from "@/components/ui/filter-bar";2 3<FilterBar4 options={[5 { id: "open", label: "Open" },6 { id: "blocked", label: "Blocked" },7 ]}8 value={filters}9 onChange={setFilters}10/>
Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| options | { id: string; label: string }[] | Yes | — | Available filter chips. |
| value | string[] | No | — | Selected option ids. |
| onChange | (value: string[]) => void | No | — | Fires when selection changes. |
| multi | boolean | No | true | Allow multiple selected chips. |
Best Practices
- Use Segmented Control for exclusive equal-width settings; use Filter Bar for wrapable multi-filters.
- Set
multi={false}when you want the shared pill morph. - Keep labels short so wrap stays readable on mobile.