LiveFilter

Hex.pmHex DocsLicense

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

Installation

Add livefilter to your dependencies in mix.exs:

def deps do
[
{:livefilter, "~> 0.2.0"}
]
end

Then 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
end

2. 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}")}
end

3. 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))
end

Filter Types

TypeFunctionDefault Operators
TextLiveFilter.text/2ilike, eq, neq, like
NumberLiveFilter.number/2eq, neq, gt, gte, lt, lte
SelectLiveFilter.select/2eq, neq
Multi-selectLiveFilter.multi_select/2ov, cs
DateLiveFilter.date/2eq, gt, gte, lt, lte
Date RangeLiveFilter.date_range/2gte_lte
DateTimeLiveFilter.datetime/2eq, gt, gte, lt, lte
BooleanLiveFilter.boolean/2is
Radio GroupLiveFilter.radio_group/2eq

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
)
OperatorLabelValue ModeURL FormatDescription
:eq"is"singlestatus=eq.activeExact match
:neq"is not"singlestatus=neq.draftNot equal
:in"is any of"multistatus=in.(a,b,c)Matches any selected value
:not_in"is none of"multistatus=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:

ModeDescription
:basicSimple chips without operator selection (default)
:commandFull 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

ThemeDescription
:defaultDaisyUI btn-based styling with outline variant
:minimalLighter padding and simpler styling
:borderedPrimary color accent with borders
:neutralTheme-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:

VariantClass AppliedNotes
:outlinebtn-outlineCan have dark active/focus states
:ghostbtn-ghostTransparent background
:softbtn-softSubtle 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:

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

OptionDefaultDescription
max_pages5Max 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}")}
end

Sorting

LiveFilter adds PostgREST-compatible order= sorting, modeled as an orthogonal, URL-shareable concern just like pagination. The headless core (LiveFilter.Sort) is multi-column ready; the bundled UI sets a single active sort.

# Declare sortable fields (static, or built at runtime from a user's column settings).
# This list is also the allow-list that incoming `order=` params are validated against.
defp sortable_fields do
[
LiveFilter.sort_field(:title, label: "Title"),
LiveFilter.sort_field(:status, label: "Status"),
LiveFilter.sort_field(:due_date, label: "Due", default_direction: :desc, nulls: :last)
]
end
# In handle_params — parse off the remaining params (after filters + pagination).
# Unknown/malformed fields are dropped; `default:` applies when no order is present.
{filters, remaining} = LiveFilter.from_params(params, filter_config())
{pagination, remaining} = LiveFilter.pagination_from_params(remaining, default_limit: 25)
{sort, remaining} = LiveFilter.sort_from_params(remaining, sortable_fields(), default: %LiveFilter.Sort{})
# Apply to the query. A stable `:id` tiebreaker is appended by default so paginated,
# low-cardinality sorts don't skip/repeat rows across pages.
query
|> LiveFilter.QueryBuilder.apply(filters, schema: Task)
|> LiveFilter.QueryBuilder.apply_sort(sort)
|> LiveFilter.QueryBuilder.apply_pagination(pagination)
# Serialize back into the URL alongside filters + pagination.
all_params =
filter_params
|> Map.merge(LiveFilter.Sort.to_params(sort))
|> Map.merge(LiveFilter.Params.Serializer.pagination_to_params(pagination))

UI

A standalone "Order by" dropdown and/or per-column sortable headers — both drive the same %LiveFilter.Sort{} state:

<LiveFilter.sort_menu sortable_fields={sortable_fields()} sort={@sort} />
<LiveFilter.sort_header field={:title} label="Title" sort={@sort} sortable_fields={sortable_fields()} />

The header emits lf_sort (tri-state toggle: none → default → opposite → none) and the menu emits lf_sort_to (explicit direction). Handle them by updating the sort and patching the URL (reset the offset to page 1 on a sort change):

def handle_event("lf_sort", %{"field" => field}, socket) do
field = String.to_existing_atom(field)
sort = LiveFilter.Sort.toggle(socket.assigns.sort, field, sortable_fields())
{:noreply, push_patch(socket, to: sort_path(socket, sort))}
end
def handle_event("lf_sort_to", %{"field" => field, "direction" => dir}, socket) do
field = String.to_existing_atom(field)
sort = LiveFilter.Sort.put(socket.assigns.sort, field, String.to_existing_atom(dir))
{:noreply, push_patch(socket, to: sort_path(socket, sort))}
end

LiveFilter.Sort.toggle/3 accepts the SortField list and reads each field's declared default_direction/nulls. Both components reflect the active column via aria-sort.

Headless

Skip the components and build your own headers from the primitives: LiveFilter.Sort.direction_for/2, toggle/3, put/4, clear/1, and to_params/1. Multi-column sort is modeled (order=a.desc,b.asc) even though the bundled UI sets a single active sort.

License

MIT