Skip to content

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 ​

SituationUseWhy
A keyword boxFilterSearchOwns collapse/expand, debounce, ⌘F focus. Pass expanded when it's the page's only filter.
Pick one of many optionsFilterComboboxAutofocus, keyboard nav, type-ahead, empty state for free. :searchable="false" for a short fixed list.
The panel needs custom contentThe 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 open

The 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 ​

PrimitiveRole
FilterTriggerThe pill. Stateless — wrap it in a PopperTrigger.
FilterPanelThe dropdown card (matches the Menu card: rounded, bordered, shadow). Drop it in PopperContent.
FilterPanelItemA selectable row. Emits select. Put flex-1 on the element that should push trailing content right.
FilterSeparatorA 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 ​

PropTypeDefaultDescription
labelstringrequiredThe pill label.
iconIconName—Leading icon (tinted text-cta).
isOpenbooleanfalseActive border + chevron flip. Feed from PopperTrigger's isOpen slot prop.
SlotContent
defaultThe value shown after Label:. Omit it entirely when there's no selection (a passed-but-empty slot still renders the colon).

FilterPanelItem — Props & Events ​

PropTypeDefaultDescription
selectedbooleanfalseReflects selection (drives the radio/checkbox + active background).
control'radio' | 'checkbox' | 'none''radio'The leading control. none = no control, selected row gets a tinted background.
disabledbooleanfalseGreys out and blocks selection.
EventDescription
selectEmitted 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…"
/>
PropTypeDefaultDescription
v-modelstring | number—The selected value.
labelstringrequiredPill label.
options{ value, label, disabled? }[]requiredThe choices.
iconIconName—Leading pill icon.
searchablebooleantrueShow the autofocused type-ahead search box. false = plain fixed list.
placeholderstring'Filter…'Search box placeholder.
emptyTextstring'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" />
PropTypeDefaultDescription
v-modelstring''The (debounced) keyword.
placeholderstring'Search'Field placeholder.
debouncenumber200Delay (ms) before the draft is pushed to v-model. 0 = immediate.
expandedbooleanfalseRender open from the start and never collapse to the icon. Use when search is the page's only/primary filter.

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 @click straight on FilterTrigger does nothing — it's stateless, emits nothing, and forwards no listeners. Wrap it in a PopperTrigger (which supplies the isOpen slot prop and owns open/close), or a plain <button> for a non-popper trigger.
  • A dangling Region: with nothing after it — the Label: colon renders whenever a default slot is passed at all, even if it resolves to an empty string. Branch v-if="hasSelection" / v-else between 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, so FilterCombobox sets highlight-on-hover and 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 — FilterCombobox falls back to a plain CSS hover: + cursor-pointer instead.
  • A pill's menu won't open when a tooltip sits inside its trigger — the menu's PopperTrigger uses @click.stop, and a tooltip inside it swallows the open click. Nest the other way: the outer hover Popper's trigger contains the inner menu Popper — as ReportingYearFilter does — and force the tooltip closed when the menu opens.