Analytics

We measure which pages get used so we can improve the documentation. No cookies, no cross-site tracking. See our privacy notice.

Marigold
v18.0.0
Marigold
v18.0.0

Layout

App Framealpha
Admin- and Mastermarkbeta

User Input

Bulk Actionsbeta
Filterbeta
Formsbeta
Multiple Selection
Pickalpha
Table Recordsalpha

Feedback

Async Data Loading
Destructive Actionsalpha
Error Boundariesbeta
Feedback Messages
Loading States

Data

Fetching and Mutationsalpha
Patterns

Filter

Understand methods to refine data.

Filtering helps users focus on what matters by narrowing a data set to results that meet specific criteria. It is most valuable when the data set is too large or complex to scan in its entirety, and especially when several parameters or categories together can significantly narrow the results. Well-applied filters reduce information overload and let users find relevant items and complete tasks more efficiently.

Filtering or picking?

Filtering narrows a data set in place, and the narrowed view is the result the user reads. When the goal is instead to collect individual records and carry them into another task, such as adding venues to a report or granting a set of people access, reach for the Pick pattern. Both open with a search field, but a filter leaves the collection where it is, while a pick takes a chosen set with it.

Structure

The pattern keeps a consistent layout while the controls inside it adapt to the data set. It arranges five areas: a filter bar above the data set, an applied-filter summary, the data set itself, pagination below it, and a filter panel that slides in from the right.

Filter BarApplied FilterData SetPaginationFilter Panel
  • Filter Bar: A single horizontal row above the data set with the search field and the button that opens the filter panel. Built as a layout composition with Inline.
  • Applied Filter: Lists the filters currently in use and offers a single action to clear them all.
  • Data Set: The collection being filtered, shown as a table, tiles, or cards. It takes up most of the layout.
  • Pagination: Splits the filtered results into pages, below the data set.
  • Filter Panel: A Drawer that slides in from the right and holds the filter controls. It overlaps the content without blocking it, so the data set stays visible while filtering. "Panel" below always means this Drawer.

The demo shows the areas working together in miniature: the search sits in the filter bar, the panel collects filter changes behind the Filter button and commits them with Apply, and applied filters appear as removable tags.

import type { Key } from 'react';import { useState } from 'react';import {  Button,  Checkbox,  Drawer,  Inline,  Radio,  SearchField,  Stack,  Tag,} from '@marigold/components';import { ListFilter } from '@marigold/icons';interface Filter {  categories: string[];  // An empty string means "no status filter" (a default treated as no filter).  status: string;}const noFilter: Filter = { categories: [], status: '' };const categoryLabels: Record<string, string> = {  concerts: 'Concerts',  festivals: 'Festivals',  theater: 'Theater',  workshops: 'Workshops',};const statusLabels: Record<string, string> = {  published: 'Published',  draft: 'Draft',  archived: 'Archived',};export default () => {  const [open, setOpen] = useState(false);  // The panel edits a draft and only commits it to `applied` on Apply.  const [applied, setApplied] = useState<Filter>(noFilter);  const [draft, setDraft] = useState<Filter>(noFilter);  const onOpenChange = (isOpen: boolean) => {    // The panel always opens with the applied state.    if (isOpen) {      setDraft(applied);    }    setOpen(isOpen);  };  const apply = () => {    setApplied(draft); // [!code highlight]    setOpen(false);  };  const tags = [    ...(applied.categories.length > 0      ? [          {            id: 'categories',            label: `Category is ${applied.categories              .map(category => categoryLabels[category])              .join(' or ')}`,          },        ]      : []),    ...(applied.status      ? [{ id: 'status', label: `Status is ${statusLabels[applied.status]}` }]      : []),  ];  const removeTags = (keys: Set<Key>) => {    setApplied(prev => ({      categories: keys.has('categories') ? [] : prev.categories,      status: keys.has('status') ? '' : prev.status,    }));  };  return (    <Stack space={4}>      {/* Filter bar: a plain layout composition, not a toolbar widget */}      <Inline space="related" alignY="input">        <SearchField          aria-label="Search events"          placeholder="Search events"          width={56}        />        <Drawer.Trigger open={open} onOpenChange={onOpenChange}>          <Button>            <ListFilter /> Filter          </Button>          <Drawer closeButton>            <Drawer.Title>Filter events</Drawer.Title>            <Drawer.Content>              <Stack space={6}>                <Checkbox.Group                  label="Category"                  value={draft.categories}                  onChange={categories =>                    setDraft(prev => ({ ...prev, categories }))                  }                >                  <Checkbox value="concerts" label="Concerts" />                  <Checkbox value="festivals" label="Festivals" />                  <Checkbox value="theater" label="Theater" />                  <Checkbox value="workshops" label="Workshops" />                </Checkbox.Group>                <Radio.Group                  label="Status"                  value={draft.status}                  onChange={status => setDraft(prev => ({ ...prev, status }))}                >                  <Radio value="published">Published</Radio>                  <Radio value="draft">Draft</Radio>                  <Radio value="archived">Archived</Radio>                </Radio.Group>              </Stack>            </Drawer.Content>            <Drawer.Actions>              <Button slot="close">Cancel</Button>              <Button variant="primary" onPress={apply}>                Apply              </Button>            </Drawer.Actions>          </Drawer>        </Drawer.Trigger>      </Inline>      {/* Rendered only while filters are active */}      {tags.length > 0 && (        <Tag.Group label="Applied Filters" onRemove={removeTags} removeAll>          {tags.map(tag => (            <Tag key={tag.id} id={tag.id}>              {tag.label}            </Tag>          ))}        </Tag.Group>      )}    </Stack>  );};

Choosing Filters

Offer filters that map directly to real properties or categories in the data set, so it is clear how each selection changes the results. Give every filter an unambiguous purpose and a clear, descriptive label.

Limit the set to filters that provide genuine value. Every filter costs scanning time and adds a way to reach an empty result, so a dimension that few users ever touch earns its place in the data model, not in the panel.

Do

Order filters by how often they are used, so the common ones are easy to find.

Don't

Do not order strictly alphabetically if it buries the most frequently selected options.

Filter Types

Once you know which filters to offer, pick the control for each one. The right control depends on the shape of the data behind the filter, the space available, and how users are expected to interact with it. The common shapes and the component that fits each are:

TypeDescriptionComponent
SingleOne option from a set, such as a category or status. Use a radio group when a few options are all worth showing, or a Select when space is tight or the list is long. A SegmentedControl (beta) is a scope switch rather than a filter, see Quick Filters.Radio
MultipleSeveral options at once, such as multiple categories. Checkboxes signal that more than one choice is possible. For a large set with search and removable tags, use TagField (beta).Checkbox
RangeA value or span within known, static bounds, ideal for quantities, prices, or measurements. A slider adjusts one or two handles without manual input.Slider
Min/MaxExact lower and upper numeric limits, for when precision matters more than a draggable range.NumberField
ContainsA keyword or partial match typed by the user. Use SearchField in the filter bar, or a TextField for a field inside the panel.SearchField
DateA point in time or a span. Use a date picker for a single date, and prefer relative presets such as "Last 7 days" for ranges (see Date Range Presets). A custom range uses RangeCalendar (alpha).DatePicker
BooleanA single on/off condition, such as "In stock only". A switch reads as an instant on/off and suits a quick filter in the filter bar. Inside the panel, a single checkbox does the same job.Switch

Selecting several values in a facet, such as multiple categories or statuses, is still filtering even though it can feel like ticking records off one by one. Those values are criteria that narrow the visible set, not records the user carries away. When the checkboxes stand for individual records that leave with the user, that is the Pick pattern instead.

For these common shapes, prefer the dedicated form controls above: radio buttons and checkboxes communicate the selection rule visually before any interaction and match what users know from other applications. When the option set is too large to scan, reach for a searchable control instead. ComboBox and Autocomplete let users narrow a single choice by typing, and the list-based SelectList covers single or multiple selection for hundreds of options that benefit from virtualization.

Applying

How a filter commits its changes is the first decision to get right, because the rest of the pattern builds on it. This pattern fixes the choice to where the control lives:

  • Filter bar controls apply instantly. The search field, along with any filter promoted into the filter bar as a quick filter, takes effect the moment the user changes it. Instant means no separate Apply step, not per keystroke: a search field still commits on Enter, so half-typed terms never churn the results. The cost is a query on every interaction and results that reflow on each change.
  • The filter panel (the slide-in drawer) batches. It collects changes across several parameters and applies them only when the user submits with an explicit Apply action. The cost is one extra step, and the user can assemble a zero-result combination before seeing it.

Apply instantly for a single, cheap decision that benefits from immediate feedback. Batch when several criteria are combined, each query is expensive, or the control is multi-select, where live updates are disorienting.

Because the model is tied to location, the two stay legible side by side. A bare filter bar control signals that it takes effect now, while the panel and its Apply button signal that the user composes first and commits once. Keep that boundary clean. A batched control sitting loose in the filter bar, or an instant control buried inside the panel, leaves users unable to tell whether their change has taken effect.

For the panel's commit control, label it clearly and tell users what they are committing to:

  • Label by behavior. Use Apply: it matches the panel's batched model. If your panel deviates and filters live, label the closing action Done instead, so the button never promises a commit that already happened.
  • Show the result count on the button where the count is available, for example "Show 47 results", so users know the outcome before they commit. This is especially valuable on mobile, where it is the clearest way to preview impact.
  • Announce the change to assistive technology. Instant or batched, a filter change updates the data set silently for screen reader users. Render the new result count in a live region so the outcome is spoken. See Accessibility for details.

Designing Options

This section is about one filter's own choices — the cities in a City filter, not the filters in the panel. These guidelines cover how to keep a long or complex option list usable, surface counts, and handle bulk or excluded values.

Many Options

A filter with many options needs help finding the right one, and the right help depends on the shape of the list.

A few common options plus a long tail. When most users only ever need a handful of well-known options, show those first and collapse the rest behind a "show more" control. The panel stays compact and the full list is one click away. This fits a short, browsable list where users recognise the option rather than recall it.

Genre

import { Checkbox } from '@marigold/components';export default () => (  <Checkbox.Group    label="Genre"    collapseAt={5} // [!code highlight]    defaultValue={['rock', 'hiphop']}  >    <Checkbox value="pop" label="Pop" />    <Checkbox value="rock" label="Rock" />    <Checkbox value="hiphop" label="Hip-Hop" />    <Checkbox value="electronic" label="Electronic" />    <Checkbox value="classical" label="Classical" />    <Checkbox value="jazz" label="Jazz" />    <Checkbox value="country" label="Country" />    <Checkbox value="rnb" label="R&B" />    <Checkbox value="metal" label="Metal" />    <Checkbox value="reggae" label="Reggae" />    <Checkbox value="blues" label="Blues" />    <Checkbox value="folk" label="Folk" />  </Checkbox.Group>);

A long list of recognisable values. When the options run to dozens or hundreds of roughly equally likely values, such as cities, venues, or organisers, users already know the name they want, so scanning a list or expanding a "show more" control only slows them down. Let them search instead. TagField is built for exactly this: it pairs a search input with a virtualized option list and shows selections as removable tags, keeping a long list usable in a compact space.

City
Berlin
Vienna
import { useState } from 'react';import { TagField } from '@marigold/components';// A long list of recognizable, equally likely values: users know the name// they want, so searching beats scanning or a "show more" control.const cities = [  'Amsterdam',  'Athens',  'Barcelona',  'Berlin',  'Bern',  'Bordeaux',  'Bremen',  'Brussels',  'Budapest',  'Cologne',  'Copenhagen',  'Dortmund',  'Dresden',  'Dublin',  'Düsseldorf',  'Edinburgh',  'Frankfurt',  'Geneva',  'Gothenburg',  'Graz',  'Hamburg',  'Hannover',  'Helsinki',  'Innsbruck',  'Leipzig',  'Lisbon',  'London',  'Lyon',  'Madrid',  'Mannheim',  'Marseille',  'Milan',  'Munich',  'Nuremberg',  'Oslo',  'Paris',  'Porto',  'Prague',  'Rome',  'Rotterdam',  'Salzburg',  'Stockholm',  'Stuttgart',  'Vienna',  'Warsaw',  'Zurich',].map(name => ({ id: name.toLowerCase(), label: name }));export default () => {  const [selected, setSelected] = useState<string[]>(['berlin', 'vienna']);  return (    <TagField      label="City"      placeholder="Search cities..."      width={80}      items={cities}      value={selected}      onChange={keys => setSelected([...keys].map(String))}    >      {(city: (typeof cities)[number]) => (        <TagField.Option id={city.id}>{city.label}</TagField.Option>      )}    </TagField>  );};

Option Counts

Displaying counts next to filter options gives users a sense of scale and helps them choose. A category labeled "Concerts (85)" or "Workshops (12)" communicates availability before the filter is even applied. Counts matter most in large data sets, so update them dynamically as filters change and keep them visible to avoid choosing options that return little or no data.

They help single select filters such as radio buttons too: even with one option active at a time, the numbers preview impact before a choice and show what scope remains afterwards.

Keep the count visually quiet so it informs without competing with the option. Use a muted Text at the same size as the label rather than a heavier Badge, and place it inside the label whatever the control, so spacing stays consistent and assistive technology announces the count together with the option.

Category
import { Checkbox, Inline, Text } from '@marigold/components';const categories = [  { value: 'concert', label: 'Concerts', count: 85 },  { value: 'festival', label: 'Festivals', count: 31 },  { value: 'theater', label: 'Theater', count: 24 },  { value: 'workshop', label: 'Workshops', count: 12 },  { value: 'reading', label: 'Readings', count: 3 },];export default () => (  <Checkbox.Group label="Category" defaultValue={['concert']}>    {categories.map(({ value, label, count }) => (      <Checkbox        key={value}        value={value}        label={          <Inline space={2} alignY="center">            {label}            <Text variant="muted">({count})</Text>          </Inline>        }      />    ))}  </Checkbox.Group>);

Dynamic counts also need the right query per option. For a filter that matches any of its selected options, count each option with every other filter applied but the option's own group ignored. Otherwise the first selection in a group would drop all of its siblings to zero. For a filter that must match all selected options, such as a list of required amenities, count with the current selection plus the candidate option, so the number previews exactly what selecting it returns.

Pairing counts in the panel with a clear applied filter summary gives users confidence that their selections match their intent.

Select All

This "Select all" checks every option in one filter — every city in a City filter — which widens the result set. It is not the row-level "select all" that starts a bulk operation; for that, see Bulk Actions. The words are the same, the object is not: here it changes what you see, there it chooses what you act on.

In multi-select filters, leaving everything unchecked typically means all results are shown, so a "Select all" option is not always required. It earns its place in long lists where users often want nearly everything, or when a parent-child structure enables bulk toggling, making the common "all except a few" pattern easier to achieve.

When included, "Select all" should sit above the filter options, paired with a quick way to reset. Communicate partial selections with an indeterminate parent state, which assistive technology announces as "mixed", following the ARIA mixed-state checkbox pattern. Show how many items the action affects, with accessible labels that clarify scope, such as "Select all 125 items."

import { useState } from 'react';import { Checkbox, Inline, Stack, Text } from '@marigold/components';const channels = [  'Box office',  'Call center',  'Mobile app',  'Partner network',  'Web shop',];export default () => {  const [selected, setSelected] = useState(['Web shop']);  const allSelected = selected.length === channels.length;  return (    <Stack space={3}>      <Checkbox        aria-label={`Select all ${channels.length} sales channels`}        // Tie the standalone control to the group it toggles, so the        // relationship is programmatic and not left to visual proximity.        aria-controls="sales-channels" // [!code highlight]        label={          <Inline space={2} alignY="center">            Select all <Text variant="muted">({channels.length})</Text>          </Inline>        }        checked={allSelected}        indeterminate={selected.length > 0 && !allSelected}        onChange={checked => setSelected(checked ? [...channels] : [])}      />      <Checkbox.Group        id="sales-channels" // [!code highlight]        aria-label="Sales channels"        value={selected}        onChange={setSelected}      >        {channels.map(channel => (          <Checkbox key={channel} value={channel} label={channel} />        ))}      </Checkbox.Group>    </Stack>  );};

Exclusions

Exclude filters let users say "show everything except these" instead of naming every value to keep. They help when the set to remove is small and the set to keep is large, but they add a second way to express intent, so reach for them only when inclusion alone is awkward. There are two ways to offer exclusion.

Deselect from a full selection. The lightest approach needs no extra control: start with every option selected and let users uncheck the few they want gone. The resulting "All except" state is easy to grasp, keeps the panel free of operators, and reads clearly in both the panel and the summary. Use it for a single checkbox group where pre-checking everything is reasonable.

Venue Type
Applied Filters
All types except Bar
import { useState } from 'react';import { Checkbox, Stack, Tag, Text } from '@marigold/components';const types = ['Bar', 'Café', 'Club', 'Concert hall', 'Lounge', 'Theater'];export default () => {  const [selected, setSelected] = useState(    types.filter(type => type !== 'Bar')  );  const excluded = types.filter(type => !selected.includes(type));  return (    <Stack space={6}>      <Checkbox.Group        label="Venue Type"        value={selected}        onChange={setSelected}      >        {types.map(type => (          <Checkbox key={type} value={type} label={type} />        ))}      </Checkbox.Group>      <Tag.Group        label="Applied Filters"        onRemove={() => setSelected([...types])}        emptyState={() => (          <Text variant="muted" fontSize="sm" fontStyle="italic">            None          </Text>        )}      >        {excluded.length > 0 ? (          <Tag id="type">            {selected.length === 0              ? 'No venue types'              : `All types except ${excluded.join(', ')}`}          </Tag>        ) : null}      </Tag.Group>    </Stack>  );};

Switch the operator from include to exclude. When a filter needs to flip polarity, give it an explicit operator such as "Is any of" / "Is none of". The selected values stay put and only their meaning changes, so users move from include to exclude without re-picking. This scales better than deselection when the list to keep would be long but the list to remove is short. Prefer it inside an advanced or per-field filter builder, and offer only one mechanism per filter.

Venue Type
Applied Filters
Type is Bar, Café
import { useState } from 'react';import { Checkbox, Select, Stack, Tag, Text } from '@marigold/components';const types = ['Bar', 'Café', 'Club', 'Concert hall', 'Lounge', 'Theater'];export default () => {  // The operator is the single source of truth for polarity: the same selection  // is read as an include ("is any of") or an exclude ("is none of"). Switching  // the operator keeps the checked values intact and only flips their meaning,  // so users can move a filter from include to exclude without re-picking.  const [operator, setOperator] = useState<'include' | 'exclude'>('include');  const [selected, setSelected] = useState<string[]>(['Bar', 'Café']);  const summary =    selected.length === 0      ? null      : operator === 'include'        ? `Type is ${selected.join(', ')}`        : `Type is not ${selected.join(', ')}`;  return (    <Stack space={6}>      <Stack space={3}>        <Select          aria-label="Venue type operator"          width={40}          defaultSelectedKey="include"          onChange={key => setOperator(key as 'include' | 'exclude')}        >          <Select.Option id="include">Is any of</Select.Option>          <Select.Option id="exclude">Is none of</Select.Option>        </Select>        <Checkbox.Group          label="Venue Type"          value={selected}          onChange={setSelected}        >          {types.map(type => (            <Checkbox key={type} value={type} label={type} />          ))}        </Checkbox.Group>      </Stack>      <Tag.Group        label="Applied Filters"        onRemove={() => setSelected([])}        emptyState={() => (          <Text variant="muted" fontSize="sm" fontStyle="italic">            None          </Text>        )}      >        {summary ? <Tag id="type">{summary}</Tag> : null}      </Tag.Group>    </Stack>  );};

Whichever you choose, make the exclusion unmistakable wherever the filter appears. Phrase the applied filter as "Type is not Bar" or "All types except Bar", never a bare value, so the polarity is clear in the panel, the tag, and the accessible name. Because switching polarity can change the result set dramatically, apply it immediately and keep the operator visible.

Scaling Up

The techniques above help within a single filter, but the set of filters can grow large too. Two moves keep it manageable as it grows: pull the few most-used filters out into the filter bar so the common case never requires opening the panel, then organize the rest so the panel stays easy to scan.

Quick Filters

When usage is concentrated on a few filters, hiding them in the panel adds an unnecessary step to the most common task. Establish a two-tier hierarchy: surface the few high-value filters as quick filters directly in the filter bar, and keep the complete set behind an "All filters" button that opens the panel. The panel stays canonical: a quick filter is a shortcut to a filter that still lives in the panel and shares its state, so anyone who opens "All filters" first still finds it.

Promote a filter to a quick filter only when both hold:

  • It is among the 2 or 3 most-used filters, based on real usage data.
  • A single selection in it meaningfully narrows the data set on its own.

If you cannot identify the most-used few, leave everything in the panel and rely on search rather than promoting a filter you cannot justify by usage.

For the controls themselves, use something compact and field shaped, such as a Select, so the bar reads as one family of inputs. Quick filters apply instantly, so see Applying Filters for how that model works alongside the panel's batched commit.

import {  Button,  Checkbox,  Drawer,  Inline,  NumberField,  SearchField,  Select,  Slider,  Stack,} from '@marigold/components';import { ListFilter } from '@marigold/icons';// The panel is canonical: a quick filter is a shortcut to a filter that still// lives here, so "All filters" opens the complete set. Kept compact for the// demo; see /examples/filter for the full grouped panel.const AllFiltersPanel = () => (  <Drawer.Trigger>    <Button>      <ListFilter /> All filters    </Button>    <Drawer closeButton>      <Drawer.Title>All filters</Drawer.Title>      <Drawer.Content>        <Stack space="group">          <Checkbox.Group label="Status">            <Checkbox value="published" label="Published" />            <Checkbox value="draft" label="Draft" />            <Checkbox value="archived" label="Archived" />          </Checkbox.Group>          <Checkbox.Group label="Category">            <Checkbox value="concert" label="Concerts" />            <Checkbox value="festival" label="Festivals" />            <Checkbox value="theater" label="Theater" />          </Checkbox.Group>          <NumberField            label="Min. capacity"            defaultValue={0}            minValue={0}            step={100}          />          <Slider            label="Max. price"            defaultValue={5000}            step={100}            maxValue={5000}            formatOptions={{              style: 'currency',              currency: 'EUR',              minimumFractionDigits: 0,            }}          />        </Stack>      </Drawer.Content>      <Drawer.Actions>        <Button slot="close">Close</Button>        <Button variant="primary" slot="close">          Apply        </Button>      </Drawer.Actions>    </Drawer>  </Drawer.Trigger>);export default () => (  <Inline space="related" alignY="input">    <SearchField      aria-label="Search events"      placeholder="Search events"      width={56}    />    <Select aria-label="Status" placeholder="Status" width={36}>      <Select.Option id="published">Published</Select.Option>      <Select.Option id="draft">Draft</Select.Option>      <Select.Option id="archived">Archived</Select.Option>    </Select>    <Select aria-label="Category" placeholder="Category" width={36}>      <Select.Option id="concert">Concerts</Select.Option>      <Select.Option id="festival">Festivals</Select.Option>      <Select.Option id="theater">Theater</Select.Option>    </Select>    <AllFiltersPanel />  </Inline>);

Bar Layout

Quick filters make the filter bar a real layout concern. Keep it to a single horizontal row: a scope switch first if there is one, then the search field, the quick filters, and the "All filters" button that opens the panel. Indicators or view actions sit at the far end, right-aligned. Compose the row with Inline and alignY="input" so inputs and buttons line up.

A SegmentedControl is not a quick filter but a scope switch: it answers which set the user is looking at, such as All, Active, and Archived events, while the filters narrow within that set. Because it re-bases the whole view rather than adding one more condition, it sits at the start of the bar, apart from the field cluster, and its label names the view rather than a filter dimension.

A single row only works when every control in it stays compact:

  • Give every field an explicit width. Form fields stretch to the full container width by default, and a single full-width field breaks the row. Size fields in the bar to their content instead, for example width={36} on a Select or width="fit" on a SegmentedControl.
  • No visible labels or helper text. A label above a control or a description below it adds a second line and breaks the row's alignment. The search field carries a placeholder, a quick filter labels itself through its placeholder or selected value, and every control still needs an aria-label.
  • Mind the width of always-visible options. A SegmentedControl scope switch pays for keeping its options visible with horizontal space, and its labels must never wrap or truncate. Keep it to a few short segments. When the row gets tight, a Select offers the same choice at a fraction of the width.

Let the row keep itself on one line with an OverflowRegion. Put the quick filters inside it and pin the scope switch, search field, and "All filters" button around it. As the bar narrows, the region hides the quick filters from the end and brings them back as space returns, so the bar never wraps. Because the panel behind "All filters" always holds every filter, a hidden quick filter is still one click away. Drag the handle in the demo below to see the bar hold a single row at any width.

720px
import {  Button,  Checkbox,  Drawer,  Inline,  NumberField,  OverflowRegion,  SearchField,  SegmentedControl,  Select,  Stack,} from '@marigold/components';import { ListFilter } from '@marigold/icons';import { DemoResizer } from '@/ui/DemoResizer';// The panel is canonical: it always renders the complete filter set, so a// quick filter that the bar hides is still available in here. Kept compact// for the demo, see /examples/filter for the full grouped panel.const AllFiltersPanel = () => (  <Drawer.Trigger>    <Button>      <ListFilter /> All filters    </Button>    <Drawer closeButton size="xsmall">      <Drawer.Title>All filters</Drawer.Title>      <Drawer.Content>        <Stack space="group">          <Checkbox.Group label="Category">            <Checkbox value="concerts" label="Concerts" />            <Checkbox value="festivals" label="Festivals" />            <Checkbox value="theater" label="Theater" />          </Checkbox.Group>          <Checkbox.Group label="Status">            <Checkbox value="published" label="Published" />            <Checkbox value="draft" label="Draft" />          </Checkbox.Group>          <NumberField label="Max. price" minValue={0} step={10} />        </Stack>      </Drawer.Content>    </Drawer>  </Drawer.Trigger>);// Drag the handle to narrow the bar. The scope switch and search field stay// put while the quick filters drop into the panel one by one, so the bar// keeps to a single row at every width.export default () => (  <DemoResizer defaultWidth={720} minWidth={380}>    <Inline space="related" alignY="center" noWrap>      {/* The scope switch leads the row and never hides: it re-bases which          events the whole bar filters, so it is not one of the quick filters          that can move into the panel. */}      <SegmentedControl aria-label="Event scope" width="fit" defaultValue="all">        <SegmentedControl.Option value="all">All</SegmentedControl.Option>        <SegmentedControl.Option value="active">Active</SegmentedControl.Option>        <SegmentedControl.Option value="archived">          Archived        </SegmentedControl.Option>      </SegmentedControl>      <SearchField        aria-label="Search events"        placeholder="Search events"        width={40}      />      {/* Quick filters, in priority order. The last one hides first. */}      <OverflowRegion>        <Select aria-label="Category" placeholder="Category" width={36}>          <Select.Option id="concerts">Concerts</Select.Option>          <Select.Option id="festivals">Festivals</Select.Option>          <Select.Option id="theater">Theater</Select.Option>        </Select>        <Select aria-label="Status" placeholder="Status" width={36}>          <Select.Option id="published">Published</Select.Option>          <Select.Option id="draft">Draft</Select.Option>        </Select>        <Select aria-label="Price" placeholder="Price" width={36}>          <Select.Option id="lt50">Under 50 €</Select.Option>          <Select.Option id="gte50">50 € and more</Select.Option>        </Select>      </OverflowRegion>      <AllFiltersPanel />    </Inline>  </DemoResizer>);

Never let the bar wrap to a second row. A new row pushes the data set down and shifts the layout every time the bar changes. Hiding the overflow keeps the row steady, and the panel is the recovery surface for anything that no longer fits. The same approach carries down to small screens, where the region can hide every quick filter and leave only the search field and the "All filters" button.

Do

Keep the filter bar to one row of compact, equally tall controls with explicit widths.

Don't

Do not let the bar wrap to a second row or add helper text below a control. Move a quick filter back to the panel when space runs out.

Labeling

How a quick filter shows its state depends on whether it allows one value or several.

For a single select quick filter, showing the chosen value in the trigger is fine, since only one value is ever possible. The applied filter tag for it is then optional, as the control already conveys the selection. The promoted Select above works this way.

For a multi select quick filter, do not list every value in the trigger. Show the dimension label with a count instead, such as "Status" followed by a count badge, and let the applied filter tags carry the actual values. This keeps the trigger compact and avoids showing the same values in two places.

import { Badge, Inline, Select, VisuallyHidden } from '@marigold/components';export default () => (  <Select    aria-label="Status"    selectionMode="multiple"    placeholder="Status"    width={36}    // Show the dimension label and a count, never the individual values, so the    // trigger stays compact and the chosen values live in the applied-filter    // tags. `count` reflects the real selection even with static options.    renderValue={(_items, { count }) => (      <>        {/* Visible label and count badge, hidden from assistive tech, which */}        {/* instead hears the dimension (aria-label) plus the summary below. */}        <Inline space={2} alignY="center" aria-hidden="true">          <span>Status</span>          <Badge>{count}</Badge> {/* [!code highlight] */}        </Inline>        <VisuallyHidden>{`${count} selected`}</VisuallyHidden>      </>    )}  >    <Select.Option id="active">Active</Select.Option>    <Select.Option id="draft">Draft</Select.Option>    <Select.Option id="archived">Archived</Select.Option>    <Select.Option id="scheduled">Scheduled</Select.Option>  </Select>);

Date Presets

Date is one of the most common things to filter by, and relative ranges such as "Last 7 days", "Last 30 days", or "This month" cover the majority of cases. Offer these as a quick filter so the frequent ranges are one click away, without making users open a calendar and count days.

Reach for the DateRangePicker quick select presets rather than building your own list. Pass the built-in keys, such as last-7-days or this-month, and each renders with a localized label and correct date math. For a range a preset cannot express, the same popover keeps a calendar beside the list, so a custom range needs no extra control. Where your ranges are specific to the domain, custom presets take a label and a value or a resolver function.

MM/DD/YYYY
–
MM/DD/YYYY
import { DateRangePicker } from '@marigold/components';export default () => (  // A single field-shaped quick filter: the built-in `presets` list covers the  // frequent ranges in one click, and the calendar in the same popover handles  // any range a preset cannot express, so there is no separate "custom" branch.  <DateRangePicker    aria-label="Date range"    width={36}    presets={['last-7-days', 'last-30-days', 'this-month', 'this-quarter']} // [!code highlight]  />);

Grouping

Even after the most-used filters are promoted, the panel can still hold many filters, and 15 or more force users to scroll past controls irrelevant to their task. Group related filters into collapsible sections with an Accordion. Order the sections most-used first, expand those by default, and collapse the rest, so the panel opens with a compact overview while every filter stays one click away.

Sections are themes: a header such as "Facilities" bundles parking, seating, and amenities into one unit that users can skip or open as a whole. A section around a single filter cannot do that job. Its header only restates the filter's label, and once every filter owns a section, the collapsed panel just re-lists the form as headers, with an extra click in front of each field and none of the overview grouping was meant to buy. When one filter itself grows too large, that is a Many Options problem, solved inside the filter with search or a "show more" control.

Category
€500

Status
Sales channel

Type
Seating
import {  Accordion,  Badge,  Checkbox,  Inline,  Radio,  Slider,  Stack,} from '@marigold/components';// The badge on a header counts the active filters inside the section, so a// collapsed section still reveals that it holds state.const SectionHeader = ({ title, count }: { title: string; count?: number }) => (  <Accordion.Header>    <Inline space={2} alignY="center">      {title}      {count ? <Badge>{count}</Badge> : null} {/* [!code highlight] */}    </Inline>  </Accordion.Header>);// Each section is a theme holding several related filters. The fields keep// their own labels, so a header never has to restate them.export default () => (  <Accordion allowsMultipleExpanded defaultExpandedKeys={['essentials']}>    <Accordion.Item id="essentials">      <SectionHeader title="Essentials" count={1} />      <Accordion.Content>        <Stack space="group">          <Checkbox.Group label="Category" defaultValue={['concert']}>            <Checkbox value="concert" label="Concerts" />            <Checkbox value="festival" label="Festivals" />            <Checkbox value="theater" label="Theater" />          </Checkbox.Group>          <Slider            label="Max. Price"            thumbLabels="price"            maxValue={500}            step={10}            defaultValue={500}            formatOptions={{              style: 'currency',              currency: 'EUR',              minimumFractionDigits: 0,            }}          />        </Stack>      </Accordion.Content>    </Accordion.Item>    <Accordion.Item id="sales">      <SectionHeader title="Sales" count={2} />      <Accordion.Content>        <Stack space="group">          <Radio.Group label="Status" defaultValue="draft">            <Radio value="published">Published</Radio>            <Radio value="draft">Draft</Radio>            <Radio value="archived">Archived</Radio>          </Radio.Group>          <Checkbox.Group label="Sales channel" defaultValue={['web']}>            <Checkbox value="web" label="Web shop" />            <Checkbox value="boxoffice" label="Box office" />            <Checkbox value="partner" label="Partner network" />          </Checkbox.Group>        </Stack>      </Accordion.Content>    </Accordion.Item>    <Accordion.Item id="venue">      <SectionHeader title="Venue" />      <Accordion.Content>        <Stack space="group">          <Checkbox.Group label="Type">            <Checkbox value="indoor" label="Indoor" />            <Checkbox value="outdoor" label="Outdoor" />          </Checkbox.Group>          <Radio.Group label="Seating">            <Radio value="standing">Standing</Radio>            <Radio value="seated">Seated</Radio>            <Radio value="mixed">Mixed</Radio>          </Radio.Group>        </Stack>      </Accordion.Content>    </Accordion.Item>  </Accordion>);

Do

Name sections after themes that hold several related filters, such as "Essentials" or "Facilities".

Don't

Do not wrap each filter in its own section. Collapsed headers that repeat the field labels are the form again, one click slower.

A collapsed section must not hide that it holds active filters. Show the number of active filters on the section header, so a scan of the collapsed panel reveals where the current state lives. The count complements the applied filter tags and the badge on the filter button rather than replacing them.

Collapsing has a cost for expert users, who pay extra clicks when they need most of the groups. If your users touch the majority of groups every session, keep more expanded by default and offer "Expand all" and "Collapse all" so they can reveal or hide everything at once.

When the groups themselves become numerous, add a search field at the top of the panel so users can jump to a filter by name instead of scanning every section. This sits on top of the grouping, not in place of it.

import { useState } from 'react';import {  Accordion,  Checkbox,  SearchField,  Stack,  Text,} from '@marigold/components';// Enough filter groups that finding one by name beats scrolling the panel.const groups = [  {    id: 'category',    label: 'Category',    options: ['Concerts', 'Festivals', 'Theater', 'Workshops'],  },  {    id: 'status',    label: 'Status',    options: ['Published', 'Draft', 'Archived'],  },  {    id: 'channel',    label: 'Sales Channel',    options: ['Web shop', 'Box office', 'Partner network'],  },  {    id: 'venue',    label: 'Venue',    options: ['Arena', 'Club', 'Open Air', 'Theater Hall'],  },  {    id: 'city',    label: 'City',    options: ['Berlin', 'Hamburg', 'Munich', 'Cologne'],  },  {    id: 'organizer',    label: 'Organizer',    options: ['In-house', 'Agency', 'Partner'],  },  {    id: 'language',    label: 'Language',    options: ['German', 'English', 'French'],  },  {    id: 'accessibility',    label: 'Accessibility',    options: ['Wheelchair access', 'Hearing loop', 'Companion ticket'],  },  {    id: 'audience',    label: 'Audience',    options: ['All ages', 'Adults only', 'Family'],  },];export default () => {  const [query, setQuery] = useState('');  const q = query.trim().toLowerCase();  const visible = groups.filter(    group =>      q === '' ||      group.label.toLowerCase().includes(q) ||      group.options.some(option => option.toLowerCase().includes(q))  );  return (    <Stack space={4}>      <SearchField        aria-label="Find a filter"        placeholder="Find a filter..."        value={query}        onChange={setQuery}      />      {visible.length > 0 ? (        // Re-key by the query so every match expands while searching. The        // search field sits outside the keyed subtree, so it keeps focus.        <Accordion          key={q}          allowsMultipleExpanded          defaultExpandedKeys={            q ? visible.map(group => group.id) : ['category']          }        >          {visible.map(group => (            <Accordion.Item key={group.id} id={group.id}>              <Accordion.Header>{group.label}</Accordion.Header>              <Accordion.Content>                <Checkbox.Group aria-label={group.label}>                  {group.options.map(option => (                    <Checkbox key={option} value={option} label={option} />                  ))}                </Checkbox.Group>              </Accordion.Content>            </Accordion.Item>          ))}        </Accordion>      ) : (        <Text variant="muted" fontSize="sm" fontStyle="italic">          No filters match your search.        </Text>      )}    </Stack>  );};

A slide-in panel also has an upper limit. It works because the data set stays visible while filtering, but once the groups grow far beyond one or two screens of panel height, consider a dedicated filter page or full-screen dialog instead.

Small Screens

On small screens the pattern converges on the filter view users know from mobile commerce: a slim bar over the results and a full-screen panel for everything else. The bar layout guidance covers what the bar sheds on narrow viewports. What changes beyond the bar is the panel's role, and one property it silently loses.

The panel fills the screen, so the data set is no longer visible while filtering. On desktop the current results stay in view for context while the user composes a change, but on a small screen the result count on the commit button is the only feedback before committing, which is why Applying calls it most valuable on mobile. Treat it as required there, not as an enhancement.

With the quick filters gone, opening the panel is the only way to reach any filter, so what the panel shows first carries more weight. Keep the most-used groups expanded, so the frequent filters sit directly under the thumb instead of behind another tap.

The controls themselves need no separate mobile design. Field overlays turn into bottom sheets on small screens, including the preset list of a date range quick filter, so a panel composed of Marigold form components adapts on its own.

Applied filter tags compete with the data set for a much shorter viewport, and grouped tags wrap sooner on a narrow screen. Truncate the applied filter area after one row instead of two, and rely on the badge on the filter button as the always-visible signal that results are filtered.

Filter State

Once filters are applied, the interface needs to reflect that state back to the user: how each active filter reads, how to signal filtering at a glance, how to undo it, and what to show when nothing matches.

Applied Labels

The recommended approach for applied filter labels is to keep them clear and structured. Combine three parts: the identifier (what is filtered, such as category, property, or type), the relative or absolute operator (how it applies, for example is, is above, is between, or a direct match), and the value (the number, threshold, or date).

For absolute values, pair the filter name with its exact value, for example "Status is Approved", to be concise yet precise. For relative values, add the relative term and exact dates or details, such as "Last 7 days (Aug 3 to Aug 9)", to avoid ambiguity. Use clear operators like "is", "is between", "before", "after", and "in the last" for readability. Symbols (>, =, :) can work in compact or technical views but may be less clear for broad audiences, especially with relative timeframes.

Always group all selected values of one filter into a single tag, never one tag per value. "Type is Club or Lounge" reads as one decision and is removed as one, while separate "Club" and "Lounge" tags multiply quickly and obscure which filter they belong to.

Keep the identifier in the tag when a value alone would be ambiguous, for example a "Draft" status and a "Draft" tag. The choice is between an explicit label ("Status: Active") and an implied one ("Active"). Lean explicit as the number of filters grows.

When an applied filter contains a large number of values, avoid grouping them all into a single tag, as this makes scanning and reading difficult and disrupts consistent sizing. Instead, display only the first two or three values, then indicate the number of remaining ones with a clear count, for example "(+7 more)".

Applied Filters
Type is Club or Lounge
Last 7 days (Aug 3 to Aug 9)
Traits are cheap, hype (+5 more)
import { DateFormat, Tag } from '@marigold/components';// Fixed dates and per-date formatting keep server and client output identical.const start = new Date(2026, 7, 3);const end = new Date(2026, 7, 9);export default () => (  <Tag.Group label="Applied Filters" onRemove={() => {}}>    <Tag id="type">Type is Club or Lounge</Tag>    <Tag id="date">      <span>        Last 7 days (<DateFormat value={start} month="short" day="numeric" /> to{' '}        <DateFormat value={end} month="short" day="numeric" />)      </span>    </Tag>    <Tag id="traits">Traits are cheap, hype (+5 more)</Tag>  </Tag.Group>);

Even with one tag per filter, many active filters can stack into several rows of tags and push the data set out of view. Use the collapseAt prop on Tag.Group to show only the first few tags and collapse the rest behind a "Show more" toggle, so the applied filter area keeps a predictable height. The example below collapses after 3 filters.

Applied Filters
Type is Club or Lounge
Next 7 Days
Min. Rating: 4 ★
Min Capacity: 100
Max. Price: €50
import { Tag } from '@marigold/components';export default () => (  <Tag.Group    label="Applied Filters"    onRemove={() => {}}    removeAll    collapseAt={3}  >    <Tag id="type">Type is Club or Lounge</Tag>    <Tag id="date">Next 7 Days</Tag>    <Tag id="rating">Min. Rating: 4 ★</Tag>    <Tag id="capacity">Min Capacity: 100</Tag>    <Tag id="price">Max. Price: €50</Tag>  </Tag.Group>);

Marigold deliberately does not provide a helper to generate these labels. Label phrasing and translation depend on the application's domain and i18n setup, so they are best composed where the filter state lives. We will revisit a shared helper if demand shows up.

Active Count

When the filter panel is closed, users need a reliable signal that the data set is currently filtered. The applied filter area provides the details, but it can be scrolled out of view or empty space may be reclaimed by other content.

Add a Badge with the number of active filters to the filter button. The count is always visible alongside the trigger, signals filtering at a glance, and combined with the applied filter tags gives a complete snapshot of what is narrowing the data set.

Applied Filters
Type is Club or Lounge
Rating is 3 or more
Max. Price is €500
import { Badge, Button, Stack, Tag } from '@marigold/components';import { ListFilter } from '@marigold/icons';export default () => (  <Stack space={4} alignX="left">    <Button>      <ListFilter /> Filter <Badge variant="primary">3</Badge>    </Button>    <Tag.Group label="Applied Filters" onRemove={() => {}}>      <Tag id="type">Type is Club or Lounge</Tag>      <Tag id="rating">Rating is 3 or more</Tag>      <Tag id="price">Max. Price is €500</Tag>    </Tag.Group>  </Stack>);

Reverting

When filters return no results or simply become irrelevant, offer quick, accessible ways to revert them so users do not get stuck or frustrated.

Two recommendations are:

  1. Remove individual filters directly from the applied filters area, so users can discard one selection and see the effect immediately without disturbing the others.
  2. Clear all filters at once with a single action in the applied filters area, resetting the view to its unfiltered default in one step.

Offering both lets users make precise adjustments or start fresh.

Applied Filters
Type is Club or Lounge
Rating is 3 or more
Traits are cheap, hype (+5 more)
import type { Key } from 'react';import { useState } from 'react';import { Button, Stack, Tag } from '@marigold/components';const items = [  {    id: 'type',    name: 'Type is Club or Lounge',  },  {    id: 'rating',    name: 'Rating is 3 or more',  },  {    id: 'traits',    name: 'Traits are cheap, hype (+5 more)',  },] satisfies { id: string; name: string }[];export default () => {  const [filter, setFilter] = useState(items);  const onRemove = (keys: Set<Key>) => {    setFilter(prevItems => prevItems.filter(item => !keys.has(item.id)));  };  return (    <Stack space={6} alignX="right">      {/* Rendered only while filters are active, no placeholder at rest */}      {filter.length > 0 && (        <Tag.Group          label="Applied Filters"          items={filter}          onRemove={onRemove}          removeAll        >          {filter.map(item => (            <Tag key={item.id} id={item.id}>              {item.name}            </Tag>          ))}        </Tag.Group>      )}      <Button size="small" onPress={() => setFilter(items)}>        Reset Demo      </Button>    </Stack>  );};

"Remove all" Visibility

The "Remove all" action is automatically hidden when only one filter is active, as removing it individually is simpler and cleaner.

Empty States

An empty state occurs when no items match the applied filters, usually because the criteria are too narrow, the filters conflict, or the data set has no matching values. Communicate it in the data set area itself, where results would normally appear, so users understand why nothing is shown and how to recover:

  • Show a clear message or visual placeholder in place of the results.
  • Offer supportive actions, such as suggesting broader parameters or related content.
  • Provide an easy "clear filters" control to reset the view and start fresh.
Name
Type
Address
Capacity
Price

No results found.

Try adjusting your search or filters.
import { Button, EmptyState, Table } from '@marigold/components';const Empty = () => (  <EmptyState    title="No results found."    description="Try adjusting your search or filters."    action={<Button>Clear all filters</Button>}  />);export default () => (  <Table aria-label="Empty table">    <Table.Header>      <Table.Column rowHeader>Name</Table.Column>      <Table.Column>Type</Table.Column>      <Table.Column>Address</Table.Column>      <Table.Column>Capacity</Table.Column>      <Table.Column>Price</Table.Column>    </Table.Header>    <Table.Body emptyState={Empty}>{[]}</Table.Body>  </Table>);

The applied filters area itself needs no empty state. When no filters are active, render nothing instead of a permanent placeholder such as "None": the unfiltered results and the missing count badge on the filter button already say that nothing is filtered, and a placeholder only adds a row of dead space to every unfiltered view.

Accessibility

Filtering combines several interactive controls, so a few accessibility details make the difference between a pattern that works with a keyboard and screen reader and one that quietly excludes people.

  • Do not give the filter bar toolbar semantics. The WAI-ARIA toolbar pattern makes the group a single tab stop with arrow-key roving focus between controls. The filter bar is dominated by inputs, and a search field needs its arrow keys for the text cursor, so roving focus breaks it. Compose the filter bar with Inline so each control is a normal tab stop, as described in the structure.
  • Name a label-only trigger by its full state. When a quick filter shows only the dimension and a count, make sure its accessible name still conveys that something is applied, for example "Status, 2 selected", rather than a bare "Status". With Select the count rendered in the trigger is part of its accessible name. For a custom trigger, set an explicit aria-label.
  • Let the controls own focus and keyboard behaviour. Building quick filters from Marigold components such as Select, Checkbox, and Radio gives correct roles, keyboard interaction, and focus return to the trigger on close. Avoid hand-rolling role="menu" or a custom dropdown.
  • Announce the result of a filter change. Applying or removing a filter changes the data set, but screen reader users get no feedback unless you announce it. Render the result count in a live region (role="status", aria-live="polite"), for example "247 results, Status applied", so the outcome is spoken. Marigold does not yet provide a built-in region for this, so add one where the result count lives.
  • Link a "select all" control to the group it toggles. When the control sits outside the group, as in Select All, set its aria-controls to the group's id so the relationship is conveyed programmatically and not left to visual proximity.

Some details are still application specific and not settled by the design system, such as the exact roles for a multi select filter popover. When in doubt, prefer a Marigold component that already encodes the right semantics over a custom control.

Implementation

The reference implementation linked in the demo section applies a few practices that are easy to miss but make the difference between a filter that feels robust and one that surprises users. They are described here so you can apply them in your own implementation.

Persist in the URL

Applied filters, search, sorting, and pagination should always be reflected in the URL as query parameters. This means the user can bookmark a filtered view for quick access, return to the same filtered view even after closing the browser, or share it with others by sending the link. It also preserves the exact context of a search or analysis for later reference, without the need to manually reselect filters.

Recommended Library

Use nuqs to manage URL state. This makes it easy to keep filter settings in sync with the address bar and share them between components.

Reset Pagination

When a filter or search query changes, the result set shrinks or grows, but the current page number stays. A user on page 7 who narrows the results to 12 items would land on an empty page. Reset to the first page whenever filters or the search query change.

const setFilter = (next: Partial<VenueFilter>) => {
  setPagination({ page: null });
  return _setFilter(next);
};

Wrap the state setters so the reset cannot be forgotten at individual call sites, as useFilter and useSearch do in the reference implementation.

Sync the Form

The form inside the filter panel is uncontrolled: its fields read their defaultValue from the current filter state once, when they mount. When a filter changes outside the panel, for example by removing an applied filter tag, the form would keep showing the stale value. Force a remount by keying the form with the filter state.

<Drawer.Content>
  <FilterForm key={JSON.stringify(filter)} filter={filter} />
</Drawer.Content>

Preview the Draft

A batched panel that shows "Show 47 results" on its Apply button, or option counts that follow the user's picks, must query the draft state of the form, not the applied filter. The applied filter only changes on submit, so previews built on it lag one commit behind.

Read the form into a draft on every interaction. The panel form is uncontrolled, so there is no state to subscribe to; instead, listen broadly on a wrapper around the fields and take a FormData snapshot. Defer the read by one tick so the interaction has been committed to the DOM before the snapshot is taken.

const updateDraft = () => {
  setTimeout(() => {
    if (formRef.current) setDraft(readFormData(formRef.current));
  }, 0);
};

<div onChange={updateDraft} onPointerUp={updateDraft} onKeyUp={updateDraft}>
  <FilterForm ... />
</div>;

These broad events over-report, so debounce the draft before querying, keyed by content rather than by reference, so typing into a number field fires one query instead of one per keystroke.

One query serves the whole preview: request only the totals (pageSize: 1) plus the per-option counts, and feed the result count to the Apply button and the counts to the options. Keep the previous data visible while the next preview loads, so the numbers update in place instead of flashing empty. See useFilterPreview in the reference implementation for the complete hook.

Defaults as No Filter

Some controls always carry a value even when the user does not intend to filter. A slider at its maximum or a number field at zero should mean "no filter applied". Define a sentinel value per filter and treat it as inactive: exclude it from the applied filter tags and remove it from the URL.

const defaultFilter = {
  capacity: 0,
  price: MAX_PRICE,
  traits: [],
  rating: 0,
};

// A price at the maximum means "no price filter"
price >= MAX_PRICE ? defaultFilter.price : price;

This keeps the applied filter area and the URL clean, and makes "remove filter" a simple reset to the sentinel.

Browser History

With filters in the URL, the browser's back and forward buttons can step through previous filter states. This is valuable for deliberate, batch-style changes, where each apply represents a meaningful state. It becomes noise for high-frequency updates such as typing into a search field, where every keystroke would pollute the history.

Use history: 'push' (see the nuqs history option) for batch-applied filter state and pagination, and keep the default replace behavior for continuously updating controls.

Demo

View DemoOpen the interactive exampleView CodeBrowse the source on GitHub
Last update: 5 days ago

Bulk Actions

Let users select many records and act on all of them at once.

Forms

Guidelines for building accessible, consistent forms covering layout, validation, and submission

© Reservix GmbH — Marigold Design System
ImpressumDatenschutzGitHub

On this page

StructureChoosing FiltersFilter TypesApplyingDesigning OptionsMany OptionsOption CountsSelect AllExclusionsScaling UpQuick FiltersGroupingSmall ScreensFilter StateApplied LabelsActive CountRevertingEmpty StatesAccessibilityImplementationPersist in the URLReset PaginationSync the FormPreview the DraftDefaults as No FilterBrowser HistoryDemo