Filter
A filter row is a line of pills. Every filter — single-select, multi-select, a boolean toggle, a year picker, a keyword search — wears the same pill, so the whole row reads as one calm, consistent control instead of a jumble of mismatched inputs.
ts
import { FilterTrigger, FilterPanel, FilterPanelItem, FilterSeparator, FilterCombobox, FilterSearch } from '@scaler-tech/aurora/filter'Year:2024
Data completion:Critical (Scaler)
87%
Issue type:
Inactive/Sold:Exclude
KPI:Energy use intensity
query: — · year: 2024 · completion: scaler · issues: error · include sold: false · KPI: energy_intensity
That's the live filters showcase — a search box, a year picker, a multi-select and a toggle, all wearing the same pill, in the fixed order the row law prescribes: FilterSearch first, the reporting year second, the other filters after.
THESE ARE EXAMPLES — COPY THE PATTERN, NOT THE FILE
ReportingYearFilter, DataCompletionFilter, IssueTypeFilter and InactiveSoldFilter are showcase examples, not exported components. They live in view/showcase/filters/ and are not part of @scaler-tech/aurora/filter — you can't import them, and you shouldn't copy them wholesale into a product. They exist to show how to compose the primitives into a real domain filter. When you need one, build your own small file in your app (owning its data + v-model) and reach for the exported primitives below.
The ideas
- One pill shape, so the row reads as one control. A leading icon, the label, an optional
: value, and a chevron — every filter looks the same, whether it's a year picker or a multi-select. - Each filter owns itself. A concrete filter — the year picker, the issue-type multi-select — is its own small file that holds its data, its
v-model, and decides what its pill shows. The page just lines them up. - One row, one home, one order. Every filter lives in a single row — including the reporting year, which never goes in the page header. Placement and ordering are law: The filter row.
Use when
| Situation | Use | Why |
|---|---|---|
| A keyword box | FilterSearch | Owns collapse/expand, debounce, ⌘F focus. Pass expanded when it's the page's only filter. |
| Pick one of many options | FilterCombobox | Autofocus, keyboard nav, type-ahead, empty state for free. :searchable="false" for a short fixed list. |
| The panel needs custom content | The primitives (FilterTrigger + FilterPanel + FilterPanelItem) | Status dots, counts, a toggle, a calendar/fiscal switch, multi-select, date ranges. |
The shape
Every pill is the same anatomy: a neutral, outline, clickable Tag holding an optional icon, the label, an optional : value, and a chevron that flips when open — all inside a Popper.
[icon] Label: <value> ⌄
└─ Tag(variant="neutral" outline clickable) ─ border turns cta when openThe value is whatever you put in the trigger's default slot — text, a % badge, status dots, anything. The colon + value only render when that slot has content (a bare Label ⌄ when nothing is selected).
Primitives
| Primitive | Role |
|---|---|
FilterTrigger | The pill. Stateless — wrap it in a PopperTrigger. |
FilterPanel | The dropdown card (matches the Menu card: rounded, bordered, shadow). Drop it in PopperContent. |
FilterPanelItem | A selectable row. Emits select. Put flex-1 on the element that should push trailing content right. |
FilterSeparator | A full-bleed divider inside a FilterPanel (breaks out of the panel's p-1). |
A hand-built single-select, start to finish:
Region:Europe
region: eu
vue
<template>
<Popper placement="bottom-start">
<PopperTrigger v-slot="{ isOpen }">
<FilterTrigger label="Region" icon="globe-alt" :is-open="isOpen">
{{ selected.label }}
</FilterTrigger>
</PopperTrigger>
<PopperContent v-slot="{ close }">
<FilterPanel>
<FilterPanelItem
v-for="r in regions"
:key="r.value"
:selected="r.value === model"
@select="select(r.value, close)"
>
<span class="flex-1">{{ r.label }}</span>
</FilterPanelItem>
</FilterPanel>
</PopperContent>
</Popper>
</template>
<script setup lang="ts">
import { FilterTrigger, FilterPanel, FilterPanelItem } from '@scaler-tech/aurora/filter'
import { Popper, PopperTrigger, PopperContent } from '@scaler-tech/aurora/popper'
const model = defineModel<string>({ required: true })
// … regions, selected, select(value, close) …
</script>The primitives don't assume a list. InactiveSoldFilter puts a single Toggle in the panel; IssueTypeFilter uses control="checkbox" for multi-select and shows overlapping dots in the pill; DataCompletionFilter puts a % badge next to each row. Same four primitives, different content.
FilterTrigger — Props & Slots
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | required | The pill label. |
icon | IconName | — | Leading icon (tinted text-cta). |
isOpen | boolean | false | Active border + chevron flip. Feed from PopperTrigger's isOpen slot prop. |
| Slot | Content |
|---|---|
| default | The value shown after Label:. Omit it entirely when there's no selection (a passed-but-empty slot still renders the colon). |
FilterPanelItem — Props & Events
| Prop | Type | Default | Description |
|---|---|---|---|
selected | boolean | false | Reflects selection (drives the radio/checkbox + active background). |
control | 'radio' | 'checkbox' | 'none' | 'radio' | The leading control. none = no control, selected row gets a tinted background. |
disabled | boolean | false | Greys out and blocks selection. |
| Event | Description |
|---|---|
select | Emitted on click. The caller owns the selection state and decides what it does (e.g. set the model + close()). |
FilterPanel and FilterSeparator are presentational — pass class to tweak padding.
FilterCombobox — single-select
When the panel is just a list of options, skip the hand-build. FilterCombobox wraps the FilterTrigger + FilterPanel chrome around the reka-backed Listbox, so you get autofocus, arrow-key nav, type-ahead, and a no-match state.
Set :searchable="false" for a short fixed list (a handful of options — a view mode, a year) where a search box is just noise. The panel drops the input and becomes a plain click/arrow-key list.
KPI:Energy use intensity
KPI: energy_intensity
vue
<FilterCombobox
v-model="metric"
label="KPI"
:options="[{ value: 'ghg_intensity', label: 'GHG intensity' }]"
icon="chart-bar"
placeholder="Filter KPIs…"
/>| Prop | Type | Default | Description |
|---|---|---|---|
v-model | string | number | — | The selected value. |
label | string | required | Pill label. |
options | { value, label, disabled? }[] | required | The choices. |
icon | IconName | — | Leading pill icon. |
searchable | boolean | true | Show the autofocused type-ahead search box. false = plain fixed list. |
placeholder | string | 'Filter…' | Search box placeholder. |
emptyText | string | 'No results' | No-match text. |
placement | 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end' | 'bottom-start' | Panel placement. |
Single-select today; multiple is a future extension (the underlying ListboxRoot already supports it).
FilterSearch — keyword pill
A search box shaped like the other pills: collapsed to a magnifying-glass icon when empty, expanding to an inline field on focus. The visible field updates instantly while the exposed v-model is debounced (default 200 ms), so consumers fire once the user pauses. ⌘F / Ctrl+F focuses it instead of the browser's find.
Pass expanded to render the field open from the start and never collapse it back to the icon. Use it when search is the only/primary filter on the page — a lone collapsed magnifying-glass is needless friction when there are no other pills to keep it company.
query: —
vue
<FilterSearch v-model="query" placeholder="Search assets" :debounce="200" />| Prop | Type | Default | Description |
|---|---|---|---|
v-model | string | '' | The (debounced) keyword. |
placeholder | string | 'Search' | Field placeholder. |
debounce | number | 200 | Delay (ms) before the draft is pushed to v-model. 0 = immediate. |
expanded | boolean | false | Render open from the start and never collapse to the icon. Use when search is the page's only/primary filter. |
Calm in-panel search
A filter's own search box is borderless — no input outline competing with the rows — separated from the list by a FilterSeparator divider, with its text aligned to the option labels (not indented). FilterCombobox already does this; when you hand-build a panel with a header row above the options, divide them the same way:
vue
<FilterPanel>
<div class="flex items-center justify-between gap-3 px-2 py-1.5"> … header … </div>
<FilterSeparator />
<FilterPanelItem v-for="…"> … </FilterPanelItem>
</FilterPanel>Incorrect / Correct
Incorrect (a hand-rolled search input — FilterSearch exists and matches the row):
vue
<FormInput v-model="query" placeholder="Search…">
<Icon name="magnifying-glass" />
</FormInput>Correct (the pill matches the filter row and owns collapse/expand + debounce):
vue
<FilterSearch v-model="query" placeholder="Search assets" />Incorrect (a click handler on FilterTrigger — it's stateless and forwards no listeners, nothing opens):
vue
<FilterTrigger label="Region" @click="open = !open" />Correct (PopperTrigger owns the open state and feeds isOpen):
vue
<PopperTrigger v-slot="{ isOpen }">
<FilterTrigger label="Region" :is-open="isOpen" />
</PopperTrigger>Incorrect (an always-present value slot — an empty selection renders a dangling Region:):
vue
<FilterTrigger label="Region" :is-open="isOpen">{{ selected?.label }}</FilterTrigger>Correct (branch between a trigger with the slot and one without):
vue
<FilterTrigger v-if="selected" label="Region" :is-open="isOpen">{{ selected.label }}</FilterTrigger>
<FilterTrigger v-else label="Region" :is-open="isOpen" />Incorrect (a hand-rolled pill — a Tag + chevron with a MenuItems body doesn't match the family):
vue
<Tag variant="neutral" outline clickable @click="…">Status ⌄</Tag>Correct (every pill is built from the family):
vue
<PopperTrigger v-slot="{ isOpen }">
<FilterTrigger label="Status" :is-open="isOpen" />
</PopperTrigger>Gotchas
- A
@clickstraight onFilterTriggerdoes nothing — it's stateless, emits nothing, and forwards no listeners. Wrap it in aPopperTrigger(which supplies theisOpenslot prop and owns open/close), or a plain<button>for a non-popper trigger. - A dangling
Region:with nothing after it — theLabel:colon renders whenever a default slot is passed at all, even if it resolves to an empty string. Branchv-if="hasSelection"/v-elsebetween a trigger with the value slot and one without. - Your own Listbox-based filter doesn't highlight rows on hover — reka's Listbox highlights on hover only when
highlightOnHover && !focusable, and an autofocused search input makes the list non-focusable, soFilterComboboxsetshighlight-on-hoverand your hand-built filter must too. [external] With:searchable="false"there's no input, the list is focusable (arrow-key nav works), and the flag no longer applies —FilterComboboxfalls back to a plain CSShover:+cursor-pointerinstead. - A pill's menu won't open when a tooltip sits inside its trigger — the menu's
PopperTriggeruses@click.stop, and a tooltip inside it swallows the open click. Nest the other way: the outer hoverPopper's trigger contains the inner menuPopper— asReportingYearFilterdoes — and force the tooltip closed when the menu opens.
Related
- The filter row — the placement + ordering law (search first, year second, actions
ml-auto) - Page structure — where the filter row sits on a page
- Tabs — filters under the tablist
Listbox— whatFilterComboboxis built onPopper·Tag- Filters showcase — the live pill row