Faceted select
A faceted filter lets people filter a result set by several categories. Anta calls each category a facet. The trigger opens a menu of facets, and each facet opens a control for choosing its value.
The value is a Record<facetKey, value>. This keeps values separate when two
facets use the same option. For example, assignee and owner below are
application-defined facet keys, not Anta terms. Each facet has one of four
kind values: single, multiple, text, or custom.
SelectFaceted uses Menu, Select’s option model,
Input, and Tag. It does not create a custom element.
Use value with onValueChange to control it, or use defaultValue for an
uncontrolled filter.
Without searchable, the trigger opens a menu of facets. With searchable, it
opens a dialog containing the search field and that menu. A filtered option
facet also opens a dialog containing its search field and options menu. Text and
custom facets use dialogs for their editable content. An unfiltered option facet
keeps the menu popup pattern.
Pass placement to control where the root facet menu opens relative to its
trigger. It uses the same values as Menu and still flips or clamps when space
is limited. offset sets the gap in pixels between the trigger and the menu:
<SelectFaceted placement="top-end" offset={8} facets={FACETS} /> Playground
Open Metadata in the first demo to choose key–value tags such as
env production or region us-east. Its renderOption callback reads
custom option fields and renders a Tag with label and value. Search
for production to see the same tag in global results.
const FACETS = [ { key: 'assignee', label: 'Assignee', kind: 'multiple', filter: true, options: people }, { key: 'owner', label: 'Owner', kind: 'single', filter: true, options: people }, { key: 'status', label: 'Status', kind: 'single', options: [{ value: 'open', label: 'Open' }, /* … */] }, { key: 'metadata', label: 'Metadata', kind: 'multiple', icon: 'tag', filter: true, options: [ { value: 'env:production', label: 'env: production', dataKey: 'env', dataValue: 'production' }, { value: 'env:staging', label: 'env: staging', dataKey: 'env', dataValue: 'staging' }, { value: 'region:us-east', label: 'region: us-east', dataKey: 'region', dataValue: 'us-east' }, { value: 'team:platform', label: 'team: platform', dataKey: 'team', dataValue: 'platform' }, ], renderOption: (option) => ( <Tag label={String(option.dataKey)} value={String(option.dataValue)} /> ), }, { key: 'title', label: 'Title contains', kind: 'text' }, { key: 'duration', label: 'Min duration', kind: 'custom', summary: (v) => `≥ ${v.min}s`, render: /* … */ },]
const isSet = (v) => !(v == null || v === '' || (Array.isArray(v) && v.length === 0))
function Demo() { const [value, setValue] = useState({ assignee: ['Alice Nguyen'], status: 'open', title: 'crash' }) const setFacet = (key, v) => setValue((p) => (isSet(v) ? { ...p, [key]: v } : (({ [key]: _, ...rest }) => rest)(p)))
// Keep a text/custom chip mounted while it's focused, even when emptied — so // backspacing to empty doesn't yank the field (only Clear or blur-when-empty does). const [focusedKey, setFocusedKey] = useState(null) const active = FACETS.filter((f) => isSet(value[f.key]) || f.key === focusedKey)
return ( <div style={{ display: 'flex', flexDirection: 'row', flexWrap: 'wrap', gap: 12, alignItems: 'center', width: '100%', }}> <SelectFaceted facets={FACETS} value={value} onValueChange={setValue} searchable />
{active.length > 0 && ( <div style={{ display: 'flex', flexDirection: 'row', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}> {/* Show one editable control for each active facet. Its label becomes the control's `leading` prefix. */} {active.map((facet) => { // Options facets → an editable Select (single shows the trailing check; // both are filterable + have a clearable footer). if (facet.kind === 'single' || facet.kind === 'multiple') return ( <Select key={facet.key} selection={facet.kind} indicator={facet.kind === 'single' ? 'check' : undefined} options={facet.options} renderOption={facet.renderOption} value={value[facet.key]} onValueChange={(v) => setFacet(facet.key, v)} leading={`${facet.label}:`} filter={facet.filter} clearable // Chip sizes to its value and caps at a max-width (ellipsizing past it). style={{ width: 'fit-content', maxWidth: '240px' }} /> )
// Free-form text / custom → an editable Input. const val = facet.kind === 'text' ? value[facet.key] : value[facet.key]?.min const write = (raw) => setFacet(facet.key, facet.kind === 'text' ? raw || undefined : raw ? { min: raw } : undefined) return ( <Input key={facet.key} value={val ?? ''} leading={`${facet.label}:`} clearable dimActions onFocus={() => setFocusedKey(facet.key)} onBlur={() => setFocusedKey(null)} onInput={(e) => write(e.currentTarget.value)} onClearInput={() => { setFocusedKey(null); setFacet(facet.key, undefined) }} // Size the chip to its value, capped at a max-width. style={{ width: 'fit-content', maxWidth: '240px' }} /> ) })} {/* Once more than one result is showing, a single control resets them all — pushed to the right of the last chip. */} {active.length > 1 && ( <Button priority="tertiary" icon="filter-x" label="Clear all" style={{ marginLeft: 'auto' }} onClick={() => { setFocusedKey(null); setValue({}) }} /> )} </div> )} </div> )}The trigger opens the facet menu. The example renders one editable
Select below it for each active facet. Each Select receives the
facet label through leading, which displays it as a prefix.
Custom option content
Pass renderOption(option, state) on a single or multiple facet to replace
each option’s label, hint, and icon layout. It runs in both the facet flyout and
global search results. state contains value, selected, and disabled.
Selection handling, indicators, and option styling remain on the row.
Return null to use the default layout for an option.
The first demo’s Metadata facet stores the key and value in custom option
fields. Pass them to Tag’s label and value props:
renderOption: (option) => ( <Tag label={String(option.dataKey)} value={String(option.dataValue)} />)Filtering still uses value, label, and hint, or the facet’s custom filter
function. Keep searchable text in those fields. Single-facet summaries still
use the option label, and multiple-facet summaries show the selected count.
Value and changes
The value is a record keyed by facet key: { [facetKey]: value }. Its shape
depends on kind: an option value for single, an array for multiple, a
string for text, and a value of type V for custom. A cleared facet is
absent from the record, rather than set to undefined. The trigger badge counts
the keys that remain. Use value with onValueChange to control the filter, or
use defaultValue for the uncontrolled case.
Because one control mixes facets that hold different value types, the record is typed
Record<string, unknown>; narrow per facet when you read it.
onValueChange(value, attrs) fires after any change:
value— the whole new record, e.g.{ assignee: ['Alice'], status: 'open', title: 'crash' }.attrs— what changed:{ facet, kind, value }for a single facet edit (the facet’skey, itskind, and its new value,undefinedwhen that facet was cleared), or{ all: true }for the Clear all row. Narrow on'all' in attrsbefore readingfacet.
<SelectFaceted facets={FACETS} onValueChange={(value, attrs) => { if ('all' in attrs) return clearFilters() // the Clear all row // `value` is the full record; `attrs.facet` / `attrs.value` is the one that changed applyFilter(attrs.facet, attrs.value) }}/>Value types
single and multiple facets use Select option
values: string, number, or boolean (OptionValue). Anta compares them
with === and returns them unchanged. For a non-primitive value, use a stable
primitive key such as a date’s ISO string or a record ID. Look up the full value
with that key. Use a custom facet when the facet value is an object, such as a
date range or numeric comparison. Its editor can return any value of type V.
Custom facet
A custom facet has no option list. Provide its editor and choose the value it
returns: a number, date, range, or any other object. In addition to key,
label, and icon, provide two functions:
render(ctx)renders the editor.ctx.valueis the current value.ctx.onChange(next)sets it, andundefinedclears the facet.ctx.close()closes the menu. Anta stores the value passed toonChangeatvalue[key]without changing it.summary(value)renders the chip on the facet row when a value is set. It does not change the stored value. It can return any node ornull. Long summaries are truncated so that they do not widen the menu.
SelectFaceted does not filter application data. It stores the facet values and
reports them through onValueChange. Filter the application’s rows from that
value. Type the facet as SelectFacetCustom<V> so ctx.value and summary
use the same value type.
For a minimum-duration filter, the editor is one Input. Because that Input
does not open a popup, it appears directly in the facet menu.
import type { SelectFacetCustom } from '@antadesign/anta'
const duration: SelectFacetCustom<string> = { key: 'duration', label: 'Min duration', kind: 'custom', render: (ctx) => ( <Input // Controlled numeric field: type="text" + inputMode, not type="number" // (which sanitizes "1." / "1.0" to empty). Keep the raw string; parse it below. type="text" inputMode="decimal" size="small" placeholder="seconds" value={ctx.value ?? ''} onInput={(e) => ctx.onChange(e.currentTarget.value || undefined)} /> ), summary: (v) => `≥ ${v}s`,}
<SelectFaceted facets={[duration]} value={value} onValueChange={setValue} />
// user types 1.5 → value.duration === "1.5" (the raw string, returned unchanged)// parse it where you apply the filter, so partial input ("1.", "1.0") types cleanly:rows.filter((r) => value.duration == null || r.seconds >= Number(value.duration))For dates, the example converts between the Calendar’s ISO strings and a stored
Date. Editors that open a menu, including InputDate and Select, also work
inside a custom facet. Their menu opens above the facet menu, which remains open.
import type { SelectFacetCustom } from '@antadesign/anta'
const toISO = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` // Date → "2026-01-01"const fromISO = (s: string | null) => (s ? new Date(`${s}T00:00:00`) : undefined) // ISO → Date | undefined
const since: SelectFacetCustom<Date> = { key: 'since', label: 'Created after', kind: 'custom', icon: 'calendar', render: (ctx) => ( <Calendar size="small" value={ctx.value ? toISO(ctx.value) : ''} // Controlled: apply the pick in onStateChange (onValueChange fires only after // value changes, so it can't drive a controlled Calendar). onStateChange={(_e, { next }) => ctx.onChange(fromISO(next))} /> ), summary: (d) => d.toLocaleDateString(),}
<SelectFaceted facets={[since]} value={value} onValueChange={setValue} />
// user picks a day → value.since is a Date, returned unchanged// you apply it:rows.filter((r) => value.since == null || r.createdAt >= value.since)A custom facet stores a Date directly. Unlike the value of a single or
multiple facet, it is not compared with === to find an option.
A recency filter: presets plus a range
This editor combines a RadioGroup of presets with a Custom range option
that reveals two InputDate fields. Its value is either { preset } or
{ from, to }. The resolveRange helper converts either shape into the date
range used to filter the result set. Each InputDate calendar opens above the
facet menu without closing it.
type Recency = { preset: 'today' | 'yesterday' | 'last14' | 'last30' } | { from: string; to: string }
// Whichever shape the facet holds, resolve it to a concrete range to filter with.const resolveRange = (r: Recency): { from: string; to: string } => { if ('from' in r) return r const today = isoDay(daysAgo(0)) switch (r.preset) { case 'today': return { from: today, to: today } case 'yesterday': { const y = isoDay(daysAgo(1)); return { from: y, to: y } } case 'last14': return { from: isoDay(daysAgo(13)), to: today } case 'last30': return { from: isoDay(daysAgo(29)), to: today } }}
import type { SelectFacetCustom } from '@antadesign/anta'
const recency: SelectFacetCustom<Recency> = { key: 'recency', label: 'Recency', kind: 'custom', icon: 'calendar', summary: (v) => ('preset' in v ? PRESET_LABELS[v.preset] : `${v.from} → ${v.to}`), render: ({ value, onChange }) => { const mode = value == null ? '' : 'preset' in value ? value.preset : 'custom' const range = value && 'from' in value ? value : { from: '', to: '' } return ( <div style={{ display: 'grid', gap: 8 }}> <RadioGroup size="small" options={PRESETS} // Today / Yesterday / Last 14 / Last 30 / Custom range value={mode} // Controlled group: apply the pick in onStateChange (onValueChange fires // only after `value` changes, so it can't drive a controlled RadioGroup). onStateChange={(_e, { next }) => onChange(next === 'custom' ? range : { preset: next })} /> {mode === 'custom' && ( <> <InputDate size="small" label="From" value={range.from} onValueChange={(from) => onChange({ from, to: range.to })} /> <InputDate size="small" label="To" value={range.to} min={range.from || undefined} onValueChange={(to) => onChange({ from: range.from, to })} /> </> )} </div> ) },}
<SelectFaceted facets={[recency]} value={value} onValueChange={setValue} />// value.recency is { preset: 'last14' } or { from, to } — apply resolveRange(value.recency) to your rowsMenu-item presets, calendar on the right
This version renders the presets as MenuItem rows and marks the selected row
with a check. Custom day reveals one InputDate. Its calendar opens to the
right with placement="right-start", which is useful when the trigger is on the
left side of a wide layout. placement and offset are passed to the
calendar’s Menu.
type Recency = { preset: 'today' | 'yesterday' | 'last14' | 'last30' } | { day: string }
const recency: SelectFacetCustom<Recency> = { key: 'recency', label: 'Recency', kind: 'custom', icon: 'calendar', summary: (v) => ('preset' in v ? PRESET_LABELS[v.preset] : v.day || 'Custom day'), render: ({ value, onChange }) => { const mode = value == null ? '' : 'preset' in value ? value.preset : 'custom' const day = value && 'day' in value ? value.day : '' return ( <> {DAY_PRESETS.map((p) => ( // Today / Yesterday / Last 14 / Last 30 / Custom day <MenuItem key={p.value} label={p.label} selectionIndicator="check" selected={mode === p.value} data-menu-open onSelect={() => onChange(p.value === 'custom' ? { day } : { preset: p.value })} /> ))} {mode === 'custom' && ( <div data-menu-open style={{ padding: 8 }}> {/* opens the calendar to the right of the field */} <InputDate size="small" label="Day" placement="right-start" value={day} onValueChange={(d) => onChange({ day: d })} /> </div> )} </> ) },}
<SelectFaceted facets={[recency]} value={value} onValueChange={setValue} />// value.recency is { preset: 'last14' } or { day: '2026-07-20' } — resolve a preset to a// concrete date (or take the day as-is) and filter your rows with it Component props
| Prop | Type | Default | Description |
|---|---|---|---|
| facets | SelectFacet[] | — | Categories to filter by. Each facet has a kind that determines its editor. |
| placement? | leftrightbottomtopbottom-startbottom-endtop-starttop-endright-startright-endleft-startleft-end | bottom-start | Preferred placement of the root filter menu relative to its trigger. The menu auto-flips vertically and clamps horizontally when needed. |
| offset? | number | 4 | Gap in pixels between the trigger and the filter menu. |
| value? | SelectFacetedValue | — | Controlled value record, keyed by facet. When provided, update it through
onValueChange. Leave it undefined for uncontrolled use. |
| defaultValue? | SelectFacetedValue | — | Initial value for uncontrolled use. |
| onValueChange? | (value, attrs) => void | — | Fires after any facet changes. value is the whole new record (a facet key →
that facet's value; a cleared facet is absent). attrs says what changed:
{ facet, kind, value } for a single facet edit, or { all: true } for the
"Clear all" row — narrow on 'all' in attrs before reading facet. |
| label? | string | Filter | Default trigger's button label. |
| icon? | IconShape | filter | Default trigger's leading icon. |
| size? | smallmediumlarge | medium | Default trigger's button size. |
| priority? | primarysecondary | secondary | Default trigger's button priority. |
| disabled? | boolean | — | Disable the whole control. |
| tone? | neutralbrandcriticalinfosuccesswarningstring | — | Default option-row tone in facet flyouts. An option's own tone wins. |
| toneScope? | allselected | 'all' | Apply the default row tone in every state, or only to selected rows. An
option's own toneScope wins. |
| searchable? | boolean | — | Adds a search field at the top of the root menu. It searches the options of
every single and multiple facet in one list. For example, "alice" can
appear under application-defined Assignee and Owner facets. text and
custom facets remain available when the search is empty. Each facet uses
its filter function when supplied, or the built-in substring match. |
| searchPlaceholder? | string | Filter… | Placeholder for the global search field. |
| clearable? | boolean | true | Show the per-facet "Clear" row and the "Clear all" row. |
| clearAllLabel? | string | Clear all | Label for the "Clear all" row. |
| renderTrigger? | (state) => ReactNode | — | Replaces the default Button with a trigger returned from this function.
Receives a SelectFacetedTriggerState. Return exactly one focusable element:
the menu is positioned relative to that element and opens when it is clicked.
Add aria-haspopup={searchable ? 'dialog' : 'menu'} and
aria-expanded={state.open} to the returned element, on a role that
supports them (an Anta Button already carries role="button"; otherwise
add role="combobox"). className, style, and other trigger props apply
only to the default Button, so add styling and attributes to the returned
element. |
Inherited props (className, id, slot, style, tabIndex, title)
| Prop | Type | Default | Description |
|---|---|---|---|
| className? | string | — | CSS class on the component's root element (merged with the component's own
classes). Use it directly for layout and positioning — grid/flex placement,
margins, alignment — rather than wrapping the component in a <div>/<span>. |
| id? | string | — | HTML id attribute. |
| slot? | string | — | Assigns the element to a named <slot> of a parent web component (e.g.
slot="header" inside a <Card>, slot="footer" inside a <Dialog>). |
| style? | CSSProperties | — | Inline styles on the component's root element. Set layout/positioning here
(or via className) directly on the component instead of adding a wrapper. |
| tabIndex? | number | — | Tab order. Set to -1 to skip the element when tabbing. |
| title? | string | — | HTML title attribute — native browser tooltip on hover. |
Plus every standard DOM event handler (onClick,
onFocus, onKeyDown, …) and any
data-* or aria-* attribute — forwarded as-is to
the underlying <a-*> element.
The SelectFacet type
facets takes a SelectFacet[]. Every facet has a key, label, and optional
icon. The kind field selects one of the shapes below.
single and multiple facets use the same options, filter, and selectAll
properties as Select. They use kind in place of Select’s
selection. text and custom do not use Select options. A single facet
returns one option value; a multiple facet returns an array of option values.
When selectAll is enabled, Alt-clicking an option in a multiple facet clears
the other values and selects that option. Use Option-click on macOS. After a
700ms hover, a tooltip beside the row describes the shortcut. Moving to another
row restarts the delay. An option’s tooltip replaces that hint; set it
to '' to hide the hint.
| Field | Type | Description |
|---|---|---|
key | string | The key for this facet’s value in the record. attrs.facet returns it. It must be unique in facets. |
label | string | The facet’s row label in the menu. |
icon | IconShape | Leading icon on the facet’s row. |
single (SelectFacetSingle) selects one option. Its value is the selected
option’s value, or is absent when cleared. Selecting the current option again
clears it.
| Field | Type | Description |
|---|---|---|
kind | 'single' | Discriminant. |
options | SelectItem[] | The choices — bare strings or SelectOptions (groups / submenus flatten to their leaves). Option values may be string / number / boolean. |
filter | FacetFilter | Search field over this facet’s options: true for the built-in substring match, or (option, query) => boolean. |
multiple (SelectFacetMultiple) selects any number of options. Its value
is an array of the selected option values. An empty array clears it.
| Field | Type | Description |
|---|---|---|
kind | 'multiple' | Discriminant. |
options | SelectItem[] | As single. |
filter | FacetFilter | As single. |
selectAll | boolean | A “Select all” row toggling every visible option. Default true. |
selectAllLabel | string | Label for that row. Default Select all. |
text (SelectFacetText) accepts a free-form string. It applies on Enter or
blur and is absent when empty.
| Field | Type | Description |
|---|---|---|
kind | 'text' | Discriminant. |
placeholder | string | Placeholder for the text field. |
custom (SelectFacetCustom<V>) renders the editor supplied by render.
It can store a value of type V, including an object such as a date range.
| Field | Type | Description |
|---|---|---|
kind | 'custom' | Discriminant. |
render | (ctx) => ReactNode | Renders the editor. ctx.value is the current V; ctx.onChange(next) sets it, and undefined clears the facet. ctx.close() closes the menu. |
summary | (value: V) => ReactNode | The chip on the facet’s row while a value is set. |