Filters
[
{
"id": "1788031874059-69medgmu9",
"field": "priority",
"operator": "is_any_of",
"values": [
"low",
"medium",
"critical"
]
}
]import { Ban, Bell, CircleAlert, CircleCheck, Clock, FunnelX, Globe, ListFilter, Mail, Phone, Star, Type, UserRoundCheck, UserRoundX, Users,} from "lucide-solid";import { type ComponentProps, createSignal, Show } from "solid-js";import { cn } from "~/lib/utils";import { createFilter, type Filter, type FilterFieldConfig, Filters,} from "@/registry/kobalte/blocks/filters";import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";import { Button } from "~/components/ui/button";
const priorityColors: Record<string, string> = { low: "bg-green-500", medium: "bg-yellow-500", high: "bg-violet-500", urgent: "bg-orange-500", critical: "bg-red-500",};
function PriorityDot(props: { priority: string }) { return <div class={cn("size-2.5 shrink-0 rounded-full", priorityColors[props.priority])} />;}
function Portrait(props: { src: string; alt: string; fallback: string }) { return ( <Avatar class="size-5 border"> <AvatarImage src={props.src} alt={props.alt} /> <AvatarFallback>{props.fallback}</AvatarFallback> </Avatar> );}
const countries = [ { code: "AR", name: "Argentina" }, { code: "AU", name: "Australia" }, { code: "AT", name: "Austria" }, { code: "BE", name: "Belgium" }, { code: "BR", name: "Brazil" }, { code: "CA", name: "Canada" }, { code: "CL", name: "Chile" }, { code: "CN", name: "China" }, { code: "CO", name: "Colombia" }, { code: "CZ", name: "Czech Republic" }, { code: "DK", name: "Denmark" }, { code: "EG", name: "Egypt" }, { code: "FI", name: "Finland" }, { code: "FR", name: "France" }, { code: "DE", name: "Germany" }, { code: "GR", name: "Greece" }, { code: "IN", name: "India" }, { code: "ID", name: "Indonesia" }, { code: "IE", name: "Ireland" }, { code: "IL", name: "Israel" }, { code: "IT", name: "Italy" }, { code: "JP", name: "Japan" }, { code: "KE", name: "Kenya" }, { code: "MX", name: "Mexico" }, { code: "MA", name: "Morocco" }, { code: "NL", name: "Netherlands" }, { code: "NZ", name: "New Zealand" }, { code: "NG", name: "Nigeria" }, { code: "NO", name: "Norway" }, { code: "PL", name: "Poland" }, { code: "PT", name: "Portugal" }, { code: "ZA", name: "South Africa" }, { code: "KR", name: "South Korea" }, { code: "ES", name: "Spain" }, { code: "SE", name: "Sweden" }, { code: "CH", name: "Switzerland" }, { code: "TR", name: "Turkey" }, { code: "AE", name: "United Arab Emirates" }, { code: "GB", name: "United Kingdom" }, { code: "US", name: "United States" }, { code: "VN", name: "Vietnam" },];
// Solid has no `cloneElement`, so a custom trigger is a component that Kobalte// renders through the polymorphic `as` prop — spread the props it receives.function AddFilterTrigger(props: ComponentProps<typeof Button>) { return ( <Button variant="outline" {...props}> <ListFilter /> Add Filter </Button> );}
export default function FiltersDemo() { const fields: FilterFieldConfig[] = [ { group: "Basic", fields: [ { key: "text", label: "Text", type: "text", icon: () => <Mail class="size-3.5" />, placeholder: "Search text...", }, { key: "email", label: "Email", type: "text", icon: () => <Type class="size-3.5" />, placeholder: "user@example.com", }, { key: "website", label: "Website", type: "text", icon: () => <Globe class="size-3.5" />, placeholder: "https://example.com", }, { key: "phone", label: "Phone", type: "text", icon: () => <Phone class="size-3.5" />, placeholder: "+1 (123) 456-7890", }, ], }, { group: "Select", fields: [ { key: "status", label: "Status", type: "select", icon: () => <Bell class="size-3.5" />, searchable: false, class: "w-[200px]", options: [ { value: "todo", label: "To Do", icon: () => <Clock class="size-4 stroke-violet-500" />, }, { value: "in-progress", label: "In Progress", icon: () => <CircleAlert class="size-4 stroke-yellow-500" />, }, { value: "done", label: "Done", icon: () => <CircleCheck class="size-4 stroke-green-500" />, }, { value: "cancelled", label: "Cancelled", icon: () => <Ban class="size-4 stroke-destructive" />, }, ], }, { key: "priority", label: "Priority", type: "multiselect", icon: () => <Ban class="size-3.5" />, class: "w-[180px]", options: [ { value: "low", label: "Low", icon: () => <PriorityDot priority="low" /> }, { value: "medium", label: "Medium", icon: () => <PriorityDot priority="medium" /> }, { value: "high", label: "High", icon: () => <PriorityDot priority="high" /> }, { value: "urgent", label: "Urgent", icon: () => <PriorityDot priority="urgent" /> }, { value: "critical", label: "Critical", icon: () => <PriorityDot priority="critical" />, }, ], }, { key: "assignee", label: "Assignee", type: "multiselect", icon: () => <UserRoundCheck class="size-3.5" />, maxSelections: 5, options: [ { value: "john", label: "John Doe", icon: () => ( <Portrait src="https://randomuser.me/api/portraits/men/1.jpg" alt="John Doe" fallback="JD" /> ), }, { value: "jane", label: "Jane Smith", icon: () => ( <Portrait src="https://randomuser.me/api/portraits/women/2.jpg" alt="Jane Smith" fallback="JS" /> ), }, { value: "bob", label: "Bob Johnson", icon: () => ( <Portrait src="https://randomuser.me/api/portraits/men/3.jpg" alt="Bob Johnson" fallback="BJ" /> ), }, { value: "alice", label: "Alice Brown", icon: () => ( <Portrait src="https://randomuser.me/api/portraits/women/4.jpg" alt="Alice Brown" fallback="AB" /> ), }, { value: "nick", label: "Nick Bold", icon: () => ( <Portrait src="https://randomuser.me/api/portraits/men/4.jpg" alt="Nick Bold" fallback="NB" /> ), }, { value: "sarah", label: "Sarah Wilson", icon: () => ( <Portrait src="https://randomuser.me/api/portraits/women/5.jpg" alt="Sarah Wilson" fallback="SW" /> ), }, { value: "unassigned", label: "Unassigned", icon: () => ( <Avatar class="size-5 border"> <AvatarFallback> <UserRoundX class="size-3" /> </AvatarFallback> </Avatar> ), }, ], }, { key: "userType", label: "User Type", type: "select", icon: () => <Users class="size-3.5" />, searchable: false, class: "w-[200px]", options: [ { value: "premium", label: "Premium", icon: () => <Star class="size-3 text-yellow-500" />, }, { value: "standard", label: "Standard", icon: () => <Users class="size-3 text-blue-500" />, }, { value: "trial", label: "Trial", icon: () => <Clock class="size-3 text-gray-500" /> }, ], }, { key: "country", label: "Country", type: "select", icon: () => <Globe class="size-3.5" />, searchable: true, class: "w-[220px]", options: countries.map((country) => ({ value: country.code, label: country.name, icon: () => ( <img src={`https://flagcdn.com/${country.code.toLowerCase()}.svg`} alt={country.code} class="size-4 rounded-full object-cover" /> ), })), }, ], }, ];
const [filters, setFilters] = createSignal<Filter[]>([ createFilter("priority", "is_any_of", ["low", "medium", "critical"]), ]);
return ( <div class="flex grow content-start items-start gap-2.5 self-start"> <div class="grow space-y-5"> <div class="flex items-start gap-2.5"> <div class="flex-1"> <Filters filters={filters()} fields={fields} onChange={setFilters} enableShortcut shortcutKey="f" shortcutLabel="F" trigger={AddFilterTrigger} /> </div>
<Show when={filters().length > 0}> <Button variant="outline" onClick={() => setFilters([])}> <FunnelX /> Clear </Button> </Show> </div>
<pre class="mt-2 max-h-[400px] w-full max-w-[500px] overflow-auto rounded-md border bg-muted p-3 text-xs dark:bg-muted/60"> {JSON.stringify(filters(), null, 2)} </pre> </div> </div> );}Filters renders a row of active filter chips plus an "Add Filter" menu. Each chip is a segmented control: the field label, an operator dropdown, a value editor, and a remove button. You own the Filter[] array — the component never mutates it, it calls onChange with the next array.
Installation
Usage
import { createSignal } from "solid-js";import { createFilter, Filters, type Filter, type FilterFieldConfig,} from "~/components/blocks/filters";const [filters, setFilters] = createSignal<Filter[]>([ createFilter("priority", "is_any_of", ["low", "medium"]),]);
const fields: FilterFieldConfig[] = [ { key: "priority", label: "Priority", type: "multiselect", options: [ { value: "low", label: "Low" }, { value: "medium", label: "Medium" }, { value: "high", label: "High" }, ], },];
return <Filters filters={filters()} fields={fields} onChange={setFilters} />;Fields can be flat, or grouped by wrapping them in { group, fields } entries. A field of type select or multiselect with options gets a sub-menu in the "Add Filter" list, so a value can be picked without leaving the menu.
Icons
Pass field and option icons as a function, not as an element:
{ key: "priority", label: "Priority", icon: () => <Ban class="size-3.5" /> }Solid evaluates JSX eagerly, so icon: <Ban /> inside a config object creates the element while the config is built — long before it is inserted (most icons live inside a closed menu). Under SSR that consumes a hydration key for a node the server never wrote into the HTML, and hydration fails with Hydration Mismatch. A thunk defers creation to insert time; Solid resolves function children on insert, so both forms render identically on the client and only the thunk is safe when the page is server-rendered.
Examples
Validation
A field's validation returns either a boolean or { valid, message }. Invalid input is reported on blur through an inline icon whose tooltip carries the message; typing clears it again. Any schema library works — this example wraps Zod schemas.
import { AtSign, CreditCard, Globe, Link, Phone, User } from "lucide-solid";import { createSignal } from "solid-js";import * as z from "zod";import { createFilter, type Filter, type FilterFieldConfig, Filters,} from "@/registry/kobalte/blocks/filters";
// `validation` may return a boolean or `{ valid, message }`. Wrapping a Zod// schema gives the chip a per-field error message in its tooltip.function zodValidator(schema: z.ZodType) { return (value: unknown): { valid: boolean; message?: string } => { const result = schema.safeParse(value); if (result.success) return { valid: true }; return { valid: false, message: result.error.issues[0]?.message ?? "Invalid value" }; };}
const emailSchema = z .string() .min(1, { message: "Email is required" }) .pipe(z.email({ message: "Please enter a valid email address" }));
const urlSchema = z .string() .pipe(z.url({ message: "Please enter a valid URL (e.g., https://example.com)" }));
const phoneSchema = z .string() .regex(/^\+?[1-9]\d{1,14}$/, { message: "Please enter a valid phone number" });
const usernameSchema = z .string() .min(3, { message: "Username must be at least 3 characters" }) .max(20, { message: "Username must be at most 20 characters" }) .regex(/^[a-zA-Z0-9_]+$/, { message: "Username can only contain letters, numbers, and underscores", });
const creditCardSchema = z .string() .regex(/^\d{13,19}$/, { message: "Please enter a valid credit card number (13-19 digits)" });
export default function FiltersValidation() { const fields: FilterFieldConfig[] = [ { key: "email", label: "Email", type: "text", icon: () => <AtSign class="size-3.5" />, placeholder: "user@example.com", validation: zodValidator(emailSchema), }, { key: "website", label: "Website", type: "text", icon: () => <Globe class="size-3.5" />, placeholder: "https://example.com", validation: zodValidator(urlSchema), }, { key: "phone", label: "Phone", type: "text", icon: () => <Phone class="size-3.5" />, placeholder: "+1234567890", validation: zodValidator(phoneSchema), }, { key: "username", label: "Username", type: "text", icon: () => <User class="size-3.5" />, class: "w-44", placeholder: "john_doe", validation: zodValidator(usernameSchema), }, { key: "cardNumber", label: "Card Number", type: "text", icon: () => <CreditCard class="size-3.5" />, placeholder: "4111111111111111", validation: zodValidator(creditCardSchema), }, { key: "customUrl", label: "Custom URL", type: "text", icon: () => <Link class="size-3.5" />, placeholder: "https://...", // A plain function works just as well as a schema library. validation: (value) => { if (!/^https?:\/\/.+\..+/.test(value as string)) { return { valid: false, message: "URL must start with http:// or https://" }; } return { valid: true }; }, }, ];
const [filters, setFilters] = createSignal<Filter[]>([createFilter("email", "contains", [""])]);
return ( <div class="flex grow content-start items-start self-start"> <Filters filters={filters()} fields={fields} onChange={setFilters} /> </div> );}Trigger Button
Pass trigger to replace the default "Filter" button. Solid has no cloneElement, so trigger takes a component, not an element: Kobalte renders it through its polymorphic as and hands it the trigger props (ref, aria-expanded, handlers) to spread.
function IconTrigger(props: ComponentProps<typeof Button>) { return ( <Button variant="outline" size="icon" {...props}> <ListFilter /> </Button> );}
<Filters filters={filters()} fields={fields} onChange={setFilters} trigger={IconTrigger} />;import { CircleAlert, FunnelX, Globe, ListFilter, Mail, Star, Tag, User } from "lucide-solid";import { type ComponentProps, createSignal, Show } from "solid-js";import { cn } from "~/lib/utils";import { createFilter, type Filter, type FilterFieldConfig, Filters,} from "@/registry/kobalte/blocks/filters";import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";import { Button } from "~/components/ui/button";
const priorityColors: Record<string, string> = { low: "text-green-500", medium: "text-yellow-500", high: "text-orange-500", urgent: "text-red-500",};
function PriorityStar(props: { priority: string }) { return <Star class={cn("size-4", priorityColors[props.priority])} />;}
function Portrait(props: { src: string; alt: string; fallback: string }) { return ( <Avatar class="size-5 border"> <AvatarImage src={props.src} alt={props.alt} /> <AvatarFallback>{props.fallback}</AvatarFallback> </Avatar> );}
// `trigger` takes a component, not an element: Solid has no `cloneElement`, so// Kobalte renders this through its polymorphic `as` and hands it the trigger// props (ref, aria state, handlers) to spread.function IconTrigger(props: ComponentProps<typeof Button>) { return ( <Button variant="outline" size="icon" {...props}> <ListFilter /> </Button> );}
export default function FiltersTrigger() { const fields: FilterFieldConfig[] = [ { key: "text", label: "Text", type: "text", icon: () => <Tag class="size-3.5" />, class: "w-36", placeholder: "Search text...", }, { key: "email", label: "Email", type: "text", icon: () => <Mail class="size-3.5" />, class: "w-40", placeholder: "user@example.com", }, { key: "website", label: "Website", type: "text", icon: () => <Globe class="size-3.5" />, class: "w-40", placeholder: "https://example.com", }, { key: "assignee", label: "Assignee", type: "multiselect", icon: () => <User class="size-3.5" />, class: "w-[200px]", options: [ { value: "john", label: "John Doe", icon: () => ( <Portrait src="https://randomuser.me/api/portraits/men/1.jpg" alt="John Doe" fallback="JD" /> ), }, { value: "jane", label: "Jane Smith", icon: () => ( <Portrait src="https://randomuser.me/api/portraits/women/2.jpg" alt="Jane Smith" fallback="JS" /> ), }, { value: "bob", label: "Bob Johnson", icon: () => ( <Portrait src="https://randomuser.me/api/portraits/men/3.jpg" alt="Bob Johnson" fallback="BJ" /> ), }, { value: "alice", label: "Alice Brown", icon: () => ( <Portrait src="https://randomuser.me/api/portraits/women/4.jpg" alt="Alice Brown" fallback="AB" /> ), }, { value: "nick", label: "Nick Bold", icon: () => ( <Portrait src="https://randomuser.me/api/portraits/men/4.jpg" alt="Nick Bold" fallback="NB" /> ), }, ], }, { key: "priority", label: "Priority", type: "multiselect", icon: () => <CircleAlert class="size-3.5" />, class: "w-[180px]", options: [ { value: "low", label: "Low", icon: () => <PriorityStar priority="low" /> }, { value: "medium", label: "Medium", icon: () => <PriorityStar priority="medium" /> }, { value: "high", label: "High", icon: () => <PriorityStar priority="high" /> }, { value: "urgent", label: "Urgent", icon: () => <PriorityStar priority="urgent" /> }, ], }, ];
const [filters, setFilters] = createSignal<Filter[]>([ createFilter("assignee", "is_any_of", ["john", "nick", "alice"]), ]);
return ( <div class="flex grow content-start items-start gap-2.5 self-start"> <div class="flex-1"> <Filters filters={filters()} fields={fields} onChange={setFilters} trigger={IconTrigger} /> </div>
<Show when={filters().length > 0}> <Button variant="outline" onClick={() => setFilters([])}> <FunnelX /> Clear </Button> </Show> </div> );}Small Size
size scales the chips, the value editors and the gap between them. It does not size the custom trigger — that stays yours to match.
import { Ban, CircleAlert, CircleCheck, Clock, Globe, ListFilter, Mail, Star, Tag,} from "lucide-solid";import { type ComponentProps, createSignal } from "solid-js";import { cn } from "~/lib/utils";import { createFilter, type Filter, type FilterFieldConfig, Filters,} from "@/registry/kobalte/blocks/filters";import { Button } from "~/components/ui/button";
const priorityColors: Record<string, string> = { low: "text-green-500", medium: "text-yellow-500", high: "text-orange-500", urgent: "text-red-500",};
function PriorityStar(props: { priority: string }) { return <Star class={cn("size-4", priorityColors[props.priority])} />;}
function SmallIconTrigger(props: ComponentProps<typeof Button>) { return ( <Button variant="outline" size="icon-sm" {...props}> <ListFilter /> </Button> );}
export default function FiltersSmall() { const fields: FilterFieldConfig[] = [ { key: "text", label: "Text", type: "text", icon: () => <Tag class="size-3.5" />, class: "w-36", placeholder: "Search text...", }, { key: "email", label: "Email", type: "text", icon: () => <Mail class="size-3.5" />, class: "w-48", placeholder: "user@example.com", }, { key: "website", label: "Website", type: "text", icon: () => <Globe class="size-3.5" />, class: "w-40", placeholder: "https://example.com", }, { key: "status", label: "Status", type: "select", icon: () => <Clock class="size-3.5" />, searchable: false, class: "w-[200px]", options: [ { value: "todo", label: "To Do", icon: () => <Clock class="size-4 text-primary" /> }, { value: "in-progress", label: "In Progress", icon: () => <CircleAlert class="size-4 text-yellow-500" />, }, { value: "done", label: "Done", icon: () => <CircleCheck class="size-4 text-green-500" /> }, { value: "cancelled", label: "Cancelled", icon: () => <Ban class="size-4 text-destructive" />, }, ], }, { key: "priority", label: "Priority", type: "multiselect", icon: () => <CircleAlert class="size-3.5" />, class: "w-[180px]", options: [ { value: "low", label: "Low", icon: () => <PriorityStar priority="low" /> }, { value: "medium", label: "Medium", icon: () => <PriorityStar priority="medium" /> }, { value: "high", label: "High", icon: () => <PriorityStar priority="high" /> }, { value: "urgent", label: "Urgent", icon: () => <PriorityStar priority="urgent" /> }, ], }, ];
const [filters, setFilters] = createSignal<Filter[]>([ createFilter("priority", "is_any_of", ["high", "urgent"]), ]);
return ( <div class="flex grow flex-col content-start items-start gap-2.5 self-start"> <Filters size="sm" filters={filters()} fields={fields} onChange={setFilters} trigger={SmallIconTrigger} /> </div> );}Large Size
import { Ban, CircleAlert, CircleCheck, Clock, Globe, ListFilter, Mail, Star, Tag,} from "lucide-solid";import { type ComponentProps, createSignal } from "solid-js";import { cn } from "~/lib/utils";import { createFilter, type Filter, type FilterFieldConfig, Filters,} from "@/registry/kobalte/blocks/filters";import { Button } from "~/components/ui/button";
const priorityColors: Record<string, string> = { low: "text-green-500", medium: "text-yellow-500", high: "text-orange-500", urgent: "text-red-500",};
function PriorityStar(props: { priority: string }) { return <Star class={cn("size-4", priorityColors[props.priority])} />;}
function LargeIconTrigger(props: ComponentProps<typeof Button>) { return ( <Button variant="outline" size="icon-lg" {...props}> <ListFilter /> </Button> );}
export default function FiltersLarge() { const fields: FilterFieldConfig[] = [ { key: "text", label: "Text", type: "text", icon: () => <Tag class="size-3.5" />, class: "w-36", placeholder: "Search text...", }, { key: "email", label: "Email", type: "text", icon: () => <Mail class="size-3.5" />, class: "w-48", placeholder: "user@example.com", }, { key: "website", label: "Website", type: "text", icon: () => <Globe class="size-3.5" />, class: "w-40", placeholder: "https://example.com", }, { key: "status", label: "Status", type: "select", icon: () => <Clock class="size-3.5" />, searchable: false, class: "w-[200px]", options: [ { value: "todo", label: "To Do", icon: () => <Clock class="size-4 text-primary" /> }, { value: "in-progress", label: "In Progress", icon: () => <CircleAlert class="size-4 text-yellow-500" />, }, { value: "done", label: "Done", icon: () => <CircleCheck class="size-4 text-green-500" /> }, { value: "cancelled", label: "Cancelled", icon: () => <Ban class="size-4 text-destructive" />, }, ], }, { key: "priority", label: "Priority", type: "multiselect", icon: () => <CircleAlert class="size-3.5" />, class: "w-[180px]", options: [ { value: "low", label: "Low", icon: () => <PriorityStar priority="low" /> }, { value: "medium", label: "Medium", icon: () => <PriorityStar priority="medium" /> }, { value: "high", label: "High", icon: () => <PriorityStar priority="high" /> }, { value: "urgent", label: "Urgent", icon: () => <PriorityStar priority="urgent" /> }, ], }, ];
const [filters, setFilters] = createSignal<Filter[]>([ createFilter("email", "contains", ["example@example.com"]), ]);
return ( <div class="flex grow flex-col content-start items-start gap-2.5 self-start"> <Filters size="lg" filters={filters()} fields={fields} onChange={setFilters} trigger={LargeIconTrigger} /> </div> );}Custom Controls
A field of type custom renders whatever customRenderer returns in place of the value editor. The renderer receives { field, values, operator, onChange } and owns its own editing UI — a dialog, a popover calendar, a slider. The props are lazy getters, so reading values inside the control keeps it reactive without recreating it on every change.
import { endOfMonth, endOfYear, format, isSameDay, startOfMonth, startOfYear, subDays, subMonths, subYears,} from "date-fns";import { Calendar as CalendarIcon, Clock, FunnelX, ListFilter, SlidersVertical,} from "lucide-solid";import { type ComponentProps, createSignal, For, onCleanup, onMount, Show } from "solid-js";import { cn } from "~/lib/utils";import { createFilter, type Filter, type FilterFieldConfig, Filters,} from "@/registry/kobalte/blocks/filters";import { Button } from "~/components/ui/button";import { Calendar, type CalendarRangeValue } from "~/components/ui/calendar";import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,} from "~/components/ui/dialog";import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover";import { ScrollArea } from "~/components/ui/scroll-area";import { Slider } from "~/components/ui/slider";
// Every custom control receives the same shape the block passes to// `customRenderer`, plus an `autofocus` flag the demo derives from the filter// that was just added, so a freshly created chip opens its editor by itself.type ControlProps = { values: unknown[]; onChange: (values: unknown[]) => void; autofocus?: boolean;};
const TRIGGER_CLASS = "cursor-default text-start outline-hidden";
const toDate = (value: unknown) => (typeof value === "string" ? new Date(value) : undefined);const toIsoDay = (date: Date) => date.toISOString().split("T")[0];
function useAutoOpen(props: ControlProps, open: (value: boolean) => void) { onMount(() => { if (!props.autofocus) return; const timer = setTimeout(() => open(true), 400); onCleanup(() => clearTimeout(timer)); });}
// A modal editor: the chip only shows the formatted value, the dialog owns the// draft state and commits on Apply.function ModalDateControl(props: ControlProps) { const [open, setOpen] = createSignal(false); const [draft, setDraft] = createSignal<Date | undefined>(toDate(props.values?.[0]));
useAutoOpen(props, setOpen);
const selected = () => toDate(props.values?.[0]);
return ( <Dialog open={open()} onOpenChange={(next) => { if (next) setDraft(selected()); setOpen(next); }} > <button type="button" class={TRIGGER_CLASS} onClick={() => setOpen(true)}> <Show when={selected()} fallback="Select a date"> {(date) => format(date(), "PPP")} </Show> </button> <DialogContent class="sm:max-w-fit"> <DialogHeader> <DialogTitle>Select Date</DialogTitle> </DialogHeader> <Calendar mode="single" selected={draft()} onSelect={setDraft} class="p-0" /> <DialogFooter> <Button variant="outline" onClick={() => setOpen(false)}> Cancel </Button> <Button onClick={() => { const date = draft(); props.onChange(date ? [date.toISOString()] : []); setOpen(false); }} > Apply </Button> </DialogFooter> </DialogContent> </Dialog> );}
function DateRangeControl(props: ControlProps) { const [open, setOpen] = createSignal(false); const [range, setRange] = createSignal<CalendarRangeValue | undefined>({ from: toDate(props.values?.[0]), to: toDate(props.values?.[1]), });
useAutoOpen(props, setOpen);
const apply = () => { const current = range(); if (current?.from) { const from = toIsoDay(current.from); props.onChange([from, current.to ? toIsoDay(current.to) : from]); } setOpen(false); };
return ( <Popover open={open()} onOpenChange={setOpen} placement="bottom-start" gutter={8}> <PopoverTrigger class={TRIGGER_CLASS}> <Show when={range()?.from} fallback={<span>Pick a date range</span>}> {(from) => ( <> {format(from(), "LLL dd, y")} <Show when={range()?.to}>{(to) => ` - ${format(to(), "LLL dd, y")}`}</Show> </> )} </Show> </PopoverTrigger> <PopoverContent class="w-auto p-0"> <Calendar mode="range" defaultMonth={range()?.from} showOutsideDays={false} selected={range()} onSelect={setRange} numberOfMonths={2} /> <div class="flex items-center justify-end gap-1.5 border-border border-t p-3"> <Button variant="outline" onClick={() => setOpen(false)}> Cancel </Button> <Button onClick={apply}>Apply</Button> </div> </PopoverContent> </Popover> );}
function DateRangePresetsControl(props: ControlProps) { const today = new Date(); const presets = [ { label: "Today", range: { from: today, to: today } }, { label: "Yesterday", range: { from: subDays(today, 1), to: subDays(today, 1) } }, { label: "Last 7 days", range: { from: subDays(today, 6), to: today } }, { label: "Last 30 days", range: { from: subDays(today, 29), to: today } }, { label: "Month to date", range: { from: startOfMonth(today), to: today } }, { label: "Last month", range: { from: startOfMonth(subMonths(today, 1)), to: endOfMonth(subMonths(today, 1)) }, }, { label: "Year to date", range: { from: startOfYear(today), to: today } }, { label: "Last year", range: { from: startOfYear(subYears(today, 1)), to: endOfYear(subYears(today, 1)) }, }, ];
const [open, setOpen] = createSignal(false); const [month, setMonth] = createSignal(today); const [range, setRange] = createSignal<CalendarRangeValue | undefined>({ from: toDate(props.values?.[0]), to: toDate(props.values?.[1]), });
useAutoOpen(props, setOpen);
// Derived, not stored: the active preset is whichever one matches the range. const activePreset = () => { const current = range(); if (!current?.from || !current.to) return null; return ( presets.find( (preset) => isSameDay(preset.range.from, current.from as Date) && isSameDay(preset.range.to, current.to as Date), )?.label ?? null ); };
const apply = () => { const current = range(); if (current?.from) { const from = toIsoDay(current.from); props.onChange([from, current.to ? toIsoDay(current.to) : from]); } setOpen(false); };
return ( <Popover open={open()} onOpenChange={setOpen} placement="bottom" gutter={8}> <PopoverTrigger class={TRIGGER_CLASS}> <Show when={range()?.from} fallback={<span>Pick a date range with presets</span>}> {(from) => ( <> {format(from(), "LLL dd, y")} <Show when={range()?.to}>{(to) => ` - ${format(to(), "LLL dd, y")}`}</Show> </> )} </Show> </PopoverTrigger> <PopoverContent class="w-auto p-0"> <div class="flex max-sm:flex-col"> <div class="relative border-border max-sm:order-1 max-sm:border-t sm:w-32"> <div class="h-full border-border py-2 sm:border-e"> <div class="flex flex-col gap-[2px] px-2"> <For each={presets}> {(preset) => ( <Button type="button" variant="ghost" class={cn( "h-8 w-full justify-start", activePreset() === preset.label && "bg-accent", )} onClick={() => { setRange(preset.range); setMonth(preset.range.from); }} > {preset.label} </Button> )} </For> </div> </div> </div> <Calendar mode="range" month={month()} onMonthChange={setMonth} showOutsideDays={false} selected={range()} onSelect={setRange} numberOfMonths={2} /> </div> <div class="flex items-center justify-end gap-1.5 border-border border-t p-3"> <Button variant="outline" onClick={() => setOpen(false)}> Cancel </Button> <Button onClick={apply}>Apply</Button> </div> </PopoverContent> </Popover> );}
const timeSlots = [ { time: "09:00", available: false }, { time: "09:30", available: false }, { time: "10:00", available: true }, { time: "10:30", available: true }, { time: "11:00", available: true }, { time: "11:30", available: true }, { time: "12:00", available: false }, { time: "12:30", available: true }, { time: "13:00", available: true }, { time: "13:30", available: true }, { time: "14:00", available: true }, { time: "14:30", available: false }, { time: "15:00", available: false }, { time: "15:30", available: true }, { time: "16:00", available: true }, { time: "16:30", available: true }, { time: "17:00", available: true }, { time: "17:30", available: true },];
function DateTimeControl(props: ControlProps) { const initial = toDate(props.values?.[0]); const [open, setOpen] = createSignal(false); const [date, setDate] = createSignal<Date | undefined>(initial); const [time, setTime] = createSignal<string | undefined>( initial ? initial.toTimeString().slice(0, 5) : "10:00", );
useAutoOpen(props, setOpen);
const apply = () => { const day = date(); const slot = time(); if (day && slot) { const [hours, minutes] = slot.split(":").map(Number); const dateTime = new Date(day); dateTime.setHours(hours, minutes, 0, 0); props.onChange([dateTime.toISOString()]); } setOpen(false); };
return ( <Popover open={open()} onOpenChange={setOpen} placement="bottom-start" gutter={8}> <PopoverTrigger class={TRIGGER_CLASS}> <Show when={date()} fallback={<span>Pick a date and time</span>}> {(day) => ( <> {format(day(), "PPP")} <Show when={time()}>{(slot) => ` - ${slot()}`}</Show> </> )} </Show> </PopoverTrigger> <PopoverContent class="w-auto gap-0 p-0 pt-1"> <div class="flex max-sm:flex-col"> <Calendar mode="single" selected={date()} onSelect={setDate} class="p-2 sm:pe-5" disabled={{ before: new Date() }} /> <div class="relative w-full max-sm:h-46 sm:w-40"> <div class="absolute inset-0 py-4 max-sm:border-t"> <ScrollArea class="h-full sm:border-s"> <div class="space-y-3"> <div class="flex h-5 shrink-0 items-center px-5"> <p class="font-medium text-sm"> <Show when={date()} fallback="Pick a date"> {(day) => format(day(), "EEEE, d")} </Show> </p> </div> <div class="grid gap-1.5 px-5 max-sm:grid-cols-2"> <For each={timeSlots}> {(slot) => ( <Button variant={time() === slot.time ? "default" : "outline"} size="sm" class="w-full" disabled={!slot.available} onClick={() => setTime(slot.time)} > {slot.time} </Button> )} </For> </div> </div> </ScrollArea> </div> </div> </div> <div class="flex items-center justify-end gap-1.5 border-border border-t p-3"> <Button variant="outline" onClick={() => setOpen(false)}> Cancel </Button> <Button onClick={apply}>Apply</Button> </div> </PopoverContent> </Popover> );}
function SliderRangeControl(props: ControlProps) { const initial = props.values?.[0]; const [open, setOpen] = createSignal(false); const [range, setRange] = createSignal<number[]>( initial && typeof initial === "object" && "min" in initial && "max" in initial ? [ (initial as { min: number; max: number }).min, (initial as { min: number; max: number }).max, ] : [0, 100], );
useAutoOpen(props, setOpen);
return ( <Popover open={open()} onOpenChange={setOpen} placement="bottom-start" gutter={8}> <PopoverTrigger class={TRIGGER_CLASS}> {range()[0]} - {range()[1]} </PopoverTrigger> <PopoverContent class="w-auto p-4"> <div class="space-y-2.5"> <div class="space-y-4 pt-2.5"> <Slider value={range()} onChange={setRange} minValue={0} maxValue={100} step={1} class="w-[200px]" /> <div class="flex justify-between ps-1.5 text-muted-foreground text-xs"> <span>0</span> <span>100</span> </div> </div> <div class="flex items-center justify-end gap-1.5"> <Button variant="ghost" size="sm" onClick={() => setOpen(false)}> Cancel </Button> <Button size="sm" variant="outline" onClick={() => { props.onChange([{ min: range()[0], max: range()[1] }]); setOpen(false); }} > Apply </Button> </div> </div> </PopoverContent> </Popover> );}
function IconTrigger(props: ComponentProps<typeof Button>) { return ( <Button variant="outline" size="icon" {...props}> <ListFilter /> </Button> );}
export default function FiltersCustomControls() { const [filters, setFilters] = createSignal<Filter[]>([ createFilter("customDateRange", "between", []), ]); const [lastAddedValues, setLastAddedValues] = createSignal<unknown[] | null>(null);
const fields: FilterFieldConfig[] = [ { key: "modalDate", label: "Modal Date", type: "custom", icon: () => <CalendarIcon class="size-3.5" />, operators: [ { value: "is", label: "is" }, { value: "is_not", label: "is not" }, ], customRenderer: (renderer) => ( <ModalDateControl values={renderer.values} onChange={renderer.onChange} autofocus={renderer.values === lastAddedValues()} /> ), }, { key: "customDateRange", label: "Date Range", type: "custom", icon: () => <CalendarIcon class="size-3.5" />, operators: [ { value: "between", label: "between" }, { value: "not_between", label: "not between" }, ], customRenderer: (renderer) => ( <DateRangeControl values={renderer.values} onChange={renderer.onChange} autofocus={renderer.values === lastAddedValues()} /> ), }, { key: "customDateRangePresets", label: "Date Range Presets", type: "custom", icon: () => <CalendarIcon class="size-3.5" />, operators: [ { value: "between", label: "between" }, { value: "not_between", label: "not between" }, ], customRenderer: (renderer) => ( <DateRangePresetsControl values={renderer.values} onChange={renderer.onChange} autofocus={renderer.values === lastAddedValues()} /> ), }, { key: "customDateTime", label: "Date & Time", type: "custom", icon: () => <Clock class="size-3.5" />, operators: [ { value: "is", label: "is" }, { value: "before", label: "before" }, { value: "after", label: "after" }, ], customRenderer: (renderer) => ( <DateTimeControl values={renderer.values} onChange={renderer.onChange} autofocus={renderer.values === lastAddedValues()} /> ), }, { key: "customSliderRange", label: "Slider Range", type: "custom", icon: () => <SlidersVertical class="size-3.5" />, class: "w-36", operators: [ { value: "between", label: "between" }, { value: "not_between", label: "not between" }, ], customRenderer: (renderer) => ( <SliderRangeControl values={renderer.values} onChange={renderer.onChange} autofocus={renderer.values === lastAddedValues()} /> ), }, ];
const handleChange = (next: Filter[]) => { const added = next.find((filter) => !filters().some((current) => current.id === filter.id)); if (added) setLastAddedValues(added.values); setFilters(next); };
return ( <div class="flex grow content-start items-start gap-2.5 self-start"> <div class="flex-1"> <Filters filters={filters()} fields={fields} onChange={handleChange} trigger={IconTrigger} /> </div>
<Show when={filters().length > 0}> <Button variant="outline" onClick={() => setFilters([])}> <FunnelX /> Clear </Button> </Show> </div> );}Data Table
The filter bar is independent of what it filters. Here it drives a table through a simulated server round trip: the bar stays interactive while the rows show skeletons.
import { Building, FunnelX, ListFilter, Mail, MapPin, User } from "lucide-solid";import { type ComponentProps, createSignal, For, Index, Show } from "solid-js";import { createFilter, type Filter, type FilterFieldConfig, Filters,} from "@/registry/kobalte/blocks/filters";import { Alert, AlertTitle } from "~/components/ui/alert";import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";import { Badge } from "~/components/ui/badge";import { Button } from "~/components/ui/button";import { ScrollArea } from "~/components/ui/scroll-area";import { Skeleton } from "~/components/ui/skeleton";import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "~/components/ui/table";
type Staff = { id: string; name: string; availability: "online" | "away" | "busy" | "offline"; avatar: string; status: "active" | "inactive"; flag: string; email: string; company: string; role: string; location: string; balance: number;};
const staff: Staff[] = [ { id: "1", name: "Alex Johnson", availability: "online", avatar: "https://randomuser.me/api/portraits/men/11.jpg", status: "active", flag: "us", email: "alex@apple.com", company: "Apple", role: "CEO", location: "San Francisco, USA", balance: 5143.03, }, { id: "2", name: "Sarah Chen", availability: "away", avatar: "https://randomuser.me/api/portraits/women/12.jpg", status: "inactive", flag: "gb", email: "sarah@openai.com", company: "OpenAI", role: "CTO", location: "London, UK", balance: 4321.87, }, { id: "3", name: "Michael Rodriguez", availability: "busy", avatar: "https://randomuser.me/api/portraits/men/13.jpg", status: "active", flag: "ca", email: "michael@meta.com", company: "Meta", role: "Designer", location: "Toronto, Canada", balance: 7654.98, }, { id: "4", name: "Emma Wilson", availability: "offline", avatar: "https://randomuser.me/api/portraits/women/14.jpg", status: "inactive", flag: "au", email: "emma@tesla.com", company: "Tesla", role: "Developer", location: "Sydney, Australia", balance: 3456.45, }, { id: "5", name: "David Kim", availability: "online", avatar: "https://randomuser.me/api/portraits/men/15.jpg", status: "active", flag: "kr", email: "david@sap.com", company: "SAP", role: "Product Manager", location: "Seoul, South Korea", balance: 2890.12, }, { id: "6", name: "Laura Mensah", availability: "away", avatar: "https://randomuser.me/api/portraits/women/16.jpg", status: "active", flag: "de", email: "laura@bbva.com", company: "BBVA", role: "Data Scientist", location: "Berlin, Germany", balance: 6120.4, },];
const availabilityColors: Record<Staff["availability"], string> = { online: "bg-green-500", away: "bg-yellow-500", busy: "bg-red-500", offline: "bg-gray-400",};
// A filter whose values are still empty should not narrow anything down yet.const activeFilters = (filters: Filter[]) => filters.filter( (filter) => filter.values.length > 0 && !filter.values.every( (value) => value === null || value === undefined || (typeof value === "string" && !value.trim()), ), );
const applyFilters = (filters: Filter[]) => activeFilters(filters).reduce((rows, filter) => { return rows.filter((row) => { const value = row[filter.field as keyof Staff]; const contains = (needle: unknown) => String(value).toLowerCase().includes(String(needle).toLowerCase());
switch (filter.operator) { case "is": return filter.values.includes(value); case "is_not": return !filter.values.includes(value); case "is_any_of": return filter.values.includes(value); case "is_not_any_of": return !filter.values.includes(value); case "contains": return filter.values.some(contains); case "not_contains": return !filter.values.some(contains); case "starts_with": return filter.values.some((needle) => String(value).toLowerCase().startsWith(String(needle).toLowerCase()), ); case "ends_with": return filter.values.some((needle) => String(value).toLowerCase().endsWith(String(needle).toLowerCase()), ); case "empty": return !value; case "not_empty": return Boolean(value); default: return true; } }); }, staff);
function SmallIconTrigger(props: ComponentProps<typeof Button>) { return ( <Button variant="outline" size="icon-sm" {...props}> <ListFilter /> </Button> );}
export default function FiltersTable() { const fields: FilterFieldConfig[] = [ { key: "name", label: "Name", type: "text", icon: () => <User class="size-3.5" />, class: "w-40", placeholder: "Search names...", }, { key: "email", label: "Email", type: "text", icon: () => <Mail class="size-3.5" />, class: "w-48", placeholder: "user@example.com", }, { key: "company", label: "Company", type: "select", icon: () => <Building class="size-3.5" />, searchable: true, class: "w-[180px]", options: [ { value: "Apple", label: "Apple" }, { value: "OpenAI", label: "OpenAI" }, { value: "Meta", label: "Meta" }, { value: "Tesla", label: "Tesla" }, { value: "SAP", label: "SAP" }, { value: "BBVA", label: "BBVA" }, ], }, { key: "role", label: "Role", type: "select", icon: () => <User class="size-3.5" />, searchable: true, class: "w-[160px]", options: [ { value: "CEO", label: "CEO" }, { value: "CTO", label: "CTO" }, { value: "Designer", label: "Designer" }, { value: "Developer", label: "Developer" }, { value: "Product Manager", label: "Product Manager" }, { value: "Data Scientist", label: "Data Scientist" }, ], }, { key: "status", label: "Status", type: "select", icon: () => <User class="size-3.5" />, searchable: false, class: "w-[140px]", options: [ { value: "active", label: "Active", icon: () => <div class="size-2 rounded-full bg-green-500" />, }, { value: "inactive", label: "Inactive", icon: () => <div class="size-2 rounded-full bg-destructive" />, }, ], }, { key: "availability", label: "Availability", type: "select", icon: () => <User class="size-3.5" />, searchable: false, class: "w-[160px]", options: [ { value: "online", label: "Online", icon: () => <div class="size-2 rounded-full bg-green-500" />, }, { value: "away", label: "Away", icon: () => <div class="size-2 rounded-full bg-yellow-500" />, }, { value: "busy", label: "Busy", icon: () => <div class="size-2 rounded-full bg-red-500" />, }, { value: "offline", label: "Offline", icon: () => <div class="size-2 rounded-full bg-gray-400" />, }, ], }, { key: "location", label: "Location", type: "text", icon: () => <MapPin class="size-3.5" />, class: "w-40", placeholder: "Search locations...", }, ];
const [filters, setFilters] = createSignal<Filter[]>([createFilter("status", "is", ["active"])]); const [rows, setRows] = createSignal<Staff[]>(applyFilters(filters())); const [loading, setLoading] = createSignal(false);
// Stand-in for a server round trip: the filter bar stays interactive while // the table shows skeleton rows. let requestId = 0; const runQuery = async (next: Filter[]) => { const current = ++requestId; setLoading(true); await new Promise((resolve) => setTimeout(resolve, 800 + Math.random() * 700)); if (current !== requestId) return; setRows(applyFilters(next)); setLoading(false); };
const handleChange = (next: Filter[]) => { const before = JSON.stringify(activeFilters(filters())); setFilters(next); if (before === JSON.stringify(activeFilters(next))) return; void runQuery(next); };
return ( <div class="w-full self-start"> <div class="mb-3.5 flex items-start gap-2.5"> <div class="flex-1"> <Filters filters={filters()} fields={fields} onChange={handleChange} size="sm" trigger={SmallIconTrigger} /> </div> <Show when={filters().length > 0}> <Button variant="outline" size="sm" disabled={loading()} onClick={() => { setFilters([]); void runQuery([]); }} > <FunnelX /> Clear </Button> </Show> </div>
<div class="rounded-lg border"> <ScrollArea> <Table> <TableHeader> <TableRow> <TableHead>Staff</TableHead> <TableHead>Company</TableHead> <TableHead>Occupation</TableHead> <TableHead>Status</TableHead> <TableHead>Availability</TableHead> <TableHead>Location</TableHead> <TableHead class="text-right">Balance</TableHead> </TableRow> </TableHeader> <TableBody> <Show when={!loading()} fallback={ <Index each={Array.from({ length: 4 })}> {() => ( <TableRow> <TableCell> <div class="flex items-center gap-3"> <Skeleton class="size-8 rounded-full" /> <div class="space-y-1"> <Skeleton class="h-4 w-24" /> <Skeleton class="h-3 w-16" /> </div> </div> </TableCell> <Index each={Array.from({ length: 6 })}> {() => ( <TableCell> <Skeleton class="h-4 w-16" /> </TableCell> )} </Index> </TableRow> )} </Index> } > <Show when={rows().length > 0} fallback={ <TableRow> <TableCell colSpan={7} class="h-24 text-center text-muted-foreground"> No results. </TableCell> </TableRow> } > <For each={rows()}> {(row) => ( <TableRow> <TableCell> <div class="flex items-center gap-3"> <Avatar class="size-8"> <AvatarImage src={row.avatar} alt={row.name} /> <AvatarFallback> {row.name .split(" ") .map((part) => part[0]) .join("")} </AvatarFallback> </Avatar> <div class="space-y-px"> <div class="font-medium text-foreground">{row.name}</div> <div class="truncate text-muted-foreground text-xs">{row.email}</div> </div> </div> </TableCell> <TableCell>{row.company}</TableCell> <TableCell>{row.role}</TableCell> <TableCell> <Badge variant={row.status === "active" ? "secondary" : "outline"}> {row.status === "active" ? "Active" : "Inactive"} </Badge> </TableCell> <TableCell> <div class="flex items-center gap-1.5"> <div class={`size-2 rounded-full ${availabilityColors[row.availability]}`} /> <span class="capitalize">{row.availability}</span> </div> </TableCell> <TableCell> <div class="flex items-center gap-2"> <img src={`https://flagcdn.com/${row.flag}.svg`} alt={row.flag} class="size-4 rounded-full object-cover" /> <span>{row.location}</span> </div> </TableCell> <TableCell class="text-right font-medium"> ${row.balance.toLocaleString()} </TableCell> </TableRow> )} </For> </Show> </Show> </TableBody> </Table> </ScrollArea> </div>
<Alert class="mt-5"> <AlertTitle>Async mode: simulated API delay of 800-1500ms</AlertTitle> </Alert> </div> );}With i18n Support
i18n takes a Partial<FilterI18nConfig> that is deep-merged onto the English defaults, so a locale only declares what it changes. Operator labels come from operators, input placeholders from placeholders.
import { Building, ChevronDown, CircleCheck, ListFilter, Mail, MapPin, User } from "lucide-solid";import { type ComponentProps, createSignal, For } from "solid-js";import { createFilter, type Filter, type FilterFieldConfig, type FilterI18nConfig, Filters,} from "@/registry/kobalte/blocks/filters";import { Button } from "~/components/ui/button";import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,} from "~/components/ui/dropdown-menu";
type Language = "en" | "es" | "fr" | "de" | "ja";
// `i18n` accepts a partial override: anything omitted falls back to the block's// English defaults, so a locale only has to declare what it changes.const i18nConfigs: Record<Language, Partial<FilterI18nConfig>> = { en: { addFilter: "Add filter", searchFields: "Search fields...", noFieldsFound: "No fields found.", noResultsFound: "No results found.", select: "Select...", selectedCount: "selected", operators: { is: "is", isNot: "is not", isAnyOf: "is any of", isNotAnyOf: "is not any of", includesAll: "includes all", excludesAll: "excludes all", before: "before", after: "after", between: "between", notBetween: "not between", contains: "contains", notContains: "does not contain", startsWith: "starts with", endsWith: "ends with", isExactly: "is exactly", equals: "equals", notEquals: "not equals", greaterThan: "greater than", lessThan: "less than", overlaps: "overlaps", includes: "includes", excludes: "excludes", includesAllOf: "includes all of", includesAnyOf: "includes any of", empty: "is empty", notEmpty: "is not empty", }, placeholders: { enterField: (fieldType) => `Enter ${fieldType}...`, selectField: "Select...", searchField: (fieldName) => `Search ${fieldName.toLowerCase()}...`, enterKey: "Enter key...", enterValue: "Enter value...", }, }, es: { addFilter: "Agregar filtro", searchFields: "Buscar campos...", noFieldsFound: "No se encontraron campos.", noResultsFound: "No se encontraron resultados.", select: "Seleccionar...", selectedCount: "seleccionados", operators: { is: "es", isNot: "no es", isAnyOf: "es cualquiera de", isNotAnyOf: "no es cualquiera de", includesAll: "incluye todos", excludesAll: "excluye todos", before: "antes de", after: "después de", between: "entre", notBetween: "no entre", contains: "contiene", notContains: "no contiene", startsWith: "comienza con", endsWith: "termina con", isExactly: "es exactamente", equals: "igual a", notEquals: "no igual a", greaterThan: "mayor que", lessThan: "menor que", overlaps: "se superpone", includes: "incluye", excludes: "excluye", includesAllOf: "incluye todos de", includesAnyOf: "incluye cualquiera de", empty: "está vacío", notEmpty: "no está vacío", }, placeholders: { enterField: (fieldType) => `Ingrese ${fieldType}...`, selectField: "Seleccionar...", searchField: (fieldName) => `Buscar ${fieldName.toLowerCase()}...`, enterKey: "Ingrese clave...", enterValue: "Ingrese valor...", }, }, fr: { addFilter: "Ajouter un filtre", searchFields: "Rechercher des champs...", noFieldsFound: "Aucun champ trouvé.", noResultsFound: "Aucun résultat trouvé.", select: "Sélectionner...", selectedCount: "sélectionnés", operators: { is: "est", isNot: "n'est pas", isAnyOf: "est l'un de", isNotAnyOf: "n'est pas l'un de", includesAll: "inclut tous", excludesAll: "exclut tous", before: "avant", after: "après", between: "entre", notBetween: "pas entre", contains: "contient", notContains: "ne contient pas", startsWith: "commence par", endsWith: "se termine par", isExactly: "est exactement", equals: "égal à", notEquals: "pas égal à", greaterThan: "supérieur à", lessThan: "inférieur à", overlaps: "se chevauche", includes: "inclut", excludes: "exclut", includesAllOf: "inclut tous de", includesAnyOf: "inclut l'un de", empty: "est vide", notEmpty: "n'est pas vide", }, placeholders: { enterField: (fieldType) => `Entrez ${fieldType}...`, selectField: "Sélectionner...", searchField: (fieldName) => `Rechercher ${fieldName.toLowerCase()}...`, enterKey: "Entrez la clé...", enterValue: "Entrez la valeur...", }, }, de: { addFilter: "Filter hinzufügen", searchFields: "Felder suchen...", noFieldsFound: "Keine Felder gefunden.", noResultsFound: "Keine Ergebnisse gefunden.", select: "Auswählen...", selectedCount: "ausgewählt", operators: { is: "ist", isNot: "ist nicht", isAnyOf: "ist eines von", isNotAnyOf: "ist nicht eines von", includesAll: "enthält alle", excludesAll: "schließt alle aus", before: "vor", after: "nach", between: "zwischen", notBetween: "nicht zwischen", contains: "enthält", notContains: "enthält nicht", startsWith: "beginnt mit", endsWith: "endet mit", isExactly: "ist genau", equals: "gleich", notEquals: "nicht gleich", greaterThan: "größer als", lessThan: "kleiner als", overlaps: "überschneidet sich", includes: "enthält", excludes: "schließt aus", includesAllOf: "enthält alle von", includesAnyOf: "enthält eines von", empty: "ist leer", notEmpty: "ist nicht leer", }, placeholders: { enterField: (fieldType) => `${fieldType} eingeben...`, selectField: "Auswählen...", searchField: (fieldName) => `${fieldName.toLowerCase()} suchen...`, enterKey: "Schlüssel eingeben...", enterValue: "Wert eingeben...", }, }, ja: { addFilter: "フィルターを追加", searchFields: "フィールドを検索...", noFieldsFound: "フィールドが見つかりません。", noResultsFound: "結果が見つかりません。", select: "選択...", selectedCount: "選択済み", operators: { is: "は", isNot: "ではない", isAnyOf: "のいずれか", isNotAnyOf: "のいずれでもない", includesAll: "すべて含む", excludesAll: "すべて除外", before: "より前", after: "より後", between: "の間", notBetween: "の間ではない", contains: "含む", notContains: "含まない", startsWith: "で始まる", endsWith: "で終わる", isExactly: "正確に", equals: "等しい", notEquals: "等しくない", greaterThan: "より大きい", lessThan: "より小さい", overlaps: "重複する", includes: "含む", excludes: "除外", includesAllOf: "すべて含む", includesAnyOf: "いずれか含む", empty: "空", notEmpty: "空でない", }, placeholders: { enterField: (fieldType) => `${fieldType}を入力...`, selectField: "選択...", searchField: (fieldName) => `${fieldName.toLowerCase()}を検索...`, enterKey: "キーを入力...", enterValue: "値を入力...", }, },};
const languages: { value: Language; label: string; flag: string }[] = [ { value: "en", label: "English", flag: "us" }, { value: "es", label: "Español", flag: "es" }, { value: "fr", label: "Français", flag: "fr" }, { value: "de", label: "Deutsch", flag: "de" }, { value: "ja", label: "日本語", flag: "jp" },];
const fieldLabels: Record<Language, Record<string, string>> = { en: { name: "Name", email: "Email", company: "Company", status: "Status", location: "Location", active: "Active", inactive: "Inactive", searchNames: "Search names...", searchLocations: "Search locations...", }, es: { name: "Nombre", email: "Correo electrónico", company: "Empresa", status: "Estado", location: "Ubicación", active: "Activo", inactive: "Inactivo", searchNames: "Buscar nombres...", searchLocations: "Buscar ubicaciones...", }, fr: { name: "Nom", email: "E-mail", company: "Entreprise", status: "Statut", location: "Localisation", active: "Actif", inactive: "Inactif", searchNames: "Rechercher des noms...", searchLocations: "Rechercher des lieux...", }, de: { name: "Name", email: "E-Mail", company: "Unternehmen", status: "Status", location: "Standort", active: "Aktiv", inactive: "Inaktiv", searchNames: "Namen suchen...", searchLocations: "Standorte suchen...", }, ja: { name: "名前", email: "メール", company: "会社", status: "ステータス", location: "場所", active: "アクティブ", inactive: "非アクティブ", searchNames: "名前を検索...", searchLocations: "場所を検索...", },};
function SmallIconTrigger(props: ComponentProps<typeof Button>) { return ( <Button variant="outline" size="icon-sm" {...props}> <ListFilter /> </Button> );}
export default function FiltersI18n() { const [language, setLanguage] = createSignal<Language>("es"); const [filters, setFilters] = createSignal<Filter[]>([createFilter("status", "is", ["active"])]);
const labels = () => fieldLabels[language()];
const fields = (): FilterFieldConfig[] => [ { key: "name", label: labels().name, type: "text", icon: () => <User class="size-3.5" />, class: "w-40", placeholder: labels().searchNames, }, { key: "email", label: labels().email, type: "text", icon: () => <Mail class="size-3.5" />, class: "w-48", placeholder: "user@example.com", }, { key: "company", label: labels().company, type: "select", icon: () => <Building class="size-3.5" />, searchable: true, class: "w-[180px]", options: [ { value: "apple", label: "Apple" }, { value: "openai", label: "OpenAI" }, { value: "meta", label: "Meta" }, { value: "tesla", label: "Tesla" }, ], }, { key: "status", label: labels().status, type: "select", icon: () => <CircleCheck class="size-3.5" />, searchable: false, class: "w-[140px]", options: [ { value: "active", label: labels().active }, { value: "inactive", label: labels().inactive }, ], }, { key: "location", label: labels().location, type: "text", icon: () => <MapPin class="size-3.5" />, class: "w-40", placeholder: labels().searchLocations, }, ];
const currentLanguage = () => languages.find((entry) => entry.value === language());
return ( <div class="flex w-full grow items-start justify-between gap-4 self-start"> <Filters filters={filters()} fields={fields()} onChange={setFilters} size="sm" i18n={i18nConfigs[language()]} trigger={SmallIconTrigger} />
<DropdownMenu placement="bottom-end"> <DropdownMenuTrigger as={Button} variant="outline" size="sm" class="gap-2"> <img src={`https://flagcdn.com/${currentLanguage()?.flag}.svg`} alt={currentLanguage()?.flag} class="size-4 rounded-full object-cover" /> <span>{currentLanguage()?.label}</span> <ChevronDown class="size-4" /> </DropdownMenuTrigger> <DropdownMenuContent> <For each={languages}> {(entry) => ( <DropdownMenuItem class="gap-2" onSelect={() => setLanguage(entry.value)}> <img src={`https://flagcdn.com/${entry.flag}.svg`} alt={entry.flag} class="size-4 rounded-full object-cover" /> <span>{entry.label}</span> </DropdownMenuItem> )} </For> </DropdownMenuContent> </DropdownMenu> </div> );}Virtualized Large Lists
The block ships no windowing dependency. Pass renderOptionList to render the option list yourself — it receives { options, highlightedIndex, renderOption }, and calling renderOption(option, index) keeps each row bound to the block's selection state, highlight and toggle handler.
import { createVirtualizer, defaultRangeExtractor, type Range } from "@tanstack/solid-virtual";import { Package } from "lucide-solid";import { createEffect, createSignal, For } from "solid-js";import { createFilter, type Filter, type FilterFieldConfig, type FilterOption, type FilterOptionListRenderProps, Filters,} from "@/registry/kobalte/blocks/filters";
const ROW_HEIGHT = 32;
// Consumer-owned virtualization wired through the field's `renderOptionList`// slot. The block ships no windowing dependency — you bring your own (here// @tanstack/solid-virtual) and stay bound to its selection and keyboard logic// through `renderOption` and `highlightedIndex`.function VirtualizedOptions(props: FilterOptionListRenderProps) { const [scrollElement, setScrollElement] = createSignal<HTMLDivElement>();
const virtualizer = createVirtualizer({ get count() { return props.options.length; }, getScrollElement: () => scrollElement() ?? null, estimateSize: () => ROW_HEIGHT, overscan: 10, getItemKey: (index) => String(props.options[index]?.value ?? index), // Keep the highlighted row mounted even when scrolled away, so the // combobox's aria-activedescendant never points at an unmounted node. rangeExtractor: (range: Range) => { const indices = new Set(defaultRangeExtractor(range)); if (props.highlightedIndex >= 0 && props.highlightedIndex < props.options.length) { indices.add(props.highlightedIndex); } return Array.from(indices).sort((a, b) => a - b); }, });
createEffect(() => { const index = props.highlightedIndex; if (index >= 0 && index < props.options.length) { virtualizer.scrollToIndex(index, { align: "auto" }); } });
return ( <div ref={setScrollElement} class="max-h-[300px] overflow-y-auto overscroll-contain px-1"> <div class="relative w-full" style={{ height: `${virtualizer.getTotalSize()}px` }}> <For each={virtualizer.getVirtualItems()}> {(row) => ( <div data-index={row.index} class="absolute top-0 left-0 w-full" style={{ transform: `translateY(${row.start}px)` }} > {props.renderOption(props.options[row.index], row.index)} </div> )} </For> </div> </div> );}
const products: FilterOption[] = Array.from({ length: 5000 }, (_, index) => ({ value: `sku-${index + 1}`, label: `Product ${String(index + 1).padStart(4, "0")}`,}));
export default function FiltersVirtualized() { const fields: FilterFieldConfig[] = [ { key: "product", label: "Product", type: "multiselect", icon: () => <Package class="size-3.5" />, options: products, // Bring your own windowing for large lists. renderOptionList: (renderProps) => <VirtualizedOptions {...renderProps} />, }, ];
const [filters, setFilters] = createSignal<Filter[]>([ createFilter("product", "is_any_of", ["sku-42", "sku-1024"]), ]);
return ( <div class="flex grow content-start items-start self-start"> <Filters filters={filters()} fields={fields} onChange={setFilters} /> </div> );}Prefetched Async Options
loadOptions(query) replaces a static options list. Returning the same cached array for every query prefetches the whole remote list once and filters it locally, so the loading state only shows on the first open.
import { Users } from "lucide-solid";import { createSignal } from "solid-js";import { type Filter, type FilterFieldConfig, type FilterOption, Filters,} from "@/registry/kobalte/blocks/filters";
const teams: FilterOption[] = [ { value: "eng", label: "Engineering" }, { value: "design", label: "Design" }, { value: "product", label: "Product" }, { value: "marketing", label: "Marketing" }, { value: "sales", label: "Sales" }, { value: "support", label: "Customer Support" }, { value: "finance", label: "Finance" }, { value: "people", label: "People Ops" }, { value: "legal", label: "Legal" }, { value: "it", label: "IT" }, { value: "data", label: "Data & Analytics" }, { value: "security", label: "Security" },];
export default function FiltersAsyncPrefetch() { // A field can take `loadOptions` instead of a static `options` list. Here it // prefetches the whole remote list once (the query is ignored on the first // call) and caches it, so opening the filter shows a loading state only once. let cache: FilterOption[] | null = null;
const fields: FilterFieldConfig[] = [ { key: "team", label: "Team", type: "multiselect", icon: () => <Users class="size-3.5" />, loadOptions: async (query: string) => { if (!cache) { await new Promise((resolve) => setTimeout(resolve, 600)); cache = teams; } const needle = query.trim().toLowerCase(); return needle ? cache.filter((team) => team.label.toLowerCase().includes(needle)) : cache; }, }, ];
const [filters, setFilters] = createSignal<Filter[]>([]);
return ( <div class="flex grow content-start items-start self-start"> <Filters filters={filters()} fields={fields} onChange={setFilters} /> </div> );}Async Server-side Search
When the list is too large to prefetch, filter by query server-side and return a bounded page. Calls are debounced, out-of-order responses are discarded, and an internal value-to-label cache keeps already-selected chips labelled even when they fall outside the current page. options still seeds the initial view.
import { UserSearch } from "lucide-solid";import { createSignal } from "solid-js";import { createFilter, type Filter, type FilterFieldConfig, type FilterOption, Filters,} from "@/registry/kobalte/blocks/filters";
const firstNames = [ "Alex", "Bailey", "Casey", "Dana", "Emerson", "Finley", "Gray", "Harper", "Indira", "Jordan", "Kai", "Logan", "Morgan", "Noor", "Parker", "Quinn", "Riley", "Sasha", "Taylor", "Umi", "Val", "Wren", "Xan", "Yuki", "Zephyr",];
const lastNames = [ "Ahmed", "Brooks", "Chen", "Diaz", "Evans", "Ferreira", "Gupta", "Hansen", "Ito", "Johnson", "Kowalski", "Lopez", "Mensah", "Novak", "Okafor", "Park",];
// Stands in for a directory too large to prefetch.const directory: FilterOption[] = Array.from({ length: 10000 }, (_, index) => { const first = firstNames[index % firstNames.length]; const last = lastNames[Math.floor(index / firstNames.length) % lastNames.length]; return { value: `user-${index + 1}`, label: `${first} ${last} #${index + 1}` };});
export default function FiltersAsyncSearch() { const fields: FilterFieldConfig[] = [ { key: "assignee", label: "Assignee", type: "multiselect", icon: () => <UserSearch class="size-3.5" />, // Seed only the initially selected value so its chip stays labelled. options: [directory[0]], // Server-side search: debounced by the block, guarded against out-of-order // responses, and cached value -> label so selected chips keep their label. loadOptions: async (query: string) => { await new Promise((resolve) => setTimeout(resolve, 400)); const needle = query.trim().toLowerCase(); const matches = needle ? directory.filter((option) => option.label.toLowerCase().includes(needle)) : directory; return matches.slice(0, 50); }, }, ];
const [filters, setFilters] = createSignal<Filter[]>([ createFilter("assignee", "is_any_of", ["user-1"]), ]);
return ( <div class="flex grow content-start items-start self-start"> <Filters filters={filters()} fields={fields} onChange={setFilters} /> </div> );}Keyboard
- With
enableShortcut, pressingshortcutKey(default F) anywhere outside a text field opens the "Add Filter" menu, andshortcutLabelrenders as a hint inside the search input. - The menu search input is a combobox: ↓ / ↑ move the highlight, Enter adds the highlighted field, → opens its sub-menu, ← closes it, and Esc closes the menu.
- Inside an options list the same keys move and toggle;
aria-activedescendantfollows the highlight, which is why a virtualized list must keep the highlighted row mounted.
API Reference
Filters
The main component for displaying and managing a collection of active filters.
radius is accepted and threaded into context but not read by any part yet, matching upstream.
FiltersContent
Renders only the chips for the current filters, without the "Add Filter" menu. It reads its size, variant and i18n from context, so it must be rendered inside a Filters provider.
FilterFieldConfig
Configuration for an individual filterable field.
FilterOption
Structure for options in select and multiselect fields.
Filter
The structure of an active filter object.
FilterI18nConfig
Configuration for internationalization and custom labels. Pass a partial — omitted keys fall back to the English defaults exported as DEFAULT_I18N.
Helpers
DEFAULT_I18N and DEFAULT_OPERATORS are exported too, so a consumer can start from the defaults instead of retyping them.