Componentform1.53 KB gzipZero dependencies

Search Filter Bar

Combined search, sort and filter toolbar with animated chips.

Preview

Live Preview

Installation

Tokens guide →

Add to your project via CLI (zero dependencies, code you own):

npx bigbullui add search-filter-bar

Or install the full npm package:

npm install bigbullui
import { SearchFilterBar } from "@/components/ui/search-filter-bar";

<SearchFilterBar />

Usage guidance

Use Search Filter Bar when a ticket stub surface needs Combined search, sort and filter toolbar with animated chips. Keep props minimal and prefer composition with sibling stubs.

Avoid Search Filter Bar for long form flows or dense data grids where a dedicated layout block fits better.

Source code

search-filter-bar.tsx

Zero dependencies (only React + utils). Copy and paste directly into your project:

"use client";

import * as React from "react";
import { cn } from "./lib/utils";

export interface SearchFilterBarProps extends React.HTMLAttributes<HTMLDivElement> {
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  placeholder?: string;
  sorts?: { id: string; label: string }[];
  activeSort?: string;
  onSortChange?: (id: string) => void;
  filters?: { id: string; label: string; active?: boolean }[];
  onFilterToggle?: (id: string) => void;
}

/** Search and filter toolbar: search input + sort dropdown + filters. */
export function SearchFilterBar({
  value,
  defaultValue,
  onValueChange,
  placeholder = "Ara…",
  sorts = [],
  activeSort,
  onSortChange,
  filters = [],
  onFilterToggle,
  className,
  ...props
}: SearchFilterBarProps) {
  const [internal, setInternal] = React.useState(defaultValue ?? "");
  const query = value ?? internal;
  const [activeFilters, setActiveFilters] = React.useState<Set<string>>(
    () => new Set(filters.filter((f) => f.active).map((f) => f.id))
  );
  React.useEffect(() => {
    setActiveFilters(new Set(filters.filter((f) => f.active).map((f) => f.id)));
  }, [filters]);

  const setQuery = (v: string) => { setInternal(v); onValueChange?.(v); };
  const toggleFilter = (id: string) => {
    setActiveFilters((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id); else next.add(id);
      return next;
    });
    onFilterToggle?.(id);
  };

  return (
    <div className={cn("flex flex-wrap items-center gap-2", className)} role="search" {...props}>
      <style>{`@keyframes sfbChip { from { opacity: 0; transform: scale(0.9); } to { opacity: 1; transform: scale(1); } }`}</style>
      {/* Arama */}
      <div className="relative min-w-40 flex-1">
        <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" aria-hidden="true">
          <circle cx="11" cy="11" r="7" /><path d="M21 21l-4.35-4.35" />
        </svg>
        <input
          type="search"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          placeholder={placeholder}
          aria-label={placeholder}
          className="w-full rounded-md border border-input bg-background py-1.5 ps-8 pe-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring transition-colors motion-reduce:transition-none"
        />
      </div>

      {/* Sort */}
      {sorts.length > 0 && (
        <div className="flex gap-1 rounded-md border border-border bg-card p-0.5" role="radiogroup" aria-label="Sorting options">
          {sorts.map((s) => (
            <button
              key={s.id}
              role="radio"
              aria-checked={activeSort === s.id}
              onClick={() => onSortChange?.(s.id)}
              className={cn(
                "rounded-sm px-2 py-1 font-mono text-[10px] uppercase tracking-wider transition-colors motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
                activeSort === s.id ? "bg-accent text-accent-foreground" : "text-muted-foreground hover:text-foreground"
              )}
            >
              {s.label}
            </button>
          ))}
        </div>
      )}

      {/* Filtre chip'leri */}
      {filters.map((f) => (
        <button
          key={f.id}
          onClick={() => toggleFilter(f.id)}
          aria-pressed={activeFilters.has(f.id)}
          className={cn(
            "rounded-full border px-2.5 py-1 font-mono text-[10px] uppercase tracking-wider transition-all duration-150 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
            "animate-[sfbChip_0.2s_ease-out] motion-reduce:animate-none",
            activeFilters.has(f.id)
              ? "border-accent bg-accent/10 text-accent"
              : "border-border text-muted-foreground hover:border-foreground/40 hover:text-foreground"
          )}
        >
          {f.label}
        </button>
      ))}
    </div>
  );
}

Props

PropTypeDescription
valuestring
defaultValuestring
onValueChange(value: string) => void
placeholderstring
sorts{ id: string; label: string }[]
activeSortstring
onSortChange(id: string) => void
filters{ id: string; label: string; active?: boolean }[]
onFilterToggle(id: string) => void