AntaSelectFaceted
Switch to dark theme
Search documentation
On this page

Faceted select Documentation has not yet been reviewed by a human.

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.

Global search matches options across all single and multiple facets, including facets with filter: false. A facet’s filter controls its own search field. Each field keeps its query independently. Built-in matching searches value, label, and hint without case sensitivity; whitespace in a query matches a run of whitespace. A custom filter receives the original query, including leading and trailing spaces, in both search locations. It also runs for an empty per-facet query. A whitespace-only global query keeps the facet list visible instead of opening global results. Select’s custom filter receives a trimmed query; account for this difference when sharing predicates.

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 Recency in the first demo, select Custom range, then pick From and To from the fields side by side. Each field closes its own calendar after a pick while the Recency editor stays open for the second date. Both fields have a clear button. Open Min duration to choose a comparison, then enter seconds. The comparison alone does not apply the facet or add a result chip.

Open Metadata 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 containscrashClearMin duration
>More than≥At least=Exactly≤At most<Less thanseconds
Recency
TodayYesterdayLast 14 daysLast 30 daysCustom range
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 recencySummary = (v) =>
'preset' in v
? { today: 'Today', yesterday: 'Yesterday', last14: 'Last 14 days', last30: 'Last 30 days' }[v.preset]
: v.from && v.to ? `${v.from} → ${v.to}` : 'Custom range'
const sign = (comparison) => ({ gt: '>', gte: '≥', eq: '=', lte: '≤', lt: '<' })[comparison] ?? '≥'
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) => `${sign(v.comparison)} ${v.min}s`, render: /* DurationEditor with draft comparison and Input value */ },
{ key: 'recency', label: 'Recency', kind: 'custom', summary: recencySummary, render: /* radio presets and two InputDate fields */ },
]
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', maxWidth: '100%' }}>
{/* Show an editable control or summary for each active facet. */}
{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] ?? (facet.kind === 'single' ? null : [])}
onValueChange={(v) => setFacet(facet.key, v)}
leading={`${facet.label}:`}
aria-label={facet.label}
filter={facet.filter}
clearable
className="sf-chip"
/>
)
if (facet.key === 'recency') {
const recency = value.recency
return <Tag key={facet.key} label="Recency" value={recencySummary(recency)} />
}
// Free-form text / duration → 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
className="sf-chip"
onFocus={() => setFocusedKey(facet.key)}
onBlur={() => setFocusedKey(null)}
onInput={(e) => write(e.currentTarget.value)}
onClearInput={() => { setFocusedKey(null); setFacet(facet.key, undefined) }}
/>
)
})}
{/* 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 demo gives editable chips a consistent width, capped by their container. Input truncates long values with an ellipsis when the field is unfocused.

.sf-chip { width: 240px; max-width: 100%; }

The trigger opens the facet menu. The example shows active values beside it. Option facets use an editable Select with the facet label in its leading prefix and aria-label as its accessible name. Recency uses a summary tag and is edited in the facet menu.

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
October 20262023JanuaryFebruaryMarchAprilMayJuneJulyAugustSeptemberOctoberNovemberDecember2024JanuaryFebruaryMarchAprilMayJuneJulyAugustSeptemberOctoberNovemberDecember2025JanuaryFebruaryMarchAprilMayJuneJulyAugustSeptemberOctoberNovemberDecember2026JanuaryFebruaryMarchAprilMayJuneJulyAugustSeptemberOctoberNovemberDecember2027JanuaryFebruaryMarchAprilMayJuneJulyAugustSeptemberOctoberNovemberDecember2028JanuaryFebruaryMarchAprilMayJuneJulyAugustSeptemberOctoberNovemberDecember2029JanuaryFebruaryMarchAprilMayJuneJulyAugustSeptemberOctoberNovemberDecember
1234567891011121314151617181920Today2122232425262728293031
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 uses MenuItem rows with radio indicators for the presets. Selecting Custom range reveals two InputDate fields side by side. The facet 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-10-07 → 2026-10-20
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={{ minWidth: 360 }}>
{PRESETS.map((preset) => (
<MenuItem key={preset.value} label={preset.label}
selectionIndicator="radio" selected={mode === preset.value} data-menu-open
onSelect={() => onChange(preset.value === 'custom' ? range : { preset: preset.value })} />
))}
{mode === 'custom' && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 8, padding: 8 }}>
<InputDate size="small" label="From" clearable value={range.from}
onValueChange={(from) => onChange({ from, to: range.to })} />
<InputDate size="small" label="To" clearable value={range.to} min={range.from || undefined}
onValueChange={(to) => onChange({ from: range.from, to })} />
</div>
)}
</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-10-07 → 2026-10-20
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 <Dialog>, 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.

Styling

size and priority style the default trigger. tone and toneScope style option rows. className, style, and extra attributes apply to the default Button; a custom renderTrigger owns its own styling and attributes.

SelectFaceted uses external styles for its fixed layout, including the text editor’s padding. Consumer style props, option styles, render-hook styles, and custom color or radius props can still produce inline style attributes. Server-rendered inline styles are blocked by style-src-attr 'none'.

The text editor uses a CSS-only a-select-faceted-text region in its Menu. If you targeted the former div[data-menu-open] wrapper, replace that part of your selector with a-select-faceted-text. Its padding now comes from CSS, so selectors that require an inline style attribute no longer apply. Use your trigger’s class and the adjacent Menu to scope its padding. The class in this example belongs to the demo; replace it with your own selector. Open Title contains to see the larger inset.

Title containsClear all
<SelectFaceted
className="roomy-text-filter"
facets={[{ key: 'title', label: 'Title contains', kind: 'text' }]}
/>
.roomy-text-filter + a-menu a-select-faceted-text { padding: 8px; }