Composable filters that survive real product requirements — including geo.
Every listing screen ends up rebuilding the same machinery: a panel of fields,
chips for what is active, a count of results, a way back to a query string.
This package ships that machinery as source-shipped .tsx. You declare the
fields; when the product asks for "is not", money in cents or "within 30 km of
Nantes", the field kinds are already there, and when it asks for something
stranger, you own the code and can read your way to the change.
Install
bun add @voila.dev/ui@import "@voila.dev/ui/styles/sources.css";Or copy the source, it's the same files.
Peers: react@19, react-dom@19, tailwindcss@4.
The 3-minute win
Declare the fields once; Filter.Root renders the search box, the trigger, the
editor and the chips from that single declaration.
import { Filter, type FilterDefinition, type FilterValues } from "@voila.dev/ui/filter";
import { useState } from "react";
const definitions = [
{
kind: "select",
key: "status",
label: "Status",
multiple: true,
options: [
{ value: "sent", label: "Sent" },
{ value: "failed", label: "Failed" },
],
},
{ kind: "dateRange", key: "sentAt", label: "Sent" },
] as const satisfies ReadonlyArray<FilterDefinition>;
export function Messages() {
const [values, setValues] = useState<FilterValues>({});
const [search, setSearch] = useState("");
return (
<Filter.Root
definitions={definitions}
values={values}
onValuesChange={setValues}
searchValue={search}
onSearchChange={setSearch}
resultCount={128}
/>
);
}Mental model
A screen declares which fields are filterable; the editor, the chips and the
query layer all read the same declaration. FilterValues never holds an empty
filter: clearing a field removes its key, so Object.keys(values).length is
the active count and the record round-trips to a query string unchanged. The
package is domain-agnostic on purpose. All copy comes from labels (English
by default), all formatting from locale, and the geo field geocodes nothing
itself, so it works against any address provider.
Page map
- Filter.Root: the whole surface: search, trigger, editor, chips, result count.
- Filter.Form: the editor on its own, for a sidebar or a settings sheet.
- Filter.Chips: the active filters as removable chips.
- Filter.Trigger: the search-shaped button that opens the editor.
- Text: free text, with "is not".
- Select: one or several options, with exclusion.
- Number: a single numeric value.
- Money range: bounds in minor units, formatted per locale.
- Date range:
YYYY-MM-DDbounds a native input round-trips. - Boolean: tri-state: true, false, any.
- Geo radius: "within N km of a place", against your geocoder.
- A real listing page: filters, a datatable and a map wired to one record.