FilterList

FilterList is the house recipe for a filterable list. It owns the whole behavior: case-insensitive substring filtering, fade-out exits with spring reordering, and scroll-fade masks instead of a scrollbar. Use it when a search input drives a list of options. With no query it still works as a plain animated scroll list. The facility picker popup is its home consumer.

import FilterList from "@haloid/azucar/ui/filter-list";

Filtering

Wrap each option in FilterList.Item and give it a searchText. That string is what the query matches against. The rendered content stays opaque JSX. Matching is a trimmed, case-insensitive substring test, through the same filterBySearch that the ranked family uses.

Type in the input. Removed items fade in place while survivors slide up on the house spring. Rows that land at the edges of the scroller arrive damped, so they never overshoot the clip. Clear the input and the list springs back.

function FacilitySearch() {
const [query, setQuery] = useState("");
const names = [
  "TOR5", "ATL2", "DFW1", "PHX4", "SEA3",
  "AT&T Global Services", "Cisco Systems Canada", "Cloudflare, US Inc.",
  "Centrilogic Inc.", "Alea Software Ltd",
];
return (
  <div className="flex w-100 flex-col gap-3">
    <Input
      variant="filled"
      placeholder="Filter facilities and customers"
      value={query}
      onChange={(event) => setQuery(event.target.value)}
    />
    <FilterList
      query={query}
      className="max-h-60 gap-2"
      emptyState={
        <div className="text-[14px] leading-none text-fg-2">No matches</div>
      }
    >
      {names.map((name) => (
        <FilterList.Item key={name} searchText={name}>
          <div className="flex h-8 items-center rounded bg-bg-2 px-3 text-[14px] text-fg-4">
            {name}
          </div>
        </FilterList.Item>
      ))}
    </FilterList>
  </div>
);
}

render(<FacilitySearch />);

The item is a marker

FilterList.Item renders nothing of its own. Its body returns children and stops. The root does the work: it reads the props of each item, filters, and renders the animated wrapper itself. Two rules follow.

First, the root matches items by prop shape, not by element type. A child counts as an item when typeof props.searchText === "string". The reason is the server to client boundary: component type identity does not survive it, so an element.type === FilterListItem check fails when a Server Component builds the children. The prop check works everywhere. FilterListItem is also a named export for call sites where the dot notation is not available.

Second, items must be direct children. A fragment or a wrapper component between the root and the items hides them from filtering and from the exit animation.

The root owns the motion wrapper because popLayout needs the motion element as the immediate child of AnimatePresence. If the item rendered its own wrapper, exits would break. This is also why as lives where it does: as on the root picks the scroller tag, as on an item picks the wrapper tag. as="ul" with as="li" items gives you a semantic list. The default is div for both.

Query semantics

query has three states:

  • undefined disables filtering. The list is a plain animated scroll list with fades. There is no search behavior to pay for.
  • An empty or whitespace-only string shows every item.
  • Any other string filters by substring.

emptyState renders in place of the whole scroller when the query is non-empty and nothing matches. Keep it to one quiet line, as in the demo above. Without it, a zero-match query renders nothing.

onMatchesChange reports the visible count, once on mount and again whenever the count changes. It ignores callback identity on purpose, so an inline arrow is fine. Use it when a parent shows a result count or collapses an adjoining section.

Anchor

anchor names one item by its searchText and scrolls it near the top. The item rests 20px below the top edge of the scroll window, clamped at the end of the list. The scroll is instant, not smooth. It applies when the list becomes visible, such as when a keepMounted popup opens, and when the anchor value changes. It applies only while the query is empty, so it never fights the snap-to-top that a query change triggers. An anchor with no matching item does nothing.

This list has no query. It opens with PHX4 already in position, and both fades on.

<FilterList anchor="PHX4" className="max-h-40 w-100 gap-2">
{[
  "TOR5", "ATL2", "DFW1", "PHX4", "SEA3",
  "AT&T Global Services", "Cisco Systems Canada",
].map((name) => (
  <FilterList.Item key={name} searchText={name}>
    <div className="flex h-8 items-center rounded bg-bg-2 px-3 text-[14px] text-fg-4">
      {name}
    </div>
  </FilterList.Item>
))}
</FilterList>

Scroll fades

The scrollbar is hidden. Fade masks hint at overflow instead: content fades out over 3rem at the bottom, and after you scroll, fades in over 2rem at the top. The masks re-measure beyond plain scrolling: when items enter or leave, when the query changes, and after exit animations finish, because popped items inflate the scroll height until they unmount.

Both fades default on. Disable a side with fadeTop={false} or fadeBottom={false} when the list should end hard against an adjacent element.

Styling splits across two class props. className styles the scroller. Max-height, gap, and padding belong there. wrapperClassName styles the mask wrapper around it.

In the facility picker

The facility picker uses every prop on this page at once. From packages/azucar/src/facility-picker/index.tsx:

<FilterList
query={searchInput}
anchor={value}
// the scroller x-clips, and the item hover pill overhangs 6px per side;
// -mx-3/px-3 gives it clearance full-bleed to the popup's p-3
wrapperClassName="-mx-3 -my-3"
className="max-h-34 gap-2 px-3 py-3"
fadeBottom={false}
emptyState={
<div className="text-fg-2 text-[14px] leading-none">No matches</div>
}
>
{facilities.map((facility) => (
<FilterList.Item key={facility.code} searchText={facility.code}>
<MenuItem onClick={() => onChange(facility.code)}>
{facility.code}
</MenuItem>
</FilterList.Item>
))}
</FilterList>

query follows the search input in the popup header. anchor={value} brings the selected facility into view. The popup is keepMounted, so the scroll lands on the first painted frame after it opens. fadeBottom={false} lets the list end hard against the separator below it. The negative margins on wrapperClassName, paid back as padding on className, extend the scroller full-bleed to the popup's own padding: the item hover pill gets clipping clearance, and the fade gradient ends at the popup header.

API

// html tags only; intersecting with motion's keys keeps the index type-safe
type FilterListTag = keyof HTMLElementTagNameMap & keyof typeof motion;
// FilterList
interface FilterListProps {
children: React.ReactNode; // direct FilterList.Item children only
query?: string; // undefined = no filtering
emptyState?: React.ReactNode; // replaces the scroller on zero matches
anchor?: string; // searchText to rest near the top; empty query only
onMatchesChange?: (matches: number) => void;
fadeTop?: boolean; // default true
fadeBottom?: boolean; // default true
as?: FilterListTag; // scroller tag, default "div"
className?: string; // the scroller: max-height, gap, padding
wrapperClassName?: string; // the fade-mask wrapper around the scroller
}
// FilterList.Item (named export: FilterListItem)
interface FilterListItemProps {
searchText: string; // match target; also the default React key
children: React.ReactNode;
as?: FilterListTag; // wrapper tag, default "div"
className?: string; // applied to the motion wrapper
}

Limits

searchText doubles as the fallback key, so give items explicit keys when the text can repeat. Matching is plain substring. There is no fuzzy matching and no highlighting of the matched text. Filtering walks the children on every render, which suits the short option lists it serves. It is not a virtualized list.