ZaidanHomeDocsComponentsBlocksChartsTypesetCreate

Command Palette

Search for a command to run...

GitHub
New
Sections
  • Introduction
  • Components
  • Blocks
  • Installation
  • Customization
  • Dark Mode
  • Typeset
  • Zaidan Skills
  • FAQ
  • Roadmap
  • Changelog
Installation
  • Astro
  • Manual
  • Solid Start
  • TanStack Router
  • TanStack Start
  • Vite
Blocks
  • Data Grid
  • Event Calendar
  • Filters
  • Gantt
  • Image Crop
  • Kanban
  • Message Scroller
  • Questionnaire
  • Sortable
Components
  • Accordion
  • Alert
  • Alert Dialog
  • Aspect Ratio
  • Attachment
  • Avatar
  • Badge
  • Breadcrumb
  • Bubble
  • Button
  • Button Group
  • Calendar
  • Card
  • Carousel
  • Chart
  • Checkbox
  • Collapsible
  • Combobox
  • Command
  • Context Menu
  • Dialog
  • Drawer
  • Dropdown Menu
  • Empty
  • Field
  • Hover Card
  • Input
  • Input Group
  • Input OTP
  • Item
  • Kbd
  • Label
  • Marker
  • Menubar
  • Message
  • Native Select
  • Navigation Menu
  • Pagination
  • Popover
  • Progress
  • Radio Group
  • Resizable
  • Scroll Area
  • Select
  • Separator
  • Sheet
  • Sidebar
  • Skeleton
  • Slider
  • Spinner
  • Switch
  • Table
  • Tabs
  • Textarea
  • Toast
  • Toggle
  • Toggle Group
  • Tooltip

Filters

Priority
[
  {
    "id": "1788031874059-69medgmu9",
    "field": "priority",
    "operator": "is_any_of",
    "values": [
      "low",
      "medium",
      "critical"
    ]
  }
]
1
import {
2
Ban,
3
Bell,
4
CircleAlert,
5
CircleCheck,
6
Clock,
7
FunnelX,
8
Globe,
9
ListFilter,
10
Mail,
11
Phone,
12
Star,
13
Type,
14
UserRoundCheck,
15
UserRoundX,
16
Users,
17
} from "lucide-solid";
18
import { type ComponentProps, createSignal, Show } from "solid-js";
19
import { cn } from "~/lib/utils";
20
import {
21
createFilter,
22
type Filter,
23
type FilterFieldConfig,
24
Filters,
25
} from "@/registry/kobalte/blocks/filters";
26
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
27
import { Button } from "~/components/ui/button";
28
29
const priorityColors: Record<string, string> = {
30
low: "bg-green-500",
31
medium: "bg-yellow-500",
32
high: "bg-violet-500",
33
urgent: "bg-orange-500",
34
critical: "bg-red-500",
35
};
36
37
function PriorityDot(props: { priority: string }) {
38
return <div class={cn("size-2.5 shrink-0 rounded-full", priorityColors[props.priority])} />;
39
}
40
41
function Portrait(props: { src: string; alt: string; fallback: string }) {
42
return (
43
<Avatar class="size-5 border">
44
<AvatarImage src={props.src} alt={props.alt} />
45
<AvatarFallback>{props.fallback}</AvatarFallback>
46
</Avatar>
47
);
48
}
49
50
const countries = [
51
{ code: "AR", name: "Argentina" },
52
{ code: "AU", name: "Australia" },
53
{ code: "AT", name: "Austria" },
54
{ code: "BE", name: "Belgium" },
55
{ code: "BR", name: "Brazil" },
56
{ code: "CA", name: "Canada" },
57
{ code: "CL", name: "Chile" },
58
{ code: "CN", name: "China" },
59
{ code: "CO", name: "Colombia" },
60
{ code: "CZ", name: "Czech Republic" },
61
{ code: "DK", name: "Denmark" },
62
{ code: "EG", name: "Egypt" },
63
{ code: "FI", name: "Finland" },
64
{ code: "FR", name: "France" },
65
{ code: "DE", name: "Germany" },
66
{ code: "GR", name: "Greece" },
67
{ code: "IN", name: "India" },
68
{ code: "ID", name: "Indonesia" },
69
{ code: "IE", name: "Ireland" },
70
{ code: "IL", name: "Israel" },
71
{ code: "IT", name: "Italy" },
72
{ code: "JP", name: "Japan" },
73
{ code: "KE", name: "Kenya" },
74
{ code: "MX", name: "Mexico" },
75
{ code: "MA", name: "Morocco" },
76
{ code: "NL", name: "Netherlands" },
77
{ code: "NZ", name: "New Zealand" },
78
{ code: "NG", name: "Nigeria" },
79
{ code: "NO", name: "Norway" },
80
{ code: "PL", name: "Poland" },
81
{ code: "PT", name: "Portugal" },
82
{ code: "ZA", name: "South Africa" },
83
{ code: "KR", name: "South Korea" },
84
{ code: "ES", name: "Spain" },
85
{ code: "SE", name: "Sweden" },
86
{ code: "CH", name: "Switzerland" },
87
{ code: "TR", name: "Turkey" },
88
{ code: "AE", name: "United Arab Emirates" },
89
{ code: "GB", name: "United Kingdom" },
90
{ code: "US", name: "United States" },
91
{ code: "VN", name: "Vietnam" },
92
];
93
94
// Solid has no `cloneElement`, so a custom trigger is a component that Kobalte
95
// renders through the polymorphic `as` prop — spread the props it receives.
96
function AddFilterTrigger(props: ComponentProps<typeof Button>) {
97
return (
98
<Button variant="outline" {...props}>
99
<ListFilter />
100
Add Filter
101
</Button>
102
);
103
}
104
105
export default function FiltersDemo() {
106
const fields: FilterFieldConfig[] = [
107
{
108
group: "Basic",
109
fields: [
110
{
111
key: "text",
112
label: "Text",
113
type: "text",
114
icon: () => <Mail class="size-3.5" />,
115
placeholder: "Search text...",
116
},
117
{
118
key: "email",
119
label: "Email",
120
type: "text",
121
icon: () => <Type class="size-3.5" />,
122
placeholder: "user@example.com",
123
},
124
{
125
key: "website",
126
label: "Website",
127
type: "text",
128
icon: () => <Globe class="size-3.5" />,
129
placeholder: "https://example.com",
130
},
131
{
132
key: "phone",
133
label: "Phone",
134
type: "text",
135
icon: () => <Phone class="size-3.5" />,
136
placeholder: "+1 (123) 456-7890",
137
},
138
],
139
},
140
{
141
group: "Select",
142
fields: [
143
{
144
key: "status",
145
label: "Status",
146
type: "select",
147
icon: () => <Bell class="size-3.5" />,
148
searchable: false,
149
class: "w-[200px]",
150
options: [
151
{
152
value: "todo",
153
label: "To Do",
154
icon: () => <Clock class="size-4 stroke-violet-500" />,
155
},
156
{
157
value: "in-progress",
158
label: "In Progress",
159
icon: () => <CircleAlert class="size-4 stroke-yellow-500" />,
160
},
161
{
162
value: "done",
163
label: "Done",
164
icon: () => <CircleCheck class="size-4 stroke-green-500" />,
165
},
166
{
167
value: "cancelled",
168
label: "Cancelled",
169
icon: () => <Ban class="size-4 stroke-destructive" />,
170
},
171
],
172
},
173
{
174
key: "priority",
175
label: "Priority",
176
type: "multiselect",
177
icon: () => <Ban class="size-3.5" />,
178
class: "w-[180px]",
179
options: [
180
{ value: "low", label: "Low", icon: () => <PriorityDot priority="low" /> },
181
{ value: "medium", label: "Medium", icon: () => <PriorityDot priority="medium" /> },
182
{ value: "high", label: "High", icon: () => <PriorityDot priority="high" /> },
183
{ value: "urgent", label: "Urgent", icon: () => <PriorityDot priority="urgent" /> },
184
{
185
value: "critical",
186
label: "Critical",
187
icon: () => <PriorityDot priority="critical" />,
188
},
189
],
190
},
191
{
192
key: "assignee",
193
label: "Assignee",
194
type: "multiselect",
195
icon: () => <UserRoundCheck class="size-3.5" />,
196
maxSelections: 5,
197
options: [
198
{
199
value: "john",
200
label: "John Doe",
201
icon: () => (
202
<Portrait
203
src="https://randomuser.me/api/portraits/men/1.jpg"
204
alt="John Doe"
205
fallback="JD"
206
/>
207
),
208
},
209
{
210
value: "jane",
211
label: "Jane Smith",
212
icon: () => (
213
<Portrait
214
src="https://randomuser.me/api/portraits/women/2.jpg"
215
alt="Jane Smith"
216
fallback="JS"
217
/>
218
),
219
},
220
{
221
value: "bob",
222
label: "Bob Johnson",
223
icon: () => (
224
<Portrait
225
src="https://randomuser.me/api/portraits/men/3.jpg"
226
alt="Bob Johnson"
227
fallback="BJ"
228
/>
229
),
230
},
231
{
232
value: "alice",
233
label: "Alice Brown",
234
icon: () => (
235
<Portrait
236
src="https://randomuser.me/api/portraits/women/4.jpg"
237
alt="Alice Brown"
238
fallback="AB"
239
/>
240
),
241
},
242
{
243
value: "nick",
244
label: "Nick Bold",
245
icon: () => (
246
<Portrait
247
src="https://randomuser.me/api/portraits/men/4.jpg"
248
alt="Nick Bold"
249
fallback="NB"
250
/>
251
),
252
},
253
{
254
value: "sarah",
255
label: "Sarah Wilson",
256
icon: () => (
257
<Portrait
258
src="https://randomuser.me/api/portraits/women/5.jpg"
259
alt="Sarah Wilson"
260
fallback="SW"
261
/>
262
),
263
},
264
{
265
value: "unassigned",
266
label: "Unassigned",
267
icon: () => (
268
<Avatar class="size-5 border">
269
<AvatarFallback>
270
<UserRoundX class="size-3" />
271
</AvatarFallback>
272
</Avatar>
273
),
274
},
275
],
276
},
277
{
278
key: "userType",
279
label: "User Type",
280
type: "select",
281
icon: () => <Users class="size-3.5" />,
282
searchable: false,
283
class: "w-[200px]",
284
options: [
285
{
286
value: "premium",
287
label: "Premium",
288
icon: () => <Star class="size-3 text-yellow-500" />,
289
},
290
{
291
value: "standard",
292
label: "Standard",
293
icon: () => <Users class="size-3 text-blue-500" />,
294
},
295
{ value: "trial", label: "Trial", icon: () => <Clock class="size-3 text-gray-500" /> },
296
],
297
},
298
{
299
key: "country",
300
label: "Country",
301
type: "select",
302
icon: () => <Globe class="size-3.5" />,
303
searchable: true,
304
class: "w-[220px]",
305
options: countries.map((country) => ({
306
value: country.code,
307
label: country.name,
308
icon: () => (
309
<img
310
src={`https://flagcdn.com/${country.code.toLowerCase()}.svg`}
311
alt={country.code}
312
class="size-4 rounded-full object-cover"
313
/>
314
),
315
})),
316
},
317
],
318
},
319
];
320
321
const [filters, setFilters] = createSignal<Filter[]>([
322
createFilter("priority", "is_any_of", ["low", "medium", "critical"]),
323
]);
324
325
return (
326
<div class="flex grow content-start items-start gap-2.5 self-start">
327
<div class="grow space-y-5">
328
<div class="flex items-start gap-2.5">
329
<div class="flex-1">
330
<Filters
331
filters={filters()}
332
fields={fields}
333
onChange={setFilters}
334
enableShortcut
335
shortcutKey="f"
336
shortcutLabel="F"
337
trigger={AddFilterTrigger}
338
/>
339
</div>
340
341
<Show when={filters().length > 0}>
342
<Button variant="outline" onClick={() => setFilters([])}>
343
<FunnelX />
344
Clear
345
</Button>
346
</Show>
347
</div>
348
349
<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">
350
{JSON.stringify(filters(), null, 2)}
351
</pre>
352
</div>
353
</div>
354
);
355
}

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

pnpm dlx shadcn@latest add @zaidan/filters
npx shadcn@latest add @zaidan/filters
yarn dlx shadcn@latest add @zaidan/filters
bunx --bun shadcn@latest add @zaidan/filters

Usage

1
import { createSignal } from "solid-js";
2
import {
3
createFilter,
4
Filters,
5
type Filter,
6
type FilterFieldConfig,
7
} from "~/components/blocks/filters";
1
const [filters, setFilters] = createSignal<Filter[]>([
2
createFilter("priority", "is_any_of", ["low", "medium"]),
3
]);
4
5
const fields: FilterFieldConfig[] = [
6
{
7
key: "priority",
8
label: "Priority",
9
type: "multiselect",
10
options: [
11
{ value: "low", label: "Low" },
12
{ value: "medium", label: "Medium" },
13
{ value: "high", label: "High" },
14
],
15
},
16
];
17
18
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:

1
{ 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.

Email
1
import { AtSign, CreditCard, Globe, Link, Phone, User } from "lucide-solid";
2
import { createSignal } from "solid-js";
3
import * as z from "zod";
4
import {
5
createFilter,
6
type Filter,
7
type FilterFieldConfig,
8
Filters,
9
} from "@/registry/kobalte/blocks/filters";
10
11
// `validation` may return a boolean or `{ valid, message }`. Wrapping a Zod
12
// schema gives the chip a per-field error message in its tooltip.
13
function zodValidator(schema: z.ZodType) {
14
return (value: unknown): { valid: boolean; message?: string } => {
15
const result = schema.safeParse(value);
16
if (result.success) return { valid: true };
17
return { valid: false, message: result.error.issues[0]?.message ?? "Invalid value" };
18
};
19
}
20
21
const emailSchema = z
22
.string()
23
.min(1, { message: "Email is required" })
24
.pipe(z.email({ message: "Please enter a valid email address" }));
25
26
const urlSchema = z
27
.string()
28
.pipe(z.url({ message: "Please enter a valid URL (e.g., https://example.com)" }));
29
30
const phoneSchema = z
31
.string()
32
.regex(/^\+?[1-9]\d{1,14}$/, { message: "Please enter a valid phone number" });
33
34
const usernameSchema = z
35
.string()
36
.min(3, { message: "Username must be at least 3 characters" })
37
.max(20, { message: "Username must be at most 20 characters" })
38
.regex(/^[a-zA-Z0-9_]+$/, {
39
message: "Username can only contain letters, numbers, and underscores",
40
});
41
42
const creditCardSchema = z
43
.string()
44
.regex(/^\d{13,19}$/, { message: "Please enter a valid credit card number (13-19 digits)" });
45
46
export default function FiltersValidation() {
47
const fields: FilterFieldConfig[] = [
48
{
49
key: "email",
50
label: "Email",
51
type: "text",
52
icon: () => <AtSign class="size-3.5" />,
53
placeholder: "user@example.com",
54
validation: zodValidator(emailSchema),
55
},
56
{
57
key: "website",
58
label: "Website",
59
type: "text",
60
icon: () => <Globe class="size-3.5" />,
61
placeholder: "https://example.com",
62
validation: zodValidator(urlSchema),
63
},
64
{
65
key: "phone",
66
label: "Phone",
67
type: "text",
68
icon: () => <Phone class="size-3.5" />,
69
placeholder: "+1234567890",
70
validation: zodValidator(phoneSchema),
71
},
72
{
73
key: "username",
74
label: "Username",
75
type: "text",
76
icon: () => <User class="size-3.5" />,
77
class: "w-44",
78
placeholder: "john_doe",
79
validation: zodValidator(usernameSchema),
80
},
81
{
82
key: "cardNumber",
83
label: "Card Number",
84
type: "text",
85
icon: () => <CreditCard class="size-3.5" />,
86
placeholder: "4111111111111111",
87
validation: zodValidator(creditCardSchema),
88
},
89
{
90
key: "customUrl",
91
label: "Custom URL",
92
type: "text",
93
icon: () => <Link class="size-3.5" />,
94
placeholder: "https://...",
95
// A plain function works just as well as a schema library.
96
validation: (value) => {
97
if (!/^https?:\/\/.+\..+/.test(value as string)) {
98
return { valid: false, message: "URL must start with http:// or https://" };
99
}
100
return { valid: true };
101
},
102
},
103
];
104
105
const [filters, setFilters] = createSignal<Filter[]>([createFilter("email", "contains", [""])]);
106
107
return (
108
<div class="flex grow content-start items-start self-start">
109
<Filters filters={filters()} fields={fields} onChange={setFilters} />
110
</div>
111
);
112
}

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.

1
function IconTrigger(props: ComponentProps<typeof Button>) {
2
return (
3
<Button variant="outline" size="icon" {...props}>
4
<ListFilter />
5
</Button>
6
);
7
}
8
9
<Filters filters={filters()} fields={fields} onChange={setFilters} trigger={IconTrigger} />;
Assignee
1
import { CircleAlert, FunnelX, Globe, ListFilter, Mail, Star, Tag, User } from "lucide-solid";
2
import { type ComponentProps, createSignal, Show } from "solid-js";
3
import { cn } from "~/lib/utils";
4
import {
5
createFilter,
6
type Filter,
7
type FilterFieldConfig,
8
Filters,
9
} from "@/registry/kobalte/blocks/filters";
10
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
11
import { Button } from "~/components/ui/button";
12
13
const priorityColors: Record<string, string> = {
14
low: "text-green-500",
15
medium: "text-yellow-500",
16
high: "text-orange-500",
17
urgent: "text-red-500",
18
};
19
20
function PriorityStar(props: { priority: string }) {
21
return <Star class={cn("size-4", priorityColors[props.priority])} />;
22
}
23
24
function Portrait(props: { src: string; alt: string; fallback: string }) {
25
return (
26
<Avatar class="size-5 border">
27
<AvatarImage src={props.src} alt={props.alt} />
28
<AvatarFallback>{props.fallback}</AvatarFallback>
29
</Avatar>
30
);
31
}
32
33
// `trigger` takes a component, not an element: Solid has no `cloneElement`, so
34
// Kobalte renders this through its polymorphic `as` and hands it the trigger
35
// props (ref, aria state, handlers) to spread.
36
function IconTrigger(props: ComponentProps<typeof Button>) {
37
return (
38
<Button variant="outline" size="icon" {...props}>
39
<ListFilter />
40
</Button>
41
);
42
}
43
44
export default function FiltersTrigger() {
45
const fields: FilterFieldConfig[] = [
46
{
47
key: "text",
48
label: "Text",
49
type: "text",
50
icon: () => <Tag class="size-3.5" />,
51
class: "w-36",
52
placeholder: "Search text...",
53
},
54
{
55
key: "email",
56
label: "Email",
57
type: "text",
58
icon: () => <Mail class="size-3.5" />,
59
class: "w-40",
60
placeholder: "user@example.com",
61
},
62
{
63
key: "website",
64
label: "Website",
65
type: "text",
66
icon: () => <Globe class="size-3.5" />,
67
class: "w-40",
68
placeholder: "https://example.com",
69
},
70
{
71
key: "assignee",
72
label: "Assignee",
73
type: "multiselect",
74
icon: () => <User class="size-3.5" />,
75
class: "w-[200px]",
76
options: [
77
{
78
value: "john",
79
label: "John Doe",
80
icon: () => (
81
<Portrait
82
src="https://randomuser.me/api/portraits/men/1.jpg"
83
alt="John Doe"
84
fallback="JD"
85
/>
86
),
87
},
88
{
89
value: "jane",
90
label: "Jane Smith",
91
icon: () => (
92
<Portrait
93
src="https://randomuser.me/api/portraits/women/2.jpg"
94
alt="Jane Smith"
95
fallback="JS"
96
/>
97
),
98
},
99
{
100
value: "bob",
101
label: "Bob Johnson",
102
icon: () => (
103
<Portrait
104
src="https://randomuser.me/api/portraits/men/3.jpg"
105
alt="Bob Johnson"
106
fallback="BJ"
107
/>
108
),
109
},
110
{
111
value: "alice",
112
label: "Alice Brown",
113
icon: () => (
114
<Portrait
115
src="https://randomuser.me/api/portraits/women/4.jpg"
116
alt="Alice Brown"
117
fallback="AB"
118
/>
119
),
120
},
121
{
122
value: "nick",
123
label: "Nick Bold",
124
icon: () => (
125
<Portrait
126
src="https://randomuser.me/api/portraits/men/4.jpg"
127
alt="Nick Bold"
128
fallback="NB"
129
/>
130
),
131
},
132
],
133
},
134
{
135
key: "priority",
136
label: "Priority",
137
type: "multiselect",
138
icon: () => <CircleAlert class="size-3.5" />,
139
class: "w-[180px]",
140
options: [
141
{ value: "low", label: "Low", icon: () => <PriorityStar priority="low" /> },
142
{ value: "medium", label: "Medium", icon: () => <PriorityStar priority="medium" /> },
143
{ value: "high", label: "High", icon: () => <PriorityStar priority="high" /> },
144
{ value: "urgent", label: "Urgent", icon: () => <PriorityStar priority="urgent" /> },
145
],
146
},
147
];
148
149
const [filters, setFilters] = createSignal<Filter[]>([
150
createFilter("assignee", "is_any_of", ["john", "nick", "alice"]),
151
]);
152
153
return (
154
<div class="flex grow content-start items-start gap-2.5 self-start">
155
<div class="flex-1">
156
<Filters filters={filters()} fields={fields} onChange={setFilters} trigger={IconTrigger} />
157
</div>
158
159
<Show when={filters().length > 0}>
160
<Button variant="outline" onClick={() => setFilters([])}>
161
<FunnelX />
162
Clear
163
</Button>
164
</Show>
165
</div>
166
);
167
}

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.

Priority
1
import {
2
Ban,
3
CircleAlert,
4
CircleCheck,
5
Clock,
6
Globe,
7
ListFilter,
8
Mail,
9
Star,
10
Tag,
11
} from "lucide-solid";
12
import { type ComponentProps, createSignal } from "solid-js";
13
import { cn } from "~/lib/utils";
14
import {
15
createFilter,
16
type Filter,
17
type FilterFieldConfig,
18
Filters,
19
} from "@/registry/kobalte/blocks/filters";
20
import { Button } from "~/components/ui/button";
21
22
const priorityColors: Record<string, string> = {
23
low: "text-green-500",
24
medium: "text-yellow-500",
25
high: "text-orange-500",
26
urgent: "text-red-500",
27
};
28
29
function PriorityStar(props: { priority: string }) {
30
return <Star class={cn("size-4", priorityColors[props.priority])} />;
31
}
32
33
function SmallIconTrigger(props: ComponentProps<typeof Button>) {
34
return (
35
<Button variant="outline" size="icon-sm" {...props}>
36
<ListFilter />
37
</Button>
38
);
39
}
40
41
export default function FiltersSmall() {
42
const fields: FilterFieldConfig[] = [
43
{
44
key: "text",
45
label: "Text",
46
type: "text",
47
icon: () => <Tag class="size-3.5" />,
48
class: "w-36",
49
placeholder: "Search text...",
50
},
51
{
52
key: "email",
53
label: "Email",
54
type: "text",
55
icon: () => <Mail class="size-3.5" />,
56
class: "w-48",
57
placeholder: "user@example.com",
58
},
59
{
60
key: "website",
61
label: "Website",
62
type: "text",
63
icon: () => <Globe class="size-3.5" />,
64
class: "w-40",
65
placeholder: "https://example.com",
66
},
67
{
68
key: "status",
69
label: "Status",
70
type: "select",
71
icon: () => <Clock class="size-3.5" />,
72
searchable: false,
73
class: "w-[200px]",
74
options: [
75
{ value: "todo", label: "To Do", icon: () => <Clock class="size-4 text-primary" /> },
76
{
77
value: "in-progress",
78
label: "In Progress",
79
icon: () => <CircleAlert class="size-4 text-yellow-500" />,
80
},
81
{ value: "done", label: "Done", icon: () => <CircleCheck class="size-4 text-green-500" /> },
82
{
83
value: "cancelled",
84
label: "Cancelled",
85
icon: () => <Ban class="size-4 text-destructive" />,
86
},
87
],
88
},
89
{
90
key: "priority",
91
label: "Priority",
92
type: "multiselect",
93
icon: () => <CircleAlert class="size-3.5" />,
94
class: "w-[180px]",
95
options: [
96
{ value: "low", label: "Low", icon: () => <PriorityStar priority="low" /> },
97
{ value: "medium", label: "Medium", icon: () => <PriorityStar priority="medium" /> },
98
{ value: "high", label: "High", icon: () => <PriorityStar priority="high" /> },
99
{ value: "urgent", label: "Urgent", icon: () => <PriorityStar priority="urgent" /> },
100
],
101
},
102
];
103
104
const [filters, setFilters] = createSignal<Filter[]>([
105
createFilter("priority", "is_any_of", ["high", "urgent"]),
106
]);
107
108
return (
109
<div class="flex grow flex-col content-start items-start gap-2.5 self-start">
110
<Filters
111
size="sm"
112
filters={filters()}
113
fields={fields}
114
onChange={setFilters}
115
trigger={SmallIconTrigger}
116
/>
117
</div>
118
);
119
}

Large Size

Email
1
import {
2
Ban,
3
CircleAlert,
4
CircleCheck,
5
Clock,
6
Globe,
7
ListFilter,
8
Mail,
9
Star,
10
Tag,
11
} from "lucide-solid";
12
import { type ComponentProps, createSignal } from "solid-js";
13
import { cn } from "~/lib/utils";
14
import {
15
createFilter,
16
type Filter,
17
type FilterFieldConfig,
18
Filters,
19
} from "@/registry/kobalte/blocks/filters";
20
import { Button } from "~/components/ui/button";
21
22
const priorityColors: Record<string, string> = {
23
low: "text-green-500",
24
medium: "text-yellow-500",
25
high: "text-orange-500",
26
urgent: "text-red-500",
27
};
28
29
function PriorityStar(props: { priority: string }) {
30
return <Star class={cn("size-4", priorityColors[props.priority])} />;
31
}
32
33
function LargeIconTrigger(props: ComponentProps<typeof Button>) {
34
return (
35
<Button variant="outline" size="icon-lg" {...props}>
36
<ListFilter />
37
</Button>
38
);
39
}
40
41
export default function FiltersLarge() {
42
const fields: FilterFieldConfig[] = [
43
{
44
key: "text",
45
label: "Text",
46
type: "text",
47
icon: () => <Tag class="size-3.5" />,
48
class: "w-36",
49
placeholder: "Search text...",
50
},
51
{
52
key: "email",
53
label: "Email",
54
type: "text",
55
icon: () => <Mail class="size-3.5" />,
56
class: "w-48",
57
placeholder: "user@example.com",
58
},
59
{
60
key: "website",
61
label: "Website",
62
type: "text",
63
icon: () => <Globe class="size-3.5" />,
64
class: "w-40",
65
placeholder: "https://example.com",
66
},
67
{
68
key: "status",
69
label: "Status",
70
type: "select",
71
icon: () => <Clock class="size-3.5" />,
72
searchable: false,
73
class: "w-[200px]",
74
options: [
75
{ value: "todo", label: "To Do", icon: () => <Clock class="size-4 text-primary" /> },
76
{
77
value: "in-progress",
78
label: "In Progress",
79
icon: () => <CircleAlert class="size-4 text-yellow-500" />,
80
},
81
{ value: "done", label: "Done", icon: () => <CircleCheck class="size-4 text-green-500" /> },
82
{
83
value: "cancelled",
84
label: "Cancelled",
85
icon: () => <Ban class="size-4 text-destructive" />,
86
},
87
],
88
},
89
{
90
key: "priority",
91
label: "Priority",
92
type: "multiselect",
93
icon: () => <CircleAlert class="size-3.5" />,
94
class: "w-[180px]",
95
options: [
96
{ value: "low", label: "Low", icon: () => <PriorityStar priority="low" /> },
97
{ value: "medium", label: "Medium", icon: () => <PriorityStar priority="medium" /> },
98
{ value: "high", label: "High", icon: () => <PriorityStar priority="high" /> },
99
{ value: "urgent", label: "Urgent", icon: () => <PriorityStar priority="urgent" /> },
100
],
101
},
102
];
103
104
const [filters, setFilters] = createSignal<Filter[]>([
105
createFilter("email", "contains", ["example@example.com"]),
106
]);
107
108
return (
109
<div class="flex grow flex-col content-start items-start gap-2.5 self-start">
110
<Filters
111
size="lg"
112
filters={filters()}
113
fields={fields}
114
onChange={setFilters}
115
trigger={LargeIconTrigger}
116
/>
117
</div>
118
);
119
}

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.

Date Range
1
import {
2
endOfMonth,
3
endOfYear,
4
format,
5
isSameDay,
6
startOfMonth,
7
startOfYear,
8
subDays,
9
subMonths,
10
subYears,
11
} from "date-fns";
12
import {
13
Calendar as CalendarIcon,
14
Clock,
15
FunnelX,
16
ListFilter,
17
SlidersVertical,
18
} from "lucide-solid";
19
import { type ComponentProps, createSignal, For, onCleanup, onMount, Show } from "solid-js";
20
import { cn } from "~/lib/utils";
21
import {
22
createFilter,
23
type Filter,
24
type FilterFieldConfig,
25
Filters,
26
} from "@/registry/kobalte/blocks/filters";
27
import { Button } from "~/components/ui/button";
28
import { Calendar, type CalendarRangeValue } from "~/components/ui/calendar";
29
import {
30
Dialog,
31
DialogContent,
32
DialogFooter,
33
DialogHeader,
34
DialogTitle,
35
} from "~/components/ui/dialog";
36
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover";
37
import { ScrollArea } from "~/components/ui/scroll-area";
38
import { Slider } from "~/components/ui/slider";
39
40
// Every custom control receives the same shape the block passes to
41
// `customRenderer`, plus an `autofocus` flag the demo derives from the filter
42
// that was just added, so a freshly created chip opens its editor by itself.
43
type ControlProps = {
44
values: unknown[];
45
onChange: (values: unknown[]) => void;
46
autofocus?: boolean;
47
};
48
49
const TRIGGER_CLASS = "cursor-default text-start outline-hidden";
50
51
const toDate = (value: unknown) => (typeof value === "string" ? new Date(value) : undefined);
52
const toIsoDay = (date: Date) => date.toISOString().split("T")[0];
53
54
function useAutoOpen(props: ControlProps, open: (value: boolean) => void) {
55
onMount(() => {
56
if (!props.autofocus) return;
57
const timer = setTimeout(() => open(true), 400);
58
onCleanup(() => clearTimeout(timer));
59
});
60
}
61
62
// A modal editor: the chip only shows the formatted value, the dialog owns the
63
// draft state and commits on Apply.
64
function ModalDateControl(props: ControlProps) {
65
const [open, setOpen] = createSignal(false);
66
const [draft, setDraft] = createSignal<Date | undefined>(toDate(props.values?.[0]));
67
68
useAutoOpen(props, setOpen);
69
70
const selected = () => toDate(props.values?.[0]);
71
72
return (
73
<Dialog
74
open={open()}
75
onOpenChange={(next) => {
76
if (next) setDraft(selected());
77
setOpen(next);
78
}}
79
>
80
<button type="button" class={TRIGGER_CLASS} onClick={() => setOpen(true)}>
81
<Show when={selected()} fallback="Select a date">
82
{(date) => format(date(), "PPP")}
83
</Show>
84
</button>
85
<DialogContent class="sm:max-w-fit">
86
<DialogHeader>
87
<DialogTitle>Select Date</DialogTitle>
88
</DialogHeader>
89
<Calendar mode="single" selected={draft()} onSelect={setDraft} class="p-0" />
90
<DialogFooter>
91
<Button variant="outline" onClick={() => setOpen(false)}>
92
Cancel
93
</Button>
94
<Button
95
onClick={() => {
96
const date = draft();
97
props.onChange(date ? [date.toISOString()] : []);
98
setOpen(false);
99
}}
100
>
101
Apply
102
</Button>
103
</DialogFooter>
104
</DialogContent>
105
</Dialog>
106
);
107
}
108
109
function DateRangeControl(props: ControlProps) {
110
const [open, setOpen] = createSignal(false);
111
const [range, setRange] = createSignal<CalendarRangeValue | undefined>({
112
from: toDate(props.values?.[0]),
113
to: toDate(props.values?.[1]),
114
});
115
116
useAutoOpen(props, setOpen);
117
118
const apply = () => {
119
const current = range();
120
if (current?.from) {
121
const from = toIsoDay(current.from);
122
props.onChange([from, current.to ? toIsoDay(current.to) : from]);
123
}
124
setOpen(false);
125
};
126
127
return (
128
<Popover open={open()} onOpenChange={setOpen} placement="bottom-start" gutter={8}>
129
<PopoverTrigger class={TRIGGER_CLASS}>
130
<Show when={range()?.from} fallback={<span>Pick a date range</span>}>
131
{(from) => (
132
<>
133
{format(from(), "LLL dd, y")}
134
<Show when={range()?.to}>{(to) => ` - ${format(to(), "LLL dd, y")}`}</Show>
135
</>
136
)}
137
</Show>
138
</PopoverTrigger>
139
<PopoverContent class="w-auto p-0">
140
<Calendar
141
mode="range"
142
defaultMonth={range()?.from}
143
showOutsideDays={false}
144
selected={range()}
145
onSelect={setRange}
146
numberOfMonths={2}
147
/>
148
<div class="flex items-center justify-end gap-1.5 border-border border-t p-3">
149
<Button variant="outline" onClick={() => setOpen(false)}>
150
Cancel
151
</Button>
152
<Button onClick={apply}>Apply</Button>
153
</div>
154
</PopoverContent>
155
</Popover>
156
);
157
}
158
159
function DateRangePresetsControl(props: ControlProps) {
160
const today = new Date();
161
const presets = [
162
{ label: "Today", range: { from: today, to: today } },
163
{ label: "Yesterday", range: { from: subDays(today, 1), to: subDays(today, 1) } },
164
{ label: "Last 7 days", range: { from: subDays(today, 6), to: today } },
165
{ label: "Last 30 days", range: { from: subDays(today, 29), to: today } },
166
{ label: "Month to date", range: { from: startOfMonth(today), to: today } },
167
{
168
label: "Last month",
169
range: { from: startOfMonth(subMonths(today, 1)), to: endOfMonth(subMonths(today, 1)) },
170
},
171
{ label: "Year to date", range: { from: startOfYear(today), to: today } },
172
{
173
label: "Last year",
174
range: { from: startOfYear(subYears(today, 1)), to: endOfYear(subYears(today, 1)) },
175
},
176
];
177
178
const [open, setOpen] = createSignal(false);
179
const [month, setMonth] = createSignal(today);
180
const [range, setRange] = createSignal<CalendarRangeValue | undefined>({
181
from: toDate(props.values?.[0]),
182
to: toDate(props.values?.[1]),
183
});
184
185
useAutoOpen(props, setOpen);
186
187
// Derived, not stored: the active preset is whichever one matches the range.
188
const activePreset = () => {
189
const current = range();
190
if (!current?.from || !current.to) return null;
191
return (
192
presets.find(
193
(preset) =>
194
isSameDay(preset.range.from, current.from as Date) &&
195
isSameDay(preset.range.to, current.to as Date),
196
)?.label ?? null
197
);
198
};
199
200
const apply = () => {
201
const current = range();
202
if (current?.from) {
203
const from = toIsoDay(current.from);
204
props.onChange([from, current.to ? toIsoDay(current.to) : from]);
205
}
206
setOpen(false);
207
};
208
209
return (
210
<Popover open={open()} onOpenChange={setOpen} placement="bottom" gutter={8}>
211
<PopoverTrigger class={TRIGGER_CLASS}>
212
<Show when={range()?.from} fallback={<span>Pick a date range with presets</span>}>
213
{(from) => (
214
<>
215
{format(from(), "LLL dd, y")}
216
<Show when={range()?.to}>{(to) => ` - ${format(to(), "LLL dd, y")}`}</Show>
217
</>
218
)}
219
</Show>
220
</PopoverTrigger>
221
<PopoverContent class="w-auto p-0">
222
<div class="flex max-sm:flex-col">
223
<div class="relative border-border max-sm:order-1 max-sm:border-t sm:w-32">
224
<div class="h-full border-border py-2 sm:border-e">
225
<div class="flex flex-col gap-[2px] px-2">
226
<For each={presets}>
227
{(preset) => (
228
<Button
229
type="button"
230
variant="ghost"
231
class={cn(
232
"h-8 w-full justify-start",
233
activePreset() === preset.label && "bg-accent",
234
)}
235
onClick={() => {
236
setRange(preset.range);
237
setMonth(preset.range.from);
238
}}
239
>
240
{preset.label}
241
</Button>
242
)}
243
</For>
244
</div>
245
</div>
246
</div>
247
<Calendar
248
mode="range"
249
month={month()}
250
onMonthChange={setMonth}
251
showOutsideDays={false}
252
selected={range()}
253
onSelect={setRange}
254
numberOfMonths={2}
255
/>
256
</div>
257
<div class="flex items-center justify-end gap-1.5 border-border border-t p-3">
258
<Button variant="outline" onClick={() => setOpen(false)}>
259
Cancel
260
</Button>
261
<Button onClick={apply}>Apply</Button>
262
</div>
263
</PopoverContent>
264
</Popover>
265
);
266
}
267
268
const timeSlots = [
269
{ time: "09:00", available: false },
270
{ time: "09:30", available: false },
271
{ time: "10:00", available: true },
272
{ time: "10:30", available: true },
273
{ time: "11:00", available: true },
274
{ time: "11:30", available: true },
275
{ time: "12:00", available: false },
276
{ time: "12:30", available: true },
277
{ time: "13:00", available: true },
278
{ time: "13:30", available: true },
279
{ time: "14:00", available: true },
280
{ time: "14:30", available: false },
281
{ time: "15:00", available: false },
282
{ time: "15:30", available: true },
283
{ time: "16:00", available: true },
284
{ time: "16:30", available: true },
285
{ time: "17:00", available: true },
286
{ time: "17:30", available: true },
287
];
288
289
function DateTimeControl(props: ControlProps) {
290
const initial = toDate(props.values?.[0]);
291
const [open, setOpen] = createSignal(false);
292
const [date, setDate] = createSignal<Date | undefined>(initial);
293
const [time, setTime] = createSignal<string | undefined>(
294
initial ? initial.toTimeString().slice(0, 5) : "10:00",
295
);
296
297
useAutoOpen(props, setOpen);
298
299
const apply = () => {
300
const day = date();
301
const slot = time();
302
if (day && slot) {
303
const [hours, minutes] = slot.split(":").map(Number);
304
const dateTime = new Date(day);
305
dateTime.setHours(hours, minutes, 0, 0);
306
props.onChange([dateTime.toISOString()]);
307
}
308
setOpen(false);
309
};
310
311
return (
312
<Popover open={open()} onOpenChange={setOpen} placement="bottom-start" gutter={8}>
313
<PopoverTrigger class={TRIGGER_CLASS}>
314
<Show when={date()} fallback={<span>Pick a date and time</span>}>
315
{(day) => (
316
<>
317
{format(day(), "PPP")}
318
<Show when={time()}>{(slot) => ` - ${slot()}`}</Show>
319
</>
320
)}
321
</Show>
322
</PopoverTrigger>
323
<PopoverContent class="w-auto gap-0 p-0 pt-1">
324
<div class="flex max-sm:flex-col">
325
<Calendar
326
mode="single"
327
selected={date()}
328
onSelect={setDate}
329
class="p-2 sm:pe-5"
330
disabled={{ before: new Date() }}
331
/>
332
<div class="relative w-full max-sm:h-46 sm:w-40">
333
<div class="absolute inset-0 py-4 max-sm:border-t">
334
<ScrollArea class="h-full sm:border-s">
335
<div class="space-y-3">
336
<div class="flex h-5 shrink-0 items-center px-5">
337
<p class="font-medium text-sm">
338
<Show when={date()} fallback="Pick a date">
339
{(day) => format(day(), "EEEE, d")}
340
</Show>
341
</p>
342
</div>
343
<div class="grid gap-1.5 px-5 max-sm:grid-cols-2">
344
<For each={timeSlots}>
345
{(slot) => (
346
<Button
347
variant={time() === slot.time ? "default" : "outline"}
348
size="sm"
349
class="w-full"
350
disabled={!slot.available}
351
onClick={() => setTime(slot.time)}
352
>
353
{slot.time}
354
</Button>
355
)}
356
</For>
357
</div>
358
</div>
359
</ScrollArea>
360
</div>
361
</div>
362
</div>
363
<div class="flex items-center justify-end gap-1.5 border-border border-t p-3">
364
<Button variant="outline" onClick={() => setOpen(false)}>
365
Cancel
366
</Button>
367
<Button onClick={apply}>Apply</Button>
368
</div>
369
</PopoverContent>
370
</Popover>
371
);
372
}
373
374
function SliderRangeControl(props: ControlProps) {
375
const initial = props.values?.[0];
376
const [open, setOpen] = createSignal(false);
377
const [range, setRange] = createSignal<number[]>(
378
initial && typeof initial === "object" && "min" in initial && "max" in initial
379
? [
380
(initial as { min: number; max: number }).min,
381
(initial as { min: number; max: number }).max,
382
]
383
: [0, 100],
384
);
385
386
useAutoOpen(props, setOpen);
387
388
return (
389
<Popover open={open()} onOpenChange={setOpen} placement="bottom-start" gutter={8}>
390
<PopoverTrigger class={TRIGGER_CLASS}>
391
{range()[0]} - {range()[1]}
392
</PopoverTrigger>
393
<PopoverContent class="w-auto p-4">
394
<div class="space-y-2.5">
395
<div class="space-y-4 pt-2.5">
396
<Slider
397
value={range()}
398
onChange={setRange}
399
minValue={0}
400
maxValue={100}
401
step={1}
402
class="w-[200px]"
403
/>
404
<div class="flex justify-between ps-1.5 text-muted-foreground text-xs">
405
<span>0</span>
406
<span>100</span>
407
</div>
408
</div>
409
<div class="flex items-center justify-end gap-1.5">
410
<Button variant="ghost" size="sm" onClick={() => setOpen(false)}>
411
Cancel
412
</Button>
413
<Button
414
size="sm"
415
variant="outline"
416
onClick={() => {
417
props.onChange([{ min: range()[0], max: range()[1] }]);
418
setOpen(false);
419
}}
420
>
421
Apply
422
</Button>
423
</div>
424
</div>
425
</PopoverContent>
426
</Popover>
427
);
428
}
429
430
function IconTrigger(props: ComponentProps<typeof Button>) {
431
return (
432
<Button variant="outline" size="icon" {...props}>
433
<ListFilter />
434
</Button>
435
);
436
}
437
438
export default function FiltersCustomControls() {
439
const [filters, setFilters] = createSignal<Filter[]>([
440
createFilter("customDateRange", "between", []),
441
]);
442
const [lastAddedValues, setLastAddedValues] = createSignal<unknown[] | null>(null);
443
444
const fields: FilterFieldConfig[] = [
445
{
446
key: "modalDate",
447
label: "Modal Date",
448
type: "custom",
449
icon: () => <CalendarIcon class="size-3.5" />,
450
operators: [
451
{ value: "is", label: "is" },
452
{ value: "is_not", label: "is not" },
453
],
454
customRenderer: (renderer) => (
455
<ModalDateControl
456
values={renderer.values}
457
onChange={renderer.onChange}
458
autofocus={renderer.values === lastAddedValues()}
459
/>
460
),
461
},
462
{
463
key: "customDateRange",
464
label: "Date Range",
465
type: "custom",
466
icon: () => <CalendarIcon class="size-3.5" />,
467
operators: [
468
{ value: "between", label: "between" },
469
{ value: "not_between", label: "not between" },
470
],
471
customRenderer: (renderer) => (
472
<DateRangeControl
473
values={renderer.values}
474
onChange={renderer.onChange}
475
autofocus={renderer.values === lastAddedValues()}
476
/>
477
),
478
},
479
{
480
key: "customDateRangePresets",
481
label: "Date Range Presets",
482
type: "custom",
483
icon: () => <CalendarIcon class="size-3.5" />,
484
operators: [
485
{ value: "between", label: "between" },
486
{ value: "not_between", label: "not between" },
487
],
488
customRenderer: (renderer) => (
489
<DateRangePresetsControl
490
values={renderer.values}
491
onChange={renderer.onChange}
492
autofocus={renderer.values === lastAddedValues()}
493
/>
494
),
495
},
496
{
497
key: "customDateTime",
498
label: "Date & Time",
499
type: "custom",
500
icon: () => <Clock class="size-3.5" />,
501
operators: [
502
{ value: "is", label: "is" },
503
{ value: "before", label: "before" },
504
{ value: "after", label: "after" },
505
],
506
customRenderer: (renderer) => (
507
<DateTimeControl
508
values={renderer.values}
509
onChange={renderer.onChange}
510
autofocus={renderer.values === lastAddedValues()}
511
/>
512
),
513
},
514
{
515
key: "customSliderRange",
516
label: "Slider Range",
517
type: "custom",
518
icon: () => <SlidersVertical class="size-3.5" />,
519
class: "w-36",
520
operators: [
521
{ value: "between", label: "between" },
522
{ value: "not_between", label: "not between" },
523
],
524
customRenderer: (renderer) => (
525
<SliderRangeControl
526
values={renderer.values}
527
onChange={renderer.onChange}
528
autofocus={renderer.values === lastAddedValues()}
529
/>
530
),
531
},
532
];
533
534
const handleChange = (next: Filter[]) => {
535
const added = next.find((filter) => !filters().some((current) => current.id === filter.id));
536
if (added) setLastAddedValues(added.values);
537
setFilters(next);
538
};
539
540
return (
541
<div class="flex grow content-start items-start gap-2.5 self-start">
542
<div class="flex-1">
543
<Filters
544
filters={filters()}
545
fields={fields}
546
onChange={handleChange}
547
trigger={IconTrigger}
548
/>
549
</div>
550
551
<Show when={filters().length > 0}>
552
<Button variant="outline" onClick={() => setFilters([])}>
553
<FunnelX />
554
Clear
555
</Button>
556
</Show>
557
</div>
558
);
559
}

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.

Status
StaffCompanyOccupationStatusAvailabilityLocationBalance
AJ
Alex Johnson
alex@apple.com
AppleCEOActive
online
usSan Francisco, USA
$5,143.03
MR
Michael Rodriguez
michael@meta.com
MetaDesignerActive
busy
caToronto, Canada
$7,654.98
DK
David Kim
david@sap.com
SAPProduct ManagerActive
online
krSeoul, South Korea
$2,890.12
LM
Laura Mensah
laura@bbva.com
BBVAData ScientistActive
away
deBerlin, Germany
$6,120.4
Async mode: simulated API delay of 800-1500ms
1
import { Building, FunnelX, ListFilter, Mail, MapPin, User } from "lucide-solid";
2
import { type ComponentProps, createSignal, For, Index, Show } from "solid-js";
3
import {
4
createFilter,
5
type Filter,
6
type FilterFieldConfig,
7
Filters,
8
} from "@/registry/kobalte/blocks/filters";
9
import { Alert, AlertTitle } from "~/components/ui/alert";
10
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
11
import { Badge } from "~/components/ui/badge";
12
import { Button } from "~/components/ui/button";
13
import { ScrollArea } from "~/components/ui/scroll-area";
14
import { Skeleton } from "~/components/ui/skeleton";
15
import {
16
Table,
17
TableBody,
18
TableCell,
19
TableHead,
20
TableHeader,
21
TableRow,
22
} from "~/components/ui/table";
23
24
type Staff = {
25
id: string;
26
name: string;
27
availability: "online" | "away" | "busy" | "offline";
28
avatar: string;
29
status: "active" | "inactive";
30
flag: string;
31
email: string;
32
company: string;
33
role: string;
34
location: string;
35
balance: number;
36
};
37
38
const staff: Staff[] = [
39
{
40
id: "1",
41
name: "Alex Johnson",
42
availability: "online",
43
avatar: "https://randomuser.me/api/portraits/men/11.jpg",
44
status: "active",
45
flag: "us",
46
email: "alex@apple.com",
47
company: "Apple",
48
role: "CEO",
49
location: "San Francisco, USA",
50
balance: 5143.03,
51
},
52
{
53
id: "2",
54
name: "Sarah Chen",
55
availability: "away",
56
avatar: "https://randomuser.me/api/portraits/women/12.jpg",
57
status: "inactive",
58
flag: "gb",
59
email: "sarah@openai.com",
60
company: "OpenAI",
61
role: "CTO",
62
location: "London, UK",
63
balance: 4321.87,
64
},
65
{
66
id: "3",
67
name: "Michael Rodriguez",
68
availability: "busy",
69
avatar: "https://randomuser.me/api/portraits/men/13.jpg",
70
status: "active",
71
flag: "ca",
72
email: "michael@meta.com",
73
company: "Meta",
74
role: "Designer",
75
location: "Toronto, Canada",
76
balance: 7654.98,
77
},
78
{
79
id: "4",
80
name: "Emma Wilson",
81
availability: "offline",
82
avatar: "https://randomuser.me/api/portraits/women/14.jpg",
83
status: "inactive",
84
flag: "au",
85
email: "emma@tesla.com",
86
company: "Tesla",
87
role: "Developer",
88
location: "Sydney, Australia",
89
balance: 3456.45,
90
},
91
{
92
id: "5",
93
name: "David Kim",
94
availability: "online",
95
avatar: "https://randomuser.me/api/portraits/men/15.jpg",
96
status: "active",
97
flag: "kr",
98
email: "david@sap.com",
99
company: "SAP",
100
role: "Product Manager",
101
location: "Seoul, South Korea",
102
balance: 2890.12,
103
},
104
{
105
id: "6",
106
name: "Laura Mensah",
107
availability: "away",
108
avatar: "https://randomuser.me/api/portraits/women/16.jpg",
109
status: "active",
110
flag: "de",
111
email: "laura@bbva.com",
112
company: "BBVA",
113
role: "Data Scientist",
114
location: "Berlin, Germany",
115
balance: 6120.4,
116
},
117
];
118
119
const availabilityColors: Record<Staff["availability"], string> = {
120
online: "bg-green-500",
121
away: "bg-yellow-500",
122
busy: "bg-red-500",
123
offline: "bg-gray-400",
124
};
125
126
// A filter whose values are still empty should not narrow anything down yet.
127
const activeFilters = (filters: Filter[]) =>
128
filters.filter(
129
(filter) =>
130
filter.values.length > 0 &&
131
!filter.values.every(
132
(value) =>
133
value === null || value === undefined || (typeof value === "string" && !value.trim()),
134
),
135
);
136
137
const applyFilters = (filters: Filter[]) =>
138
activeFilters(filters).reduce((rows, filter) => {
139
return rows.filter((row) => {
140
const value = row[filter.field as keyof Staff];
141
const contains = (needle: unknown) =>
142
String(value).toLowerCase().includes(String(needle).toLowerCase());
143
144
switch (filter.operator) {
145
case "is":
146
return filter.values.includes(value);
147
case "is_not":
148
return !filter.values.includes(value);
149
case "is_any_of":
150
return filter.values.includes(value);
151
case "is_not_any_of":
152
return !filter.values.includes(value);
153
case "contains":
154
return filter.values.some(contains);
155
case "not_contains":
156
return !filter.values.some(contains);
157
case "starts_with":
158
return filter.values.some((needle) =>
159
String(value).toLowerCase().startsWith(String(needle).toLowerCase()),
160
);
161
case "ends_with":
162
return filter.values.some((needle) =>
163
String(value).toLowerCase().endsWith(String(needle).toLowerCase()),
164
);
165
case "empty":
166
return !value;
167
case "not_empty":
168
return Boolean(value);
169
default:
170
return true;
171
}
172
});
173
}, staff);
174
175
function SmallIconTrigger(props: ComponentProps<typeof Button>) {
176
return (
177
<Button variant="outline" size="icon-sm" {...props}>
178
<ListFilter />
179
</Button>
180
);
181
}
182
183
export default function FiltersTable() {
184
const fields: FilterFieldConfig[] = [
185
{
186
key: "name",
187
label: "Name",
188
type: "text",
189
icon: () => <User class="size-3.5" />,
190
class: "w-40",
191
placeholder: "Search names...",
192
},
193
{
194
key: "email",
195
label: "Email",
196
type: "text",
197
icon: () => <Mail class="size-3.5" />,
198
class: "w-48",
199
placeholder: "user@example.com",
200
},
201
{
202
key: "company",
203
label: "Company",
204
type: "select",
205
icon: () => <Building class="size-3.5" />,
206
searchable: true,
207
class: "w-[180px]",
208
options: [
209
{ value: "Apple", label: "Apple" },
210
{ value: "OpenAI", label: "OpenAI" },
211
{ value: "Meta", label: "Meta" },
212
{ value: "Tesla", label: "Tesla" },
213
{ value: "SAP", label: "SAP" },
214
{ value: "BBVA", label: "BBVA" },
215
],
216
},
217
{
218
key: "role",
219
label: "Role",
220
type: "select",
221
icon: () => <User class="size-3.5" />,
222
searchable: true,
223
class: "w-[160px]",
224
options: [
225
{ value: "CEO", label: "CEO" },
226
{ value: "CTO", label: "CTO" },
227
{ value: "Designer", label: "Designer" },
228
{ value: "Developer", label: "Developer" },
229
{ value: "Product Manager", label: "Product Manager" },
230
{ value: "Data Scientist", label: "Data Scientist" },
231
],
232
},
233
{
234
key: "status",
235
label: "Status",
236
type: "select",
237
icon: () => <User class="size-3.5" />,
238
searchable: false,
239
class: "w-[140px]",
240
options: [
241
{
242
value: "active",
243
label: "Active",
244
icon: () => <div class="size-2 rounded-full bg-green-500" />,
245
},
246
{
247
value: "inactive",
248
label: "Inactive",
249
icon: () => <div class="size-2 rounded-full bg-destructive" />,
250
},
251
],
252
},
253
{
254
key: "availability",
255
label: "Availability",
256
type: "select",
257
icon: () => <User class="size-3.5" />,
258
searchable: false,
259
class: "w-[160px]",
260
options: [
261
{
262
value: "online",
263
label: "Online",
264
icon: () => <div class="size-2 rounded-full bg-green-500" />,
265
},
266
{
267
value: "away",
268
label: "Away",
269
icon: () => <div class="size-2 rounded-full bg-yellow-500" />,
270
},
271
{
272
value: "busy",
273
label: "Busy",
274
icon: () => <div class="size-2 rounded-full bg-red-500" />,
275
},
276
{
277
value: "offline",
278
label: "Offline",
279
icon: () => <div class="size-2 rounded-full bg-gray-400" />,
280
},
281
],
282
},
283
{
284
key: "location",
285
label: "Location",
286
type: "text",
287
icon: () => <MapPin class="size-3.5" />,
288
class: "w-40",
289
placeholder: "Search locations...",
290
},
291
];
292
293
const [filters, setFilters] = createSignal<Filter[]>([createFilter("status", "is", ["active"])]);
294
const [rows, setRows] = createSignal<Staff[]>(applyFilters(filters()));
295
const [loading, setLoading] = createSignal(false);
296
297
// Stand-in for a server round trip: the filter bar stays interactive while
298
// the table shows skeleton rows.
299
let requestId = 0;
300
const runQuery = async (next: Filter[]) => {
301
const current = ++requestId;
302
setLoading(true);
303
await new Promise((resolve) => setTimeout(resolve, 800 + Math.random() * 700));
304
if (current !== requestId) return;
305
setRows(applyFilters(next));
306
setLoading(false);
307
};
308
309
const handleChange = (next: Filter[]) => {
310
const before = JSON.stringify(activeFilters(filters()));
311
setFilters(next);
312
if (before === JSON.stringify(activeFilters(next))) return;
313
void runQuery(next);
314
};
315
316
return (
317
<div class="w-full self-start">
318
<div class="mb-3.5 flex items-start gap-2.5">
319
<div class="flex-1">
320
<Filters
321
filters={filters()}
322
fields={fields}
323
onChange={handleChange}
324
size="sm"
325
trigger={SmallIconTrigger}
326
/>
327
</div>
328
<Show when={filters().length > 0}>
329
<Button
330
variant="outline"
331
size="sm"
332
disabled={loading()}
333
onClick={() => {
334
setFilters([]);
335
void runQuery([]);
336
}}
337
>
338
<FunnelX />
339
Clear
340
</Button>
341
</Show>
342
</div>
343
344
<div class="rounded-lg border">
345
<ScrollArea>
346
<Table>
347
<TableHeader>
348
<TableRow>
349
<TableHead>Staff</TableHead>
350
<TableHead>Company</TableHead>
351
<TableHead>Occupation</TableHead>
352
<TableHead>Status</TableHead>
353
<TableHead>Availability</TableHead>
354
<TableHead>Location</TableHead>
355
<TableHead class="text-right">Balance</TableHead>
356
</TableRow>
357
</TableHeader>
358
<TableBody>
359
<Show
360
when={!loading()}
361
fallback={
362
<Index each={Array.from({ length: 4 })}>
363
{() => (
364
<TableRow>
365
<TableCell>
366
<div class="flex items-center gap-3">
367
<Skeleton class="size-8 rounded-full" />
368
<div class="space-y-1">
369
<Skeleton class="h-4 w-24" />
370
<Skeleton class="h-3 w-16" />
371
</div>
372
</div>
373
</TableCell>
374
<Index each={Array.from({ length: 6 })}>
375
{() => (
376
<TableCell>
377
<Skeleton class="h-4 w-16" />
378
</TableCell>
379
)}
380
</Index>
381
</TableRow>
382
)}
383
</Index>
384
}
385
>
386
<Show
387
when={rows().length > 0}
388
fallback={
389
<TableRow>
390
<TableCell colSpan={7} class="h-24 text-center text-muted-foreground">
391
No results.
392
</TableCell>
393
</TableRow>
394
}
395
>
396
<For each={rows()}>
397
{(row) => (
398
<TableRow>
399
<TableCell>
400
<div class="flex items-center gap-3">
401
<Avatar class="size-8">
402
<AvatarImage src={row.avatar} alt={row.name} />
403
<AvatarFallback>
404
{row.name
405
.split(" ")
406
.map((part) => part[0])
407
.join("")}
408
</AvatarFallback>
409
</Avatar>
410
<div class="space-y-px">
411
<div class="font-medium text-foreground">{row.name}</div>
412
<div class="truncate text-muted-foreground text-xs">{row.email}</div>
413
</div>
414
</div>
415
</TableCell>
416
<TableCell>{row.company}</TableCell>
417
<TableCell>{row.role}</TableCell>
418
<TableCell>
419
<Badge variant={row.status === "active" ? "secondary" : "outline"}>
420
{row.status === "active" ? "Active" : "Inactive"}
421
</Badge>
422
</TableCell>
423
<TableCell>
424
<div class="flex items-center gap-1.5">
425
<div
426
class={`size-2 rounded-full ${availabilityColors[row.availability]}`}
427
/>
428
<span class="capitalize">{row.availability}</span>
429
</div>
430
</TableCell>
431
<TableCell>
432
<div class="flex items-center gap-2">
433
<img
434
src={`https://flagcdn.com/${row.flag}.svg`}
435
alt={row.flag}
436
class="size-4 rounded-full object-cover"
437
/>
438
<span>{row.location}</span>
439
</div>
440
</TableCell>
441
<TableCell class="text-right font-medium">
442
${row.balance.toLocaleString()}
443
</TableCell>
444
</TableRow>
445
)}
446
</For>
447
</Show>
448
</Show>
449
</TableBody>
450
</Table>
451
</ScrollArea>
452
</div>
453
454
<Alert class="mt-5">
455
<AlertTitle>Async mode: simulated API delay of 800-1500ms</AlertTitle>
456
</Alert>
457
</div>
458
);
459
}

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.

Estado
1
import { Building, ChevronDown, CircleCheck, ListFilter, Mail, MapPin, User } from "lucide-solid";
2
import { type ComponentProps, createSignal, For } from "solid-js";
3
import {
4
createFilter,
5
type Filter,
6
type FilterFieldConfig,
7
type FilterI18nConfig,
8
Filters,
9
} from "@/registry/kobalte/blocks/filters";
10
import { Button } from "~/components/ui/button";
11
import {
12
DropdownMenu,
13
DropdownMenuContent,
14
DropdownMenuItem,
15
DropdownMenuTrigger,
16
} from "~/components/ui/dropdown-menu";
17
18
type Language = "en" | "es" | "fr" | "de" | "ja";
19
20
// `i18n` accepts a partial override: anything omitted falls back to the block's
21
// English defaults, so a locale only has to declare what it changes.
22
const i18nConfigs: Record<Language, Partial<FilterI18nConfig>> = {
23
en: {
24
addFilter: "Add filter",
25
searchFields: "Search fields...",
26
noFieldsFound: "No fields found.",
27
noResultsFound: "No results found.",
28
select: "Select...",
29
selectedCount: "selected",
30
operators: {
31
is: "is",
32
isNot: "is not",
33
isAnyOf: "is any of",
34
isNotAnyOf: "is not any of",
35
includesAll: "includes all",
36
excludesAll: "excludes all",
37
before: "before",
38
after: "after",
39
between: "between",
40
notBetween: "not between",
41
contains: "contains",
42
notContains: "does not contain",
43
startsWith: "starts with",
44
endsWith: "ends with",
45
isExactly: "is exactly",
46
equals: "equals",
47
notEquals: "not equals",
48
greaterThan: "greater than",
49
lessThan: "less than",
50
overlaps: "overlaps",
51
includes: "includes",
52
excludes: "excludes",
53
includesAllOf: "includes all of",
54
includesAnyOf: "includes any of",
55
empty: "is empty",
56
notEmpty: "is not empty",
57
},
58
placeholders: {
59
enterField: (fieldType) => `Enter ${fieldType}...`,
60
selectField: "Select...",
61
searchField: (fieldName) => `Search ${fieldName.toLowerCase()}...`,
62
enterKey: "Enter key...",
63
enterValue: "Enter value...",
64
},
65
},
66
es: {
67
addFilter: "Agregar filtro",
68
searchFields: "Buscar campos...",
69
noFieldsFound: "No se encontraron campos.",
70
noResultsFound: "No se encontraron resultados.",
71
select: "Seleccionar...",
72
selectedCount: "seleccionados",
73
operators: {
74
is: "es",
75
isNot: "no es",
76
isAnyOf: "es cualquiera de",
77
isNotAnyOf: "no es cualquiera de",
78
includesAll: "incluye todos",
79
excludesAll: "excluye todos",
80
before: "antes de",
81
after: "después de",
82
between: "entre",
83
notBetween: "no entre",
84
contains: "contiene",
85
notContains: "no contiene",
86
startsWith: "comienza con",
87
endsWith: "termina con",
88
isExactly: "es exactamente",
89
equals: "igual a",
90
notEquals: "no igual a",
91
greaterThan: "mayor que",
92
lessThan: "menor que",
93
overlaps: "se superpone",
94
includes: "incluye",
95
excludes: "excluye",
96
includesAllOf: "incluye todos de",
97
includesAnyOf: "incluye cualquiera de",
98
empty: "está vacío",
99
notEmpty: "no está vacío",
100
},
101
placeholders: {
102
enterField: (fieldType) => `Ingrese ${fieldType}...`,
103
selectField: "Seleccionar...",
104
searchField: (fieldName) => `Buscar ${fieldName.toLowerCase()}...`,
105
enterKey: "Ingrese clave...",
106
enterValue: "Ingrese valor...",
107
},
108
},
109
fr: {
110
addFilter: "Ajouter un filtre",
111
searchFields: "Rechercher des champs...",
112
noFieldsFound: "Aucun champ trouvé.",
113
noResultsFound: "Aucun résultat trouvé.",
114
select: "Sélectionner...",
115
selectedCount: "sélectionnés",
116
operators: {
117
is: "est",
118
isNot: "n'est pas",
119
isAnyOf: "est l'un de",
120
isNotAnyOf: "n'est pas l'un de",
121
includesAll: "inclut tous",
122
excludesAll: "exclut tous",
123
before: "avant",
124
after: "après",
125
between: "entre",
126
notBetween: "pas entre",
127
contains: "contient",
128
notContains: "ne contient pas",
129
startsWith: "commence par",
130
endsWith: "se termine par",
131
isExactly: "est exactement",
132
equals: "égal à",
133
notEquals: "pas égal à",
134
greaterThan: "supérieur à",
135
lessThan: "inférieur à",
136
overlaps: "se chevauche",
137
includes: "inclut",
138
excludes: "exclut",
139
includesAllOf: "inclut tous de",
140
includesAnyOf: "inclut l'un de",
141
empty: "est vide",
142
notEmpty: "n'est pas vide",
143
},
144
placeholders: {
145
enterField: (fieldType) => `Entrez ${fieldType}...`,
146
selectField: "Sélectionner...",
147
searchField: (fieldName) => `Rechercher ${fieldName.toLowerCase()}...`,
148
enterKey: "Entrez la clé...",
149
enterValue: "Entrez la valeur...",
150
},
151
},
152
de: {
153
addFilter: "Filter hinzufügen",
154
searchFields: "Felder suchen...",
155
noFieldsFound: "Keine Felder gefunden.",
156
noResultsFound: "Keine Ergebnisse gefunden.",
157
select: "Auswählen...",
158
selectedCount: "ausgewählt",
159
operators: {
160
is: "ist",
161
isNot: "ist nicht",
162
isAnyOf: "ist eines von",
163
isNotAnyOf: "ist nicht eines von",
164
includesAll: "enthält alle",
165
excludesAll: "schließt alle aus",
166
before: "vor",
167
after: "nach",
168
between: "zwischen",
169
notBetween: "nicht zwischen",
170
contains: "enthält",
171
notContains: "enthält nicht",
172
startsWith: "beginnt mit",
173
endsWith: "endet mit",
174
isExactly: "ist genau",
175
equals: "gleich",
176
notEquals: "nicht gleich",
177
greaterThan: "größer als",
178
lessThan: "kleiner als",
179
overlaps: "überschneidet sich",
180
includes: "enthält",
181
excludes: "schließt aus",
182
includesAllOf: "enthält alle von",
183
includesAnyOf: "enthält eines von",
184
empty: "ist leer",
185
notEmpty: "ist nicht leer",
186
},
187
placeholders: {
188
enterField: (fieldType) => `${fieldType} eingeben...`,
189
selectField: "Auswählen...",
190
searchField: (fieldName) => `${fieldName.toLowerCase()} suchen...`,
191
enterKey: "Schlüssel eingeben...",
192
enterValue: "Wert eingeben...",
193
},
194
},
195
ja: {
196
addFilter: "フィルターを追加",
197
searchFields: "フィールドを検索...",
198
noFieldsFound: "フィールドが見つかりません。",
199
noResultsFound: "結果が見つかりません。",
200
select: "選択...",
201
selectedCount: "選択済み",
202
operators: {
203
is: "は",
204
isNot: "ではない",
205
isAnyOf: "のいずれか",
206
isNotAnyOf: "のいずれでもない",
207
includesAll: "すべて含む",
208
excludesAll: "すべて除外",
209
before: "より前",
210
after: "より後",
211
between: "の間",
212
notBetween: "の間ではない",
213
contains: "含む",
214
notContains: "含まない",
215
startsWith: "で始まる",
216
endsWith: "で終わる",
217
isExactly: "正確に",
218
equals: "等しい",
219
notEquals: "等しくない",
220
greaterThan: "より大きい",
221
lessThan: "より小さい",
222
overlaps: "重複する",
223
includes: "含む",
224
excludes: "除外",
225
includesAllOf: "すべて含む",
226
includesAnyOf: "いずれか含む",
227
empty: "空",
228
notEmpty: "空でない",
229
},
230
placeholders: {
231
enterField: (fieldType) => `${fieldType}を入力...`,
232
selectField: "選択...",
233
searchField: (fieldName) => `${fieldName.toLowerCase()}を検索...`,
234
enterKey: "キーを入力...",
235
enterValue: "値を入力...",
236
},
237
},
238
};
239
240
const languages: { value: Language; label: string; flag: string }[] = [
241
{ value: "en", label: "English", flag: "us" },
242
{ value: "es", label: "Español", flag: "es" },
243
{ value: "fr", label: "Français", flag: "fr" },
244
{ value: "de", label: "Deutsch", flag: "de" },
245
{ value: "ja", label: "日本語", flag: "jp" },
246
];
247
248
const fieldLabels: Record<Language, Record<string, string>> = {
249
en: {
250
name: "Name",
251
email: "Email",
252
company: "Company",
253
status: "Status",
254
location: "Location",
255
active: "Active",
256
inactive: "Inactive",
257
searchNames: "Search names...",
258
searchLocations: "Search locations...",
259
},
260
es: {
261
name: "Nombre",
262
email: "Correo electrónico",
263
company: "Empresa",
264
status: "Estado",
265
location: "Ubicación",
266
active: "Activo",
267
inactive: "Inactivo",
268
searchNames: "Buscar nombres...",
269
searchLocations: "Buscar ubicaciones...",
270
},
271
fr: {
272
name: "Nom",
273
email: "E-mail",
274
company: "Entreprise",
275
status: "Statut",
276
location: "Localisation",
277
active: "Actif",
278
inactive: "Inactif",
279
searchNames: "Rechercher des noms...",
280
searchLocations: "Rechercher des lieux...",
281
},
282
de: {
283
name: "Name",
284
email: "E-Mail",
285
company: "Unternehmen",
286
status: "Status",
287
location: "Standort",
288
active: "Aktiv",
289
inactive: "Inaktiv",
290
searchNames: "Namen suchen...",
291
searchLocations: "Standorte suchen...",
292
},
293
ja: {
294
name: "名前",
295
email: "メール",
296
company: "会社",
297
status: "ステータス",
298
location: "場所",
299
active: "アクティブ",
300
inactive: "非アクティブ",
301
searchNames: "名前を検索...",
302
searchLocations: "場所を検索...",
303
},
304
};
305
306
function SmallIconTrigger(props: ComponentProps<typeof Button>) {
307
return (
308
<Button variant="outline" size="icon-sm" {...props}>
309
<ListFilter />
310
</Button>
311
);
312
}
313
314
export default function FiltersI18n() {
315
const [language, setLanguage] = createSignal<Language>("es");
316
const [filters, setFilters] = createSignal<Filter[]>([createFilter("status", "is", ["active"])]);
317
318
const labels = () => fieldLabels[language()];
319
320
const fields = (): FilterFieldConfig[] => [
321
{
322
key: "name",
323
label: labels().name,
324
type: "text",
325
icon: () => <User class="size-3.5" />,
326
class: "w-40",
327
placeholder: labels().searchNames,
328
},
329
{
330
key: "email",
331
label: labels().email,
332
type: "text",
333
icon: () => <Mail class="size-3.5" />,
334
class: "w-48",
335
placeholder: "user@example.com",
336
},
337
{
338
key: "company",
339
label: labels().company,
340
type: "select",
341
icon: () => <Building class="size-3.5" />,
342
searchable: true,
343
class: "w-[180px]",
344
options: [
345
{ value: "apple", label: "Apple" },
346
{ value: "openai", label: "OpenAI" },
347
{ value: "meta", label: "Meta" },
348
{ value: "tesla", label: "Tesla" },
349
],
350
},
351
{
352
key: "status",
353
label: labels().status,
354
type: "select",
355
icon: () => <CircleCheck class="size-3.5" />,
356
searchable: false,
357
class: "w-[140px]",
358
options: [
359
{ value: "active", label: labels().active },
360
{ value: "inactive", label: labels().inactive },
361
],
362
},
363
{
364
key: "location",
365
label: labels().location,
366
type: "text",
367
icon: () => <MapPin class="size-3.5" />,
368
class: "w-40",
369
placeholder: labels().searchLocations,
370
},
371
];
372
373
const currentLanguage = () => languages.find((entry) => entry.value === language());
374
375
return (
376
<div class="flex w-full grow items-start justify-between gap-4 self-start">
377
<Filters
378
filters={filters()}
379
fields={fields()}
380
onChange={setFilters}
381
size="sm"
382
i18n={i18nConfigs[language()]}
383
trigger={SmallIconTrigger}
384
/>
385
386
<DropdownMenu placement="bottom-end">
387
<DropdownMenuTrigger as={Button} variant="outline" size="sm" class="gap-2">
388
<img
389
src={`https://flagcdn.com/${currentLanguage()?.flag}.svg`}
390
alt={currentLanguage()?.flag}
391
class="size-4 rounded-full object-cover"
392
/>
393
<span>{currentLanguage()?.label}</span>
394
<ChevronDown class="size-4" />
395
</DropdownMenuTrigger>
396
<DropdownMenuContent>
397
<For each={languages}>
398
{(entry) => (
399
<DropdownMenuItem class="gap-2" onSelect={() => setLanguage(entry.value)}>
400
<img
401
src={`https://flagcdn.com/${entry.flag}.svg`}
402
alt={entry.flag}
403
class="size-4 rounded-full object-cover"
404
/>
405
<span>{entry.label}</span>
406
</DropdownMenuItem>
407
)}
408
</For>
409
</DropdownMenuContent>
410
</DropdownMenu>
411
</div>
412
);
413
}

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.

Product
1
import { createVirtualizer, defaultRangeExtractor, type Range } from "@tanstack/solid-virtual";
2
import { Package } from "lucide-solid";
3
import { createEffect, createSignal, For } from "solid-js";
4
import {
5
createFilter,
6
type Filter,
7
type FilterFieldConfig,
8
type FilterOption,
9
type FilterOptionListRenderProps,
10
Filters,
11
} from "@/registry/kobalte/blocks/filters";
12
13
const ROW_HEIGHT = 32;
14
15
// Consumer-owned virtualization wired through the field's `renderOptionList`
16
// slot. The block ships no windowing dependency — you bring your own (here
17
// @tanstack/solid-virtual) and stay bound to its selection and keyboard logic
18
// through `renderOption` and `highlightedIndex`.
19
function VirtualizedOptions(props: FilterOptionListRenderProps) {
20
const [scrollElement, setScrollElement] = createSignal<HTMLDivElement>();
21
22
const virtualizer = createVirtualizer({
23
get count() {
24
return props.options.length;
25
},
26
getScrollElement: () => scrollElement() ?? null,
27
estimateSize: () => ROW_HEIGHT,
28
overscan: 10,
29
getItemKey: (index) => String(props.options[index]?.value ?? index),
30
// Keep the highlighted row mounted even when scrolled away, so the
31
// combobox's aria-activedescendant never points at an unmounted node.
32
rangeExtractor: (range: Range) => {
33
const indices = new Set(defaultRangeExtractor(range));
34
if (props.highlightedIndex >= 0 && props.highlightedIndex < props.options.length) {
35
indices.add(props.highlightedIndex);
36
}
37
return Array.from(indices).sort((a, b) => a - b);
38
},
39
});
40
41
createEffect(() => {
42
const index = props.highlightedIndex;
43
if (index >= 0 && index < props.options.length) {
44
virtualizer.scrollToIndex(index, { align: "auto" });
45
}
46
});
47
48
return (
49
<div ref={setScrollElement} class="max-h-[300px] overflow-y-auto overscroll-contain px-1">
50
<div class="relative w-full" style={{ height: `${virtualizer.getTotalSize()}px` }}>
51
<For each={virtualizer.getVirtualItems()}>
52
{(row) => (
53
<div
54
data-index={row.index}
55
class="absolute top-0 left-0 w-full"
56
style={{ transform: `translateY(${row.start}px)` }}
57
>
58
{props.renderOption(props.options[row.index], row.index)}
59
</div>
60
)}
61
</For>
62
</div>
63
</div>
64
);
65
}
66
67
const products: FilterOption[] = Array.from({ length: 5000 }, (_, index) => ({
68
value: `sku-${index + 1}`,
69
label: `Product ${String(index + 1).padStart(4, "0")}`,
70
}));
71
72
export default function FiltersVirtualized() {
73
const fields: FilterFieldConfig[] = [
74
{
75
key: "product",
76
label: "Product",
77
type: "multiselect",
78
icon: () => <Package class="size-3.5" />,
79
options: products,
80
// Bring your own windowing for large lists.
81
renderOptionList: (renderProps) => <VirtualizedOptions {...renderProps} />,
82
},
83
];
84
85
const [filters, setFilters] = createSignal<Filter[]>([
86
createFilter("product", "is_any_of", ["sku-42", "sku-1024"]),
87
]);
88
89
return (
90
<div class="flex grow content-start items-start self-start">
91
<Filters filters={filters()} fields={fields} onChange={setFilters} />
92
</div>
93
);
94
}

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.

1
import { Users } from "lucide-solid";
2
import { createSignal } from "solid-js";
3
import {
4
type Filter,
5
type FilterFieldConfig,
6
type FilterOption,
7
Filters,
8
} from "@/registry/kobalte/blocks/filters";
9
10
const teams: FilterOption[] = [
11
{ value: "eng", label: "Engineering" },
12
{ value: "design", label: "Design" },
13
{ value: "product", label: "Product" },
14
{ value: "marketing", label: "Marketing" },
15
{ value: "sales", label: "Sales" },
16
{ value: "support", label: "Customer Support" },
17
{ value: "finance", label: "Finance" },
18
{ value: "people", label: "People Ops" },
19
{ value: "legal", label: "Legal" },
20
{ value: "it", label: "IT" },
21
{ value: "data", label: "Data & Analytics" },
22
{ value: "security", label: "Security" },
23
];
24
25
export default function FiltersAsyncPrefetch() {
26
// A field can take `loadOptions` instead of a static `options` list. Here it
27
// prefetches the whole remote list once (the query is ignored on the first
28
// call) and caches it, so opening the filter shows a loading state only once.
29
let cache: FilterOption[] | null = null;
30
31
const fields: FilterFieldConfig[] = [
32
{
33
key: "team",
34
label: "Team",
35
type: "multiselect",
36
icon: () => <Users class="size-3.5" />,
37
loadOptions: async (query: string) => {
38
if (!cache) {
39
await new Promise((resolve) => setTimeout(resolve, 600));
40
cache = teams;
41
}
42
const needle = query.trim().toLowerCase();
43
return needle ? cache.filter((team) => team.label.toLowerCase().includes(needle)) : cache;
44
},
45
},
46
];
47
48
const [filters, setFilters] = createSignal<Filter[]>([]);
49
50
return (
51
<div class="flex grow content-start items-start self-start">
52
<Filters filters={filters()} fields={fields} onChange={setFilters} />
53
</div>
54
);
55
}

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.

Assignee
1
import { UserSearch } from "lucide-solid";
2
import { createSignal } from "solid-js";
3
import {
4
createFilter,
5
type Filter,
6
type FilterFieldConfig,
7
type FilterOption,
8
Filters,
9
} from "@/registry/kobalte/blocks/filters";
10
11
const firstNames = [
12
"Alex",
13
"Bailey",
14
"Casey",
15
"Dana",
16
"Emerson",
17
"Finley",
18
"Gray",
19
"Harper",
20
"Indira",
21
"Jordan",
22
"Kai",
23
"Logan",
24
"Morgan",
25
"Noor",
26
"Parker",
27
"Quinn",
28
"Riley",
29
"Sasha",
30
"Taylor",
31
"Umi",
32
"Val",
33
"Wren",
34
"Xan",
35
"Yuki",
36
"Zephyr",
37
];
38
39
const lastNames = [
40
"Ahmed",
41
"Brooks",
42
"Chen",
43
"Diaz",
44
"Evans",
45
"Ferreira",
46
"Gupta",
47
"Hansen",
48
"Ito",
49
"Johnson",
50
"Kowalski",
51
"Lopez",
52
"Mensah",
53
"Novak",
54
"Okafor",
55
"Park",
56
];
57
58
// Stands in for a directory too large to prefetch.
59
const directory: FilterOption[] = Array.from({ length: 10000 }, (_, index) => {
60
const first = firstNames[index % firstNames.length];
61
const last = lastNames[Math.floor(index / firstNames.length) % lastNames.length];
62
return { value: `user-${index + 1}`, label: `${first} ${last} #${index + 1}` };
63
});
64
65
export default function FiltersAsyncSearch() {
66
const fields: FilterFieldConfig[] = [
67
{
68
key: "assignee",
69
label: "Assignee",
70
type: "multiselect",
71
icon: () => <UserSearch class="size-3.5" />,
72
// Seed only the initially selected value so its chip stays labelled.
73
options: [directory[0]],
74
// Server-side search: debounced by the block, guarded against out-of-order
75
// responses, and cached value -> label so selected chips keep their label.
76
loadOptions: async (query: string) => {
77
await new Promise((resolve) => setTimeout(resolve, 400));
78
const needle = query.trim().toLowerCase();
79
const matches = needle
80
? directory.filter((option) => option.label.toLowerCase().includes(needle))
81
: directory;
82
return matches.slice(0, 50);
83
},
84
},
85
];
86
87
const [filters, setFilters] = createSignal<Filter[]>([
88
createFilter("assignee", "is_any_of", ["user-1"]),
89
]);
90
91
return (
92
<div class="flex grow content-start items-start self-start">
93
<Filters filters={filters()} fields={fields} onChange={setFilters} />
94
</div>
95
);
96
}

Keyboard

  • With enableShortcut, pressing shortcutKey (default F) anywhere outside a text field opens the "Add Filter" menu, and shortcutLabel renders 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-activedescendant follows 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.

PropTypeDefaultDescription
filtersFilter<T>[]-Required. The array of active filters.
fieldsFilterFieldsConfig<T>-Required. The configuration for available filter fields.
onChange(filters: Filter<T>[]) => void-Required. Fired when filters are added, updated, or removed.
size"sm" | "default" | "lg""default"The size of the filter chips and controls.
variant"solid" | "default""default"Spacing variant of the container.
triggerValidComponent-Component rendered as the "Add Filter" trigger. A component, not an element — Solid has no cloneElement.
showSearchInputbooleantrueWhether to show the search input in the "Add Filter" menu.
allowMultiplebooleantrueWhether to allow multiple filters for the same field.
enableShortcutbooleanfalseWhether to enable the keyboard shortcut that opens the filter menu.
shortcutKeystring"f"The key used for the shortcut (e.g. "f").
shortcutLabelstring"F"The label displayed in the shortcut indicator.
i18nPartial<FilterI18nConfig>-Custom labels and operators for internationalization.
classstring-Additional CSS classes for the container.
menuPopupClassNamestring-Additional CSS classes for the "Add Filter" dropdown menu.

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.

PropTypeDescription
filtersFilter<T>[]Required. The array of active filters.
fieldsFilterFieldsConfig<T>Required. The field configuration.
onChange(filters: Filter<T>[]) => voidRequired. Fired on any chip edit.

FilterFieldConfig

Configuration for an individual filterable field.

PropertyTypeDescription
keystringRequired. Unique identifier for the field.
labelstringRequired. Human-readable label for the field.
type"text" | "select" | "multiselect" | "custom" | "separator"The type of filter input to use.
iconFilterIcon (JSX.Element | (() => JSX.Element))Optional icon displayed next to the field label. Pass a thunk — see Icons.
optionsFilterOption<T>[]List of options for select and multiselect fields.
loadOptions(query: string) => FilterOption<T>[] | Promise<FilterOption<T>[]>Async options loader for large or remote lists. Debounced, with loading/error states and a value-to-label cache. options still seeds the initial view.
renderOptionList(props: FilterOptionListRenderProps<T>) => JSX.ElementBring-your-own rendering for the options list, e.g. virtualization. Receives { options, highlightedIndex, renderOption } as lazy getters; render the scrollable list and call renderOption.
operatorsFilterOperator[]Custom operators for this specific field.
defaultOperatorstringThe operator selected by default when adding this field.
placeholderstringPlaceholder text for text inputs.
searchablebooleanWhether the options list shows a search input.
maxSelectionsnumberMaximum number of items allowed in a multiselect.
prefixstring | JSX.ElementPrefix element for the input field.
suffixstring | JSX.ElementSuffix element for the input field.
patternstringRegex pattern for text input validation.
validation(value: unknown) => boolean | { valid: boolean; message?: string }Custom validation function, run on blur.
customRenderer(props: CustomRendererProps<T>) => JSX.ElementCustom renderer for the filter value editor.
customValueRenderer(values: T[], options: FilterOption<T>[]) => JSX.ElementCustom renderer for the active filter value display.
fieldsFilterFieldConfig<T>[]Nested fields when this entry is a group.
groupstringGroup label when this entry is a group.
classstringAdditional CSS classes for the field's editor.

FilterOption

Structure for options in select and multiselect fields.

PropertyTypeDescription
valueTRequired. The internal value of the option.
labelstringRequired. Human-readable label for the option.
iconFilterIconOptional icon displayed next to the option. Pass a thunk — see Icons.
classstringAdditional CSS classes for the option row.

Filter

The structure of an active filter object.

PropertyTypeDescription
idstringUnique identifier for the filter instance.
fieldstringThe key of the associated field configuration.
operatorstringThe selected operator (e.g. "is", "contains").
valuesT[]The current value(s) of the filter.

FilterI18nConfig

Configuration for internationalization and custom labels. Pass a partial — omitted keys fall back to the English defaults exported as DEFAULT_I18N.

PropertyTypeDescription
addFilterstringLabel for the "Add Filter" button.
searchFieldsstringPlaceholder for the field search input.
operatorsobjectMap of operator keys to localized strings.
validationobjectMap of validation error messages.
placeholdersobjectMap of dynamic placeholder templates.

Helpers

FunctionSignatureDescription
createFilter(field, operator?, values?) => FilterCreates a new filter object with a unique id.
createFilterGroup(id, label, fields, initialFilters?) => FilterGroupCreates a filter group configuration.
getOperatorsForField(field, values, i18n) => FilterOperator[]Resolves the operator list shown for a field.
mergeI18n(i18n?) => FilterI18nConfigDeep-merges an override onto DEFAULT_I18N.

DEFAULT_I18N and DEFAULT_OPERATORS are exported too, so a consumer can start from the defaults instead of retyping them.

On This Page

  • Installation
  • Usage
    • Icons
  • Examples
    • Validation
    • Trigger Button
    • Small Size
    • Large Size
    • Custom Controls
    • Data Table
    • With i18n Support
    • Virtualized Large Lists
    • Prefetched Async Options
    • Async Server-side Search
  • Keyboard
  • API Reference
    • Filters
    • FiltersContent
    • FilterFieldConfig
    • FilterOption
    • Filter
    • FilterI18nConfig
    • Helpers
Built by Kevin Abatan. The source code is available on GitHub.