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

Install dependencies:

$ pnpm add motion

Add the utility function for class merging:

lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {  return twMerge(clsx(inputs));}

Copy the component code into your project:

components/ui/filter-bar.tsx
tsx
"use client";
import React, {  useCallback,  useEffect,  useLayoutEffect,  useRef,  useState,} from "react";import { cn } from "@/lib/utils";
export type FilterBarOption = {  id: string;  label: string;};
export type FilterBarProps = {  options: FilterBarOption[];  value?: string[];  defaultValue?: string[];  onChange?: (value: string[]) => void;  multi?: boolean;  className?: string;  "aria-label"?: string;};
function useAvatarShifts(rootRef: React.RefObject<HTMLElement | null>) {  return useCallback((activeIdx: number | null, phase: "in" | "out") => {    if (!rootRef.current) return;    const cs = getComputedStyle(document.documentElement);    const num = (name: string, fb: number) => {      const v = parseFloat(cs.getPropertyValue(name));      return Number.isFinite(v) ? v : fb;    };    const ease = (name: string, fb: string) =>      cs.getPropertyValue(name).trim() || fb;
    const lift = num("--avatar-lift", -4);    const falloff = num("--avatar-falloff", 0.45);    const scale = num("--avatar-scale", 1.05);    const tf =      phase === "out"        ? ease("--avatar-ease-out", "cubic-bezier(0.34, 3.85, 0.64, 1)")        : ease("--avatar-ease-in", "cubic-bezier(0.22, 1, 0.36, 1)");
    rootRef.current.querySelectorAll<HTMLElement>(".t-avatar").forEach((el, i) => {      el.style.transitionTimingFunction = tf;      if (activeIdx == null) {        el.style.setProperty("--shift", "0px");        el.style.setProperty("--scale-active", "1");        return;      }      const d = Math.abs(i - activeIdx);      el.style.setProperty(        "--shift",        (lift * Math.pow(falloff, d)).toFixed(3) + "px",      );      el.style.setProperty(        "--scale-active",        i === activeIdx ? String(scale) : "1",      );    });  }, [rootRef]);}
export function FilterBar({  options,  value: valueProp,  defaultValue = [],  onChange,  multi = true,  className,  "aria-label": ariaLabel = "Filters",}: FilterBarProps) {  const listRef = useRef<HTMLDivElement>(null);  const pillRef = useRef<HTMLSpanElement>(null);  const animatePill = useRef(false);  const isControlled = valueProp !== undefined;  const [uncontrolled, setUncontrolled] = useState(defaultValue);  const selected = isControlled ? valueProp : uncontrolled;
  const setSelected = useCallback(    (next: string[], animate = false) => {      animatePill.current = animate;      if (!isControlled) setUncontrolled(next);      onChange?.(next);    },    [isControlled, onChange],  );
  const toggle = (id: string, animate = false) => {    if (multi) {      setSelected(        selected.includes(id)          ? selected.filter((x) => x !== id)          : [...selected, id],        animate,      );    } else {      setSelected(selected.includes(id) ? [] : [id], animate);    }  };
  const setShifts = useAvatarShifts(listRef);
  const moveTo = useCallback((tab: HTMLElement | null, animate: boolean) => {    const pill = pillRef.current;    if (!pill) return;    if (!tab) {      const prev = pill.style.transition;      pill.style.transition = "none";      pill.style.transform = "translateX(0)";      pill.style.width = "0px";      void pill.offsetWidth;      pill.style.transition = prev;      return;    }    if (!animate) {      const prev = pill.style.transition;      pill.style.transition = "none";      pill.style.transform = `translateX(${tab.offsetLeft}px)`;      pill.style.width = `${tab.offsetWidth}px`;      void pill.offsetWidth;      pill.style.transition = prev;    } else {      pill.style.transform = `translateX(${tab.offsetLeft}px)`;      pill.style.width = `${tab.offsetWidth}px`;    }  }, []);
  const activeTabEl = useCallback(() => {    const bar = listRef.current;    if (!bar) return null;    return bar.querySelector<HTMLElement>('.t-tab[aria-selected="true"]');  }, []);
  // Single-select: sliding pill (16)  useLayoutEffect(() => {    if (multi) return;    moveTo(activeTabEl(), animatePill.current);    animatePill.current = false;  }, [multi, selected, options, moveTo, activeTabEl]);
  useEffect(() => {    if (multi) return;    const onResize = () => moveTo(activeTabEl(), false);    window.addEventListener("resize", onResize);    return () => window.removeEventListener("resize", onResize);  }, [multi, moveTo, activeTabEl]);
  // Multi-select: avatar-group hover falloff (11)  if (multi) {    return (      <div        ref={listRef}        role="group"        aria-label={ariaLabel}        className={cn(          "t-avatar-group flex flex-wrap gap-2",          className,        )}        onMouseLeave={() => setShifts(null, "out")}      >        {options.map((opt, i) => {          const on = selected.includes(opt.id);          return (            <button              key={opt.id}              type="button"              aria-pressed={on}              onClick={() => toggle(opt.id)}              onMouseEnter={() => setShifts(i, "in")}              className={cn(                "t-avatar relative min-h-10 min-w-10 overflow-hidden rounded-full px-3.5 text-sm font-medium outline-none",                "focus-visible:ring-2 focus-visible:ring-[var(--color-focus)]",                "active:scale-[0.96]",                on                  ? "bg-[var(--color-ink)] text-[var(--color-paper)]"                  : "text-[var(--color-ink)] ring-1 ring-[var(--color-rule)]",              )}            >              <span className="relative z-[1]">{opt.label}</span>            </button>          );        })}      </div>    );  }
  return (    <div      ref={listRef}      role="tablist"      aria-label={ariaLabel}      className={cn(        "t-tabs relative inline-flex flex-wrap items-center gap-1 rounded-full p-1",        "bg-[var(--color-paper-2)]",        className,      )}      style={        {          "--tabs-bar-bg": "var(--color-paper-2)",          "--tabs-pill-bg": "var(--color-ink)",          "--tabs-text-muted": "var(--color-ink-muted)",          "--tabs-text-active": "var(--color-paper)",        } as React.CSSProperties      }    >      <span        ref={pillRef}        className="t-tabs-pill rounded-full bg-[var(--color-ink)]"        style={{ top: 4, height: 32 }}        aria-hidden="true"      />      {options.map((opt) => {        const on = selected.includes(opt.id);        return (          <button            key={opt.id}            type="button"            role="tab"            aria-selected={on}            onClick={() => toggle(opt.id, true)}            className={cn(              "t-tab relative z-1 min-h-8 rounded-full px-3.5 text-sm font-medium outline-none",              "focus-visible:ring-2 focus-visible:ring-[var(--color-focus)]",              "active:scale-[0.96]",              on                ? "text-[var(--color-paper)]"                : "text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]",            )}            style={{ height: 32, background: "transparent" }}          >            {opt.label}          </button>        );      })}    </div>  );}

Adjust import paths according to your folder structure.


Basic Usage

tsx
import { FilterBar } from "@/components/ui/filter-bar";
<FilterBar  options={[    { id: "open", label: "Open" },    { id: "blocked", label: "Blocked" },  ]}  value={filters}  onChange={setFilters}/>

Props

PropTypeRequiredDefaultDescription
options{ id: string; label: string }[]YesAvailable filter chips.
valuestring[]NoSelected option ids.
onChange(value: string[]) => voidNoFires when selection changes.
multibooleanNotrueAllow multiple selected chips.

Best Practices

  1. Use Segmented Control for exclusive equal-width settings; use Filter Bar for wrapable multi-filters.
  2. Set multi={false} when you want the shared pill morph.
  3. Keep labels short so wrap stays readable on mobile.