Composable, URL-driven filtering for Phoenix LiveView with Linear/Notion-style UI filters and PostgREST-compatible parameters for shareable filter states using PgRest.
Demo App
See demo/ for an interactive filter explorer built with Phoenix LiveView.
The repo ships a Hivemind wrapper that serves the demo at
localhost:4032:
bin/livefilter start
bin/livefilter stop
bin/livefilter console
Requires a local Postgres reachable at localhost:5432 with a demo_dev
database (mix ecto.setup from demo/ creates it). Or run the demo directly:
cd demo && mix setup && mix phx.server
Prerequisites
- Elixir 1.15+
- Phoenix LiveView 1.0+
- DaisyUI (via daisy_ui_components)
Installation
Add livefilter to your dependencies in mix.exs:
def deps do
[
{:livefilter, "~> 0.1.8"}
]
endThen fetch dependencies:
mix deps.get
JavaScript Hooks
LiveFilter requires JavaScript hooks for dropdown behavior. Add them to your LiveSocket:
import { hooks as liveFilterHooks } from "live_filter"
const liveSocket = new LiveSocket("/live", Socket, {
hooks: { ...liveFilterHooks }
})For esbuild, add the deps path to your NODE_PATH in config/config.exs:
config :esbuild,
version: "0.25.4",
my_app: [
args: ~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets/js),
cd: Path.expand("../assets", __DIR__),
env: %{
"NODE_PATH" => Path.expand("../deps", __DIR__)
}
]Quick Start
1. Define Filter Configuration
defmodule MyAppWeb.TaskLive.Index do
use MyAppWeb, :live_view
defp filter_config do
[
LiveFilter.text(:title, label: "Search", always_on: true),
LiveFilter.select(:status, label: "Status", options: ~w(pending active done)),
LiveFilter.multi_select(:tags, label: "Tags", options: ~w(bug feature docs)),
LiveFilter.boolean(:urgent, label: "Urgent Only"),
LiveFilter.date_range(:due_date, label: "Due Date")
]
end
end2. Initialize in LiveView
def handle_params(params, _uri, socket) do
{filters, remaining_params} = LiveFilter.from_params(params, filter_config())
socket =
socket
|> LiveFilter.init(filter_config(), filters)
|> assign(:remaining_params, remaining_params)
|> load_data()
{:noreply, socket}
end
def handle_info({:livefilter, :updated, params}, socket) do
all_params = Map.merge(socket.assigns.remaining_params, params)
{:noreply, push_patch(socket, to: ~p"/tasks?#{all_params}")}
end3. Render the Filter Bar (Optional)
Use the built-in UI component:
<LiveFilter.bar filter={@livefilter} />Or build your own UI — the param/query layers work independently:
# Parse params and build queries without the bar component
{filters, _} = LiveFilter.from_params(params, filter_config())
query = LiveFilter.QueryBuilder.apply(Task, filters, schema: Task, allowed_fields: [...])4. Apply Filters to Queries
defp load_data(socket) do
query =
Task
|> LiveFilter.QueryBuilder.apply(socket.assigns.livefilter.filters,
schema: Task,
allowed_fields: [:title, :status, :tags, :urgent, :due_date]
)
assign(socket, :tasks, Repo.all(query))
endFilter Types
| Type | Function | Default Operators |
|---|---|---|
| Text | LiveFilter.text/2 | ilike, eq, neq, like |
| Number | LiveFilter.number/2 | eq, neq, gt, gte, lt, lte |
| Select | LiveFilter.select/2 | eq, neq |
| Multi-select | LiveFilter.multi_select/2 | ov, cs |
| Date | LiveFilter.date/2 | eq, gt, gte, lt, lte |
| Date Range | LiveFilter.date_range/2 | gte_lte |
| DateTime | LiveFilter.datetime/2 | eq, gt, gte, lt, lte |
| Boolean | LiveFilter.boolean/2 | is |
| Radio Group | LiveFilter.radio_group/2 | eq |
Select Filter with Multi-Value Operators
Select filters support additional operators for multi-value selection. Add :in and :not_in to enable "is any of" and "is none of" filtering:
LiveFilter.select(:status,
label: "Status",
options: ~w(draft pending active shipped),
operators: [:eq, :neq, :in, :not_in], # Enable multi-value operators
mode: :command # Command mode shows operator dropdown
)| Operator | Label | Value Mode | URL Format | Description |
|---|---|---|---|---|
:eq | "is" | single | status=eq.active | Exact match |
:neq | "is not" | single | status=neq.draft | Not equal |
:in | "is any of" | multi | status=in.(a,b,c) | Matches any selected value |
:not_in | "is none of" | multi | status=not_in.(a,b) | Excludes all selected values |
The UI automatically switches between single-select and multi-select based on the active operator. When switching between single/multi operators, the value is cleared to prevent type mismatches.
Display Modes
LiveFilter supports two display modes for filter chips:
| Mode | Description |
|---|---|
:basic | Simple chips without operator selection (default) |
:command | Full chips with inline operator dropdown (Linear/Notion style) |
Set the mode globally on the bar:
<LiveFilter.bar filter={@livefilter} mode={:command} />Or per-filter in the configuration:
LiveFilter.number(:estimated_hours, label: "Hours", mode: :command)Theming
LiveFilter provides preset themes that control the styling of filter chips. Set the theme on the bar:
<LiveFilter.bar filter={@livefilter} theme={:neutral} variant={:neutral} />Available Themes
| Theme | Description |
|---|---|
:default | DaisyUI btn-based styling with outline variant |
:minimal | Lighter padding and simpler styling |
:bordered | Primary color accent with borders |
:neutral | Theme-aware utilities without DaisyUI btn classes |
Theme + Variant Combinations
The theme controls element styling (chip, badge, field, etc.) while variant controls the DaisyUI btn variant class:
| Variant | Class Applied | Notes |
|---|---|---|
:outline | btn-outline | Can have dark active/focus states |
:ghost | btn-ghost | Transparent background |
:soft | btn-soft | Subtle background |
:neutral | (none) | No btn variant, uses theme utilities only |
The :neutral Theme
The :neutral theme avoids DaisyUI's btn component classes entirely, using theme-aware Tailwind utilities instead. This prevents the dark active/focus states that btn-outline can produce on light themes:
<LiveFilter.bar filter={@livefilter} theme={:neutral} variant={:neutral} />This theme uses:
bg-base-100,text-base-content,border-base-300for automatic light/dark theme supportbadge-softwithroundedfor squared badge styling- No CSS overrides needed in your host app
Tailwind Content Scanning
For Tailwind to generate LiveFilter's theme classes, add the library's templates to your tailwind.config.js:
module.exports = {
content: [
// ... existing paths ...
"../deps/livefilter/**/*.*ex",
],
}Filter Options
LiveFilter.text(:field,
label: "Display Label", # Human-readable label
always_on: true, # Always visible (not removable)
operators: [:eq, :ilike], # Allowed operators
default_operator: :ilike, # Default when adding filter
placeholder: "Search...", # Input placeholder
custom_param: "search", # Custom URL param name
query_field: :other_field, # Query different DB column
mode: :command # Display mode for this filter
)
LiveFilter.select(:status,
options: ["pending", "active"], # Static options
options_fn: fn -> fetch_options() end # Dynamic options
)
LiveFilter.boolean(:active,
nullable: true, # Allow nil (Any) state
true_label: "Active", # Custom label for true
false_label: "Inactive", # Custom label for false
any_label: "All" # Custom label for nil
)Pagination
LiveFilter includes a pagination component with PostgREST-compatible limit/offset URL params.
# In handle_params
{filters, remaining} = LiveFilter.from_params(params, filter_config())
{pagination, remaining} = LiveFilter.pagination_from_params(remaining, default_limit: 25)
# Apply to query
query
|> LiveFilter.QueryBuilder.apply(filters, schema: Task)
|> LiveFilter.QueryBuilder.apply_pagination(pagination)
# Get total count for pagination UI
total = LiveFilter.QueryBuilder.count(base_query, Repo)
pagination = LiveFilter.Pagination.with_total(pagination, total)Render the paginator:
<LiveFilter.paginator pagination={@pagination} />Paginator Options
| Option | Default | Description |
|---|---|---|
max_pages | 5 | Max page buttons in stepper |
class | "" | Additional CSS classes |
<LiveFilter.paginator pagination={@pagination} max_pages={7} />Handle page changes:
def handle_info({:livefilter, :page_changed, pagination_params}, socket) do
all_params = Map.merge(filter_params, pagination_params)
{:noreply, push_patch(socket, to: ~p"/tasks?#{all_params}")}
endLicense
MIT