AntaSelectFaceted
Switch to dark theme
Search documentation
On this page

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.

Assignee1Select allAlice NguyenAlt+Click to select only thisBob CarterAlt+Click to select only thisCarol DiazAlt+Click to select only thisDave FeldAlt+Click to select only thisErin ShahAlt+Click to select only thisFrank LopezAlt+Click to select only thisGrace KimAlt+Click to select only thisHeidi BraunAlt+Click to select only thisIvan PetrovAlt+Click to select only thisJudy ChenAlt+Click to select only thisKarl OberAlt+Click to select only thisLiz MoreauAlt+Click to select only thisClearOwnerAlice NguyenBob CarterCarol DiazDave FeldErin ShahFrank LopezGrace KimHeidi BraunIvan PetrovJudy ChenKarl OberLiz MoreauStatusOpenOpenNot startedIn progressBeing worked onClosedDoneClearLabelSelect allBugAlt+Click to select only thisFeatureAlt+Click to select only thisDocsAlt+Click to select only thisChoreAlt+Click to select only thisMetadataSelect allenvproductionAlt+Click to select only thisenvstagingAlt+Click to select only thisregionus-eastAlt+Click to select only thisteamplatformAlt+Click to select only thisTitle containscrash
Clear
Min duration
seconds
Clear all
Select allAlice NguyenAlt+Click to select only thisBob CarterAlt+Click to select only thisCarol DiazAlt+Click to select only thisDave FeldAlt+Click to select only thisErin ShahAlt+Click to select only thisFrank LopezAlt+Click to select only thisGrace KimAlt+Click to select only thisHeidi BraunAlt+Click to select only thisIvan PetrovAlt+Click to select only thisJudy ChenAlt+Click to select only thisKarl OberAlt+Click to select only thisLiz MoreauAlt+Click to select only thisClearOpenNot startedIn progressBeing worked onClosedDoneClearTitle contains:Clear all
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:

<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:

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.

Min duration
Clear all
No duration filter
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.

Created after
September 20262023JanuaryFebruaryMarchAprilMayJuneJulyAugustSeptemberOctoberNovemberDecember2024JanuaryFebruaryMarchAprilMayJuneJulyAugustSeptemberOctoberNovemberDecember2025JanuaryFebruaryMarchAprilMayJuneJulyAugustSeptemberOctoberNovemberDecember2026JanuaryFebruaryMarchAprilMayJuneJulyAugustSeptemberOctoberNovemberDecember2027JanuaryFebruaryMarchAprilMayJuneJulyAugustSeptemberOctoberNovemberDecember2028JanuaryFebruaryMarchAprilMayJuneJulyAugustSeptemberOctoberNovemberDecember2029JanuaryFebruaryMarchAprilMayJuneJulyAugustSeptemberOctoberNovemberDecember
1234567891011121314151617Today18192021222324252627282930
Clear all
No date filter
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.

RecencyLast 14 days
TodayYesterdayLast 14 daysLast 30 daysCustom range
Clear
Clear all
Filtering 2026-09-04 → 2026-09-17
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 rows

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.

RecencyLast 14 days
TodayYesterdayLast 14 daysLast 30 daysCustom day
Clear
Clear all
Filtering 2026-09-04 → 2026-09-17
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.

FieldTypeDescription
keystringThe key for this facet’s value in the record. attrs.facet returns it. It must be unique in facets.
labelstringThe facet’s row label in the menu.
iconIconShapeLeading 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.

FieldTypeDescription
kind'single'Discriminant.
optionsSelectItem[]The choices — bare strings or SelectOptions (groups / submenus flatten to their leaves). Option values may be string / number / boolean.
filterFacetFilterSearch 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.

FieldTypeDescription
kind'multiple'Discriminant.
optionsSelectItem[]As single.
filterFacetFilterAs single.
selectAllbooleanA “Select all” row toggling every visible option. Default true.
selectAllLabelstringLabel for that row. Default Select all.

text (SelectFacetText) accepts a free-form string. It applies on Enter or blur and is absent when empty.

FieldTypeDescription
kind'text'Discriminant.
placeholderstringPlaceholder 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.

FieldTypeDescription
kind'custom'Discriminant.
render(ctx) => ReactNodeRenders 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) => ReactNodeThe chip on the facet’s row while a value is set.