A unified smart search bar where structured filters and free text coexist.
Type @ to summon column filters as chips — right inside the input.
React · Vue · Headless Core · API · Accessibility
Every app has a search bar. Most also need column filters. Today you build both separately — a text input and a filter panel with dropdowns. They don't talk to each other. Users context-switch between them.
faceted-search fuses them into one.
┌─────────────────────────────────────────────────────────────────┐
│ 🔍 @Department Engineering @Status Active search people… │
│ ├── chip ──────────────┤ ├── chip ──────┤ ├── free text │
└─────────────────────────────────────────────────────────────────┘
- Type freely — instant substring search across all columns
- Press
@— a guided dropdown appears with available fields - Pick a field — dropdown pivots to that field's values
- Pick a value — a chip locks in, input stays focused, keep going
- Stack filters — they AND together with free text
Backspaceon empty removes the last chipEscapecancels mid-flow- Full ARIA — combobox pattern, live regions, keyboard-navigable
The ingredients exist in GitHub, Linear, Slack, Notion. The recipe — all three fused into a single input — is new.
@faceted-search/core → Framework-agnostic state machine (3kb gzip)
@faceted-search/react → Hook + prop-getters for React 18+
@faceted-search/vue → Composable + attribute-getters for Vue 3.3+
The core is a pure state machine with zero DOM dependencies. It manages filters, free text, dropdown state, keyboard navigation indices, and ARIA attributes. Framework bindings are thin wrappers that sync core state into React/Vue reactivity and provide prop-getter functions you spread onto your own elements.
You own the markup. We own the behavior. Like Downshift or Headless UI.
npm install @faceted-search/core @faceted-search/reactimport { useFacetedSearch } from '@faceted-search/react'
const columns = [
{ key: 'department', label: 'Department' },
{ key: 'status', label: 'Status' },
{ key: 'location', label: 'Location' },
]
function PeopleSearch({ data }) {
const {
state, filteredData, dropdown,
inputRef, rootRef,
getRootProps, getInputProps,
getListboxProps, getOptionProps,
getChipProps, getChipRemoveProps,
getClearProps, getLiveRegionProps,
} = useFacetedSearch({ columns, data })
return (
<div>
{/* Search bar */}
<div ref={rootRef} {...getRootProps()}>
{state.filters.map(f => (
<span key={f.key} {...getChipProps(f)}>
{f.key}: {f.value}
<button {...getChipRemoveProps(f)}>×</button>
</span>
))}
<input ref={inputRef} {...getInputProps()} />
{(state.filters.length > 0 || state.freeText) && (
<button {...getClearProps()}>Clear</button>
)}
</div>
{/* Dropdown */}
{dropdown && (
<div {...getListboxProps()}>
{dropdown.items.map((item, i) => (
<div key={item.key} {...getOptionProps(i)}>
{item.label}
</div>
))}
</div>
)}
{/* Screen reader announcements */}
<div {...getLiveRegionProps()} />
{/* Results */}
<ul>
{filteredData.map(row => (
<li key={row.id}>{row.name}</li>
))}
</ul>
</div>
)
}| Property | Type | Description |
|---|---|---|
state |
FacetedSearchState |
Full internal state (filters, input value, mode, etc.) |
filteredData |
T[] |
Data rows matching all active filters + free text |
dropdown |
DropdownState | null |
Current dropdown items, or null when closed |
aria |
AriaAttributes |
Raw ARIA attributes (if you need manual control) |
inputRef |
RefObject |
Attach to your <input> element |
rootRef |
RefObject |
Attach to the wrapper element |
getRootProps() |
prop-getter | Spread on wrapper div |
getInputProps() |
prop-getter | Spread on input — wires onChange, onKeyDown, onFocus, onBlur |
getListboxProps() |
prop-getter | Spread on dropdown container |
getOptionProps(i) |
prop-getter | Spread on each dropdown item — wires onMouseDown |
getChipProps(filter) |
prop-getter | Spread on each chip |
getChipRemoveProps(filter) |
prop-getter | Spread on chip remove button |
getClearProps() |
prop-getter | Spread on clear-all button |
getLiveRegionProps() |
prop-getter | Spread on a visually-hidden div for screen reader announcements |
removeFilter(key) |
action | Remove a specific filter |
clearAll() |
action | Reset everything |
setFilters(filters) |
action | Controlled mode — set filters programmatically |
npm install @faceted-search/core @faceted-search/vue<script setup>
import { useFacetedSearch } from '@faceted-search/vue'
const columns = [
{ key: 'department', label: 'Department' },
{ key: 'status', label: 'Status' },
]
const {
state, filteredData, dropdown,
inputRef, rootRef,
getRootAttrs, getInputAttrs, getInputHandlers,
getListboxAttrs, getOptionAttrs, getOptionHandlers,
getChipAttrs, removeFilter, clearAll,
} = useFacetedSearch({ columns, data: props.data })
</script>
<template>
<div ref="rootRef" v-bind="getRootAttrs()">
<span
v-for="f in state.filters"
:key="f.key"
v-bind="getChipAttrs(f)"
>
{{ f.key }}: {{ f.value }}
<button @click="removeFilter(f.key)">×</button>
</span>
<input
ref="inputRef"
v-bind="getInputAttrs()"
v-on="getInputHandlers()"
/>
</div>
<div v-if="dropdown" v-bind="getListboxAttrs()">
<div
v-for="(item, i) in dropdown.items"
:key="item.key"
v-bind="getOptionAttrs(i)"
v-on="getOptionHandlers(i)"
>
{{ item.label }}
</div>
</div>
<ul>
<li v-for="row in filteredData" :key="row.id">
{{ row.name }}
</li>
</ul>
</template>Vue uses getInputHandlers() / getOptionHandlers() separately from attrs because Vue's v-on takes an object of event handlers.
For non-React/Vue contexts, or if you want to build your own framework binding:
npm install @faceted-search/coreimport { createFacetedSearch, keyToAction } from '@faceted-search/core'
const store = createFacetedSearch({
columns: [
{ key: 'status', label: 'Status' },
{ key: 'priority', label: 'Priority' },
],
data: myDataArray,
onFiltersChange: (filters) => console.log('Filters:', filters),
onSearch: (text) => console.log('Search:', text),
})
// Subscribe to state changes
store.subscribe(() => {
const state = store.getState()
const results = store.getFilteredData()
const dropdown = store.getDropdownState()
const aria = store.getAriaAttributes()
// Re-render your UI...
})
// Dispatch actions
store.dispatch({ type: 'INPUT_CHANGE', value: '@sta' })
store.dispatch({ type: 'SELECT_COLUMN', key: 'status' })
store.dispatch({ type: 'SELECT_VALUE', value: 'Active' })
// Map keyboard events
document.addEventListener('keydown', (e) => {
const action = keyToAction(e.key, store.getState())
if (action) {
e.preventDefault()
store.dispatch(action)
}
})interface ColumnConfig {
key: string // Property key on data rows
label: string // Human-readable label
icon?: string // Emoji or string shown in dropdowns
color?: string // Accent background color for chips
textColor?: string // Text color paired with accent
values?: string[] | ((data) => string[]) // Explicit values, or auto-derived
match?: (rowValue, filterValue) => boolean // Custom match logic
}interface FacetedSearchOptions<T> {
columns: ColumnConfig[]
data: T[]
initialFilters?: Filter[]
trigger?: string // Default: "@"
placeholder?: string // Default: "Search… type @ to add a field filter"
activePlaceholder?: string // Default: "Continue typing or @ for more filters…"
onFiltersChange?: (filters: Filter[]) => void
onSearch?: (freeText: string) => void
onOpenChange?: (isOpen: boolean) => void
}interface Filter {
key: string // Column key
value: string // Selected value
}interface DropdownState {
mode: 'columns' | 'values'
items: DropdownItem[]
highlightedIndex: number
activeColumn?: ColumnConfig // Set when mode is 'values'
}| Action | Description |
|---|---|
INPUT_CHANGE |
User typed in the input |
SELECT_COLUMN |
Picked a column from the dropdown |
SELECT_VALUE |
Picked a value — commits the filter |
REMOVE_FILTER |
Remove a specific filter by key |
REMOVE_LAST_FILTER |
Remove the most recently added filter |
CLEAR_ALL |
Reset all filters and input |
DISMISS |
Cancel the current dropdown (Escape) |
FOCUS / BLUR |
Track input focus |
HIGHLIGHT_NEXT / HIGHLIGHT_PREV |
Keyboard nav in dropdown |
CONFIRM_HIGHLIGHTED |
Select the highlighted item (Enter) |
SET_FILTERS |
Programmatically set filters (controlled mode) |
The ARIA implementation follows the WAI-ARIA Combobox pattern:
- Input:
role="searchbox",aria-autocomplete="list",aria-controls,aria-activedescendant - Dropdown:
role="listbox"with descriptivearia-label - Options:
role="option"witharia-selectedtracking - Chips:
role="group"witharia-labeldescribing the filter - Live region:
aria-live="polite"announces filter changes and suggestion counts - Keyboard: Full arrow-key navigation, Enter to confirm, Escape to dismiss, Backspace to remove last chip
The prop-getters handle all of this automatically. Spread them and you're compliant.
| Key | Context | Action |
|---|---|---|
@ |
While typing | Opens field picker |
↓ ↑ |
Dropdown open | Navigate options |
Enter |
Dropdown open | Select highlighted option |
Escape |
Dropdown open | Cancel and close |
Backspace |
Empty input, filters exist | Remove last chip |
useFacetedSearch({ columns, data, trigger: '#' })const columns = [{
key: 'name',
label: 'Name',
match: (val, filter) => String(val).toLowerCase().includes(filter.toLowerCase()),
}]const { setFilters } = useFacetedSearch({ columns, data })
// Set filters from URL params, external state, etc.
useEffect(() => {
setFilters(filtersFromURL)
}, [filtersFromURL])// React: just pass new data — the hook syncs automatically
const search = useFacetedSearch({ columns, data: liveData })<!-- Vue: pass a ref and it reacts automatically -->
<script setup>
const data = ref([])
const search = useFacetedSearch(computed(() => ({ columns, data: data.value })))
</script>git clone https://github.com/saurabh/faceted-search
cd faceted-search
npm install
npm run build
npm testThe monorepo uses npm workspaces. packages/core is the brain, packages/react and packages/vue are thin bindings.
Push a version tag to trigger automatic npm publishing:
# Bump versions in all package.json files, then:
git tag v0.1.0
git push origin v0.1.0Requires NPM_TOKEN secret in GitHub repo settings.
MIT
