Skip to content

Filters written as sentences, each word a dropdown, with an all-or-any switch.

Command & search@base-ui/reactmotion

01Preview

Filter deals12 of 60 deals
Where
2 filters applied, matching all

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install @base-ui/react motion

03Usage

import { FilterBuilder, matchesFilters, type FilterField, type FilterGroup } from "@/components/ui/filter-builder";

const fields: FilterField[] = [
  { key: "stage", label: "Stage", type: "option", options: [{ value: "won", label: "Closed won" }, { value: "lost", label: "Closed lost" }] },
  { key: "amount", label: "Amount", type: "number", unit: "" },
  { key: "company", label: "Company", type: "text" },
];

const [group, setGroup] = useState<FilterGroup>({ conjunction: "and", rules: [] });
const visible = deals.filter((d) => matchesFilters(d, group, fields));

<FilterBuilder fields={fields} value={group} onValueChange={setGroup} />

04Source

"use client";
import { Menu } from "@base-ui/react/menu";
import { Select } from "@base-ui/react/select";
import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, type AnimationPlaybackControls } from "motion/react";
import { useEffect, useId, useRef, useState } from "react";
import { cn } from "@/lib/cn";
import { Check, ChevronDown, Plus, X } from "@/lib/icons";
import { ease, spring } from "@/lib/motion";
import { useControllableState } from "@/lib/use-controllable-state";

/* ------------------------------------------------------------------ */
/* Model                                                               */
/* ------------------------------------------------------------------ */

export type FilterOption = { value: string; label: string; icon?: React.ReactNode };

export type FilterField = {
  key: string;
  label: string;
  icon?: React.ReactNode;
  type: "option" | "text" | "number";
  /** The choices for an option field. */
  options?: FilterOption[];
  /** Shown before a number, e.g. "€". */
  unit?: string;
  placeholder?: string;
};

export type FilterRule = { id: string; field: string; operator: string; value: string | string[] | null };
export type FilterGroup = { conjunction: "and" | "or"; rules: FilterRule[] };

type Arity = "one" | "many" | "none";
type Operator = { value: string; label: string; arity: Arity };

export const OPERATORS: Record<FilterField["type"], Operator[]> = {
  option: [
    { value: "is", label: "is", arity: "one" },
    { value: "is_not", label: "is not", arity: "one" },
    { value: "any_of", label: "is any of", arity: "many" },
    { value: "none_of", label: "is none of", arity: "many" },
  ],
  text: [
    { value: "contains", label: "contains", arity: "one" },
    { value: "not_contains", label: "doesn’t contain", arity: "one" },
    { value: "is", label: "is", arity: "one" },
    { value: "starts_with", label: "starts with", arity: "one" },
    { value: "empty", label: "is empty", arity: "none" },
  ],
  number: [
    { value: "eq", label: "is", arity: "one" },
    { value: "gt", label: "is more than", arity: "one" },
    { value: "lt", label: "is less than", arity: "one" },
    { value: "gte", label: "is at least", arity: "one" },
    { value: "lte", label: "is at most", arity: "one" },
  ],
};

const operatorOf = (field: FilterField | undefined, op: string) => (field ? OPERATORS[field.type].find((o) => o.value === op) : undefined);

/** A rule is applied only once it has everything its operator needs. */
export function isRuleComplete(rule: FilterRule, fields: FilterField[]) {
  const field = fields.find((f) => f.key === rule.field);
  const op = operatorOf(field, rule.operator);
  if (!field || !op) return false;
  if (op.arity === "none") return true;
  if (op.arity === "many") return Array.isArray(rule.value) && rule.value.length > 0;
  if (typeof rule.value !== "string" || rule.value.trim() === "") return false;
  return field.type !== "number" || Number.isFinite(Number(rule.value));
}

/** Tests one record against the group. Incomplete rules are ignored; an empty group matches everything. */
export function matchesFilters(record: Record<string, unknown>, group: FilterGroup, fields: FilterField[]) {
  const rules = group.rules.filter((r) => isRuleComplete(r, fields));
  if (!rules.length) return true;
  const test = (rule: FilterRule) => {
    const field = fields.find((f) => f.key === rule.field)!;
    const raw = record[field.key];
    const v = rule.value;
    if (field.type === "number") {
      const a = Number(raw);
      const b = Number(v);
      return { eq: a === b, gt: a > b, lt: a < b, gte: a >= b, lte: a <= b }[rule.operator as "eq"] ?? false;
    }
    if (field.type === "text") {
      const a = String(raw ?? "").toLocaleLowerCase();
      const b = String(v ?? "").toLocaleLowerCase();
      return { contains: a.includes(b), not_contains: !a.includes(b), is: a === b, starts_with: a.startsWith(b), empty: a.trim() === "" }[rule.operator as "is"] ?? false;
    }
    const list = [v].flat();
    return { is: raw === v, is_not: raw !== v, any_of: list.includes(raw as string), none_of: !list.includes(raw as string) }[rule.operator as "is"] ?? false;
  };
  return group.conjunction === "and" ? rules.every(test) : rules.some(test);
}

/* ------------------------------------------------------------------ */
/* Builder                                                             */
/* ------------------------------------------------------------------ */

export type FilterBuilderProps = Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> & {
  fields: FilterField[];
  value?: FilterGroup;
  defaultValue?: FilterGroup;
  onValueChange?: (group: FilterGroup) => void;
  /** Portal target for every popup. Defaults to document.body. */
  container?: HTMLElement | null;
};

export function FilterBuilder({ fields, value, defaultValue = { conjunction: "and", rules: [] }, onValueChange, container, className, ...rest }: FilterBuilderProps) {
  const reduce = !!useReducedMotion();
  const [group, setGroup] = useControllableState({ value, defaultValue, onChange: onValueChange });
  const [openValue, setOpenValue] = useState<string | null>(null);
  const [announce, setAnnounce] = useState("");
  const rootRef = useRef<HTMLDivElement>(null);
  const addRef = useRef<HTMLButtonElement>(null);
  const added = useRef<string | null>(null);
  const pendingFocus = useRef<string | null>(null);
  const counter = useRef(0);
  const baseId = useId();

  const fieldOf = (key: string) => fields.find((f) => f.key === key);
  const update = (id: string, patch: Partial<FilterRule>) => setGroup({ ...group, rules: group.rules.map((r) => (r.id === id ? { ...r, ...patch } : r)) });

  const add = (key: string) => {
    const field = fieldOf(key);
    if (!field) return;
    const id = `${baseId}-${++counter.current}`;
    added.current = id;
    setGroup({ ...group, rules: [...group.rules, { id, field: key, operator: OPERATORS[field.type][0].value, value: null }] });
    setAnnounce(`Added a ${field.label} filter`);
  };

  const remove = (id: string) => {
    const index = group.rules.findIndex((r) => r.id === id);
    const next = group.rules.filter((r) => r.id !== id);
    const neighbor = next[Math.min(index, next.length - 1)];
    const owned = rootRef.current?.querySelector(`[data-rule="${CSS.escape(id)}"]`)?.contains(document.activeElement);
    if (owned) pendingFocus.current = neighbor?.id ?? "add";
    setGroup({ ...group, rules: next });
    const field = fieldOf(group.rules[index]?.field ?? "");
    setAnnounce(`Removed the ${field?.label ?? ""} filter`);
  };

  // Focus follows a removal to the row that slid up into its place, or to Add filter.
  useEffect(() => {
    const target = pendingFocus.current;
    if (!target) return;
    pendingFocus.current = null;
    if (target === "add") return addRef.current?.focus();
    rootRef.current?.querySelector<HTMLElement>(`[data-rule="${CSS.escape(target)}"] [data-part=remove]`)?.focus();
  });

  const toggleConjunction = () => {
    const conjunction = group.conjunction === "and" ? "or" : "and";
    setGroup({ ...group, conjunction });
    setAnnounce(conjunction === "and" ? "Matching all filters" : "Matching any filter");
  };

  // After Add filter's menu closes, the new row's value is where the eye and focus go next.
  const valueElement = (id: string) => rootRef.current?.querySelector<HTMLElement>(`[data-rule="${CSS.escape(id)}"] [data-part=value]`) ?? null;

  const complete = group.rules.filter((r) => isRuleComplete(r, fields)).length;
  const incomplete = group.rules.length - complete;

  return (
    <div ref={rootRef} className={cn("flex min-w-0 flex-col", className)} {...rest}>
      <div role="group" aria-label="Filters" className="flex flex-col">
        <AnimatePresence initial={false}>
          {group.rules.length === 0 && (
            <motion.p
              key="empty"
              initial={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}
              animate={{ opacity: 1, height: "auto" }}
              exit={reduce ? { opacity: 0 } : { opacity: 0, height: 0, transition: { duration: 0.14, ease: ease.out } }}
              transition={{ duration: 0.22, ease: ease.out }}
              className="overflow-hidden text-[12.5px] text-fg-3"
            >
              <span className="flex h-9 items-center pl-2">No filters yet. Add one to narrow the list.</span>
            </motion.p>
          )}
          {group.rules.map((rule, index) => (
            <motion.div
              key={rule.id}
              data-rule={rule.id}
              initial={reduce ? { opacity: 0 } : { opacity: 0, height: 0, y: -4 }}
              animate={{ opacity: 1, height: "auto", y: 0 }}
              exit={reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, height: 0, transition: { duration: 0.16, ease: ease.out } }}
              transition={reduce ? { duration: 0.14 } : { duration: 0.24, ease: ease.out }}
              // Clips while the height animates; the side padding keeps focus rings inside the clip.
              className="-mx-1 overflow-hidden px-1"
            >
              <Row
                rule={rule}
                index={index}
                fields={fields}
                conjunction={group.conjunction}
                onConjunction={toggleConjunction}
                onChange={(patch) => update(rule.id, patch)}
                onRemove={() => remove(rule.id)}
                valueOpen={openValue === rule.id}
                onValueOpenChange={(o) => setOpenValue(o ? rule.id : null)}
                container={container}
                reduce={reduce}
                complete={isRuleComplete(rule, fields)}
              />
            </motion.div>
          ))}
        </AnimatePresence>
      </div>

      <div className="mt-1.5 flex h-8 items-center justify-between gap-2">
        <Menu.Root
          onOpenChange={(open) => {
            if (open) added.current = null;
          }}
          onOpenChangeComplete={(open) => {
            const id = added.current;
            if (open || !id) return;
            added.current = null;
            // Hand over to the new row: options open their list, text and numbers take the caret.
            valueElement(id)?.focus();
            const rule = group.rules.find((r) => r.id === id);
            if (rule && fieldOf(rule.field)?.type === "option") setOpenValue(id);
          }}
        >
          <Menu.Trigger ref={addRef} className={cn(ghost, "gap-1.5 pr-2.5 pl-2 text-fg-2")}>
            <Plus size={14} />
            Add filter
          </Menu.Trigger>
          <Menu.Portal container={container}>
            <Menu.Positioner align="start" sideOffset={6} collisionPadding={8} className="z-(--z-popover) outline-none">
              <Menu.Popup
                finalFocus={() => !added.current}
                className={cn(popupClass, "min-w-44 p-1")}
              >
                <p className="px-2 pt-1 pb-1.5 font-mono text-2xs tracking-[0.08em] text-fg-4 uppercase">Filter by</p>
                {fields.map((f) => (
                  <Menu.Item key={f.key} onClick={() => add(f.key)} className={itemClass}>
                    {f.icon && <span className="flex shrink-0 text-fg-3 [&_svg]:size-3.5">{f.icon}</span>}
                    <span className="truncate">{f.label}</span>
                  </Menu.Item>
                ))}
              </Menu.Popup>
            </Menu.Positioner>
          </Menu.Portal>
        </Menu.Root>

        <div className="flex min-w-0 items-center gap-2">
          <AnimatePresence initial={false}>
            {incomplete > 0 && (
              <motion.span
                key="incomplete"
                initial={{ opacity: 0 }}
                animate={{ opacity: 1 }}
                exit={{ opacity: 0, transition: { duration: 0.1 } }}
                className="truncate text-[12px] text-fg-4"
              >
                {incomplete === 1 ? "1 filter needs a value" : `${incomplete} filters need a value`}
              </motion.span>
            )}
          </AnimatePresence>
          {group.rules.length > 0 && (
            <button
              type="button"
              onClick={() => {
                setGroup({ ...group, rules: [] });
                addRef.current?.focus();
                setAnnounce("Cleared all filters");
              }}
              className={cn(ghost, "px-2 text-fg-3")}
            >
              Clear all
            </button>
          )}
        </div>
      </div>

      <span role="status" aria-live="polite" className="sr-only">
        {announce}
      </span>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Row                                                                 */
/* ------------------------------------------------------------------ */

type RowProps = {
  rule: FilterRule;
  index: number;
  fields: FilterField[];
  conjunction: "and" | "or";
  onConjunction: () => void;
  onChange: (patch: Partial<FilterRule>) => void;
  onRemove: () => void;
  valueOpen: boolean;
  onValueOpenChange: (open: boolean) => void;
  container?: HTMLElement | null;
  reduce: boolean;
  complete: boolean;
};

function Row({ rule, index, fields, conjunction, onConjunction, onChange, onRemove, valueOpen, onValueOpenChange, container, reduce, complete }: RowProps) {
  const field = fields.find((f) => f.key === rule.field);
  const ops = field ? OPERATORS[field.type] : [];
  const op = operatorOf(field, rule.operator);

  const changeField = (key: string) => {
    const next = fields.find((f) => f.key === key);
    if (!next || key === rule.field) return;
    // A new field keeps its operator only when the new type has one with the same meaning.
    const same = next.type === field?.type ? rule.operator : OPERATORS[next.type][0].value;
    onChange({ field: key, operator: same, value: next.type === field?.type && next.type !== "option" ? rule.value : null });
  };

  const changeOperator = (value: string) => {
    const nextOp = ops.find((o) => o.value === value);
    if (!nextOp) return;
    // Carry the value across when the arity changes: one becomes a list of one, a list keeps its first.
    let v = rule.value;
    if (nextOp.arity === "many") v = v == null ? null : [v].flat();
    else if (nextOp.arity === "one") v = Array.isArray(v) ? (v[0] ?? null) : v;
    else v = null;
    onChange({ operator: value, value: v });
  };

  return (
    <div data-complete={complete || undefined} className="grid grid-cols-[3.25rem_minmax(0,1fr)_auto] items-start gap-x-1.5 py-[3px]">
      <div className="flex h-7 items-center">
        {index === 0 ? (
          <span className="pl-2 text-[12.5px] text-fg-3">Where</span>
        ) : (
          <button
            type="button"
            onClick={onConjunction}
            aria-label={conjunction === "and" ? "Match all filters. Switch to any." : "Match any filter. Switch to all."}
            className={cn(ghost, "relative w-full justify-start overflow-hidden pr-1 pl-2 text-fg-2")}
          >
            <span className="relative grid h-4 overflow-hidden">
              <AnimatePresence initial={false} mode="popLayout">
                <motion.span
                  key={conjunction}
                  initial={reduce ? { opacity: 0 } : { opacity: 0, y: 10 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={reduce ? { opacity: 0 } : { opacity: 0, y: -10 }}
                  // Each row's word rolls a beat after the one above, so the change reads down the list.
                  transition={reduce ? { duration: 0.12 } : { ...spring.snappy, delay: (index - 1) * 0.03 }}
                  className="leading-4"
                >
                  {conjunction}
                </motion.span>
              </AnimatePresence>
            </span>
            <ChevronDown size={12} className="ml-0.5 shrink-0 text-fg-4" />
          </button>
        )}
      </div>

      <div className="flex min-w-0 flex-wrap items-center gap-1.5">
        <Segment
          label="Field"
          value={rule.field}
          items={fields.map((f) => ({ value: f.key, label: f.label, icon: f.icon }))}
          onValueChange={(v) => v && changeField(v)}
          container={container}
          reduce={reduce}
          variant="field"
        />
        {field && (
          <Segment
            label="Operator"
            value={rule.operator}
            items={ops.map((o) => ({ value: o.value, label: o.label }))}
            onValueChange={(v) => v && changeOperator(v)}
            container={container}
            reduce={reduce}
            variant="operator"
          />
        )}
        {field && op && op.arity !== "none" && (
          <ValueControl
            field={field}
            arity={op.arity}
            value={rule.value}
            onChange={(v) => onChange({ value: v })}
            open={valueOpen}
            onOpenChange={onValueOpenChange}
            container={container}
            reduce={reduce}
          />
        )}
      </div>

      <button
        type="button"
        data-part="remove"
        aria-label={`Remove ${field?.label ?? "this"} filter`}
        onClick={onRemove}
        className={cn(ghost, "relative w-7 justify-center text-fg-3 active:scale-[0.9] pointer-coarse:after:-inset-2")}
      >
        <X size={14} />
      </button>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Segments                                                            */
/* ------------------------------------------------------------------ */

type SegmentProps = {
  label: string;
  value: string | null;
  items: FilterOption[];
  onValueChange: (value: string | null) => void;
  container?: HTMLElement | null;
  reduce: boolean;
  variant: "field" | "operator" | "value";
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
  placeholder?: string;
};

function Segment({ label, value, items, onValueChange, container, reduce, variant, open, onOpenChange, placeholder = "Select…" }: SegmentProps) {
  const current = items.find((i) => i.value === value);
  return (
    <Select.Root
      items={items}
      value={value}
      onValueChange={(v) => onValueChange(v as string | null)}
      open={open}
      onOpenChange={onOpenChange ? (o) => onOpenChange(o) : undefined}
    >
      <Select.Trigger
        aria-label={label}
        data-part={variant === "value" ? "value" : undefined}
        className={cn(triggerClass(variant), !current && "border-dashed text-fg-4")}
      >
        {current?.icon && <span className="flex shrink-0 text-fg-3 [&_svg]:size-3.5">{current.icon}</span>}
        <SwapText text={current?.label ?? placeholder} reduce={reduce} />
        {variant !== "operator" && <ChevronDown size={12} className="shrink-0 text-fg-4" />}
      </Select.Trigger>
      <Popup container={container} reduce={reduce}>
        {items.map((item) => (
          <Select.Item key={item.value} value={item.value} className={itemClass}>
            {item.icon && <span className="flex shrink-0 text-fg-3 [&_svg]:size-3.5">{item.icon}</span>}
            <Select.ItemText className="min-w-0 flex-1 truncate">{item.label}</Select.ItemText>
            <Select.ItemIndicator className="flex shrink-0 text-fg-2">
              <Check size={14} />
            </Select.ItemIndicator>
          </Select.Item>
        ))}
      </Popup>
    </Select.Root>
  );
}

type ValueControlProps = {
  field: FilterField;
  arity: "one" | "many";
  value: string | string[] | null;
  onChange: (value: string | string[] | null) => void;
  open: boolean;
  onOpenChange: (open: boolean) => void;
  container?: HTMLElement | null;
  reduce: boolean;
};

function ValueControl({ field, arity, value, onChange, open, onOpenChange, container, reduce }: ValueControlProps) {
  const options = field.options ?? [];
  if (field.type === "option" && arity === "one") {
    return (
      <Segment
        label={`${field.label} value`}
        value={typeof value === "string" ? value : null}
        items={options}
        onValueChange={onChange}
        open={open}
        onOpenChange={onOpenChange}
        container={container}
        reduce={reduce}
        variant="value"
        placeholder={field.placeholder}
      />
    );
  }

  if (field.type === "option") {
    const list = Array.isArray(value) ? value : [];
    const labels = options.filter((o) => list.includes(o.value)).map((o) => o.label);
    const text = labels.length === 0 ? (field.placeholder ?? "Select…") : labels.length <= 2 ? labels.join(", ") : `${labels[0]} +${labels.length - 1}`;
    return (
      <Select.Root<string, true> multiple items={options} value={list} onValueChange={(v) => onChange(v.length ? v : null)} open={open} onOpenChange={(o) => onOpenChange(o)}>
        <Select.Trigger aria-label={`${field.label} values`} data-part="value" className={cn(triggerClass("value"), !labels.length && "border-dashed text-fg-4")}>
          <SwapText text={text} reduce={reduce} />
          <ChevronDown size={12} className="shrink-0 text-fg-4" />
        </Select.Trigger>
        <Popup container={container} reduce={reduce}>
          {options.map((item) => (
            <Select.Item key={item.value} value={item.value} className={itemClass}>
              <Box on={list.includes(item.value)} reduce={reduce} />
              {item.icon && <span className="flex shrink-0 text-fg-3 [&_svg]:size-3.5">{item.icon}</span>}
              <Select.ItemText className="min-w-0 flex-1 truncate">{item.label}</Select.ItemText>
            </Select.Item>
          ))}
        </Popup>
      </Select.Root>
    );
  }

  const number = field.type === "number";
  return (
    <label
      data-invalid={number && typeof value === "string" && value !== "" && !Number.isFinite(Number(value)) ? "" : undefined}
      className={cn(
        "flex h-7 min-w-0 items-center gap-1 rounded-md border border-line-2 bg-raised px-2 text-[12.5px] shadow-[var(--shadow)]",
        "transition-[border-color,box-shadow] duration-150 hover:border-fg-4 focus-within:border-fg-4 focus-within:ring-2 focus-within:ring-fg/10",
        "data-invalid:border-danger/70",
        number ? "w-28" : "w-40 max-w-full",
      )}
    >
      <span className="sr-only">{field.label} value</span>
      {number && field.unit && <span aria-hidden className="shrink-0 text-fg-3">{field.unit}</span>}
      <input
        data-part="value"
        type="text"
        inputMode={number ? "decimal" : "text"}
        enterKeyHint="done"
        autoComplete="off"
        spellCheck={false}
        value={typeof value === "string" ? value : ""}
        onChange={(e) => onChange(e.target.value === "" ? null : e.target.value)}
        placeholder={field.placeholder ?? (number ? "0" : "Type a value")}
        className={cn("h-full min-w-0 flex-1 bg-transparent text-base text-fg outline-none placeholder:text-fg-4 sm:text-[12.5px]", number && "tabular")}
      />
    </label>
  );
}

// A label that swaps inside its own box: the old word lifts out as the new one settles in.
function SwapText({ text, reduce }: { text: string; reduce: boolean }) {
  return (
    <span className="grid min-w-0 overflow-hidden">
      <AnimatePresence initial={false} mode="popLayout">
        <motion.span
          key={text}
          initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: "blur(2px)" }}
          animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
          exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6, filter: "blur(2px)", transition: { duration: 0.12 } }}
          transition={{ duration: reduce ? 0.12 : 0.2, ease: ease.out }}
          className="col-start-1 row-start-1 truncate"
        >
          {text}
        </motion.span>
      </AnimatePresence>
    </span>
  );
}

function Popup({ children, container, reduce }: { children: React.ReactNode; container?: HTMLElement | null; reduce: boolean }) {
  const { setList, y, height, opacity } = useGlide(reduce);
  return (
    <Select.Portal container={container}>
      <Select.Positioner alignItemWithTrigger={false} align="start" sideOffset={6} collisionPadding={8} className="z-(--z-popover) outline-none select-none">
        <Select.Popup className={cn(popupClass, "min-w-(--anchor-width)")}>
          <Select.List ref={setList} className="relative max-h-[min(var(--available-height),16rem)] min-w-40 overflow-y-auto overscroll-contain p-1 outline-none">
            <motion.div aria-hidden style={{ y, height, opacity }} className="pointer-events-none absolute inset-x-1 top-0 rounded-lg bg-line" />
            {children}
          </Select.List>
        </Select.Popup>
      </Select.Positioner>
    </Select.Portal>
  );
}

function Box({ on, reduce }: { on: boolean; reduce: boolean }) {
  return (
    <span
      aria-hidden
      className={cn(
        "grid size-3.5 shrink-0 place-items-center rounded-[4px] border transition-[background-color,border-color,scale] duration-150 ease-out group-active/item:scale-[0.85]",
        on ? "border-fg bg-fg text-frame" : "border-line-2 bg-frame group-data-highlighted/item:border-fg-4",
      )}
    >
      <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
        <motion.path
          d="M2 5.2 4.1 7.2 8 2.8"
          initial={false}
          animate={{ pathLength: on ? 1 : 0, opacity: on ? 1 : 0 }}
          transition={reduce ? { duration: 0 } : { pathLength: { duration: 0.2, ease: ease.out }, opacity: { duration: 0.05 } }}
        />
      </svg>
    </span>
  );
}

/* ------------------------------------------------------------------ */
/* Styles                                                              */
/* ------------------------------------------------------------------ */

const focusRing = "outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3";

const ghost = cn(
  "inline-flex h-7 shrink-0 items-center rounded-md text-[12.5px] font-medium whitespace-nowrap select-none",
  focusRing,
  "transition-[background-color,color,scale] duration-150 ease-out hover:bg-hover hover:text-fg active:scale-[0.96] active:duration-75 data-popup-open:bg-hover data-popup-open:text-fg",
);

const triggerClass = (variant: "field" | "operator" | "value") =>
  cn(
    "relative inline-flex h-7 max-w-full min-w-0 shrink items-center gap-1.5 rounded-md text-[12.5px] select-none",
    focusRing,
    "transition-[background-color,border-color,color,scale] duration-150 ease-out active:scale-[0.97] active:duration-75",
    "pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:-inset-y-2",
    // The operator reads as a word in the sentence; field and value read as the things being compared.
    variant === "operator"
      ? "px-1.5 text-fg-2 hover:bg-hover hover:text-fg data-popup-open:bg-hover data-popup-open:text-fg"
      : "border border-line-2 bg-raised pr-1.5 pl-2 text-fg shadow-[var(--shadow)] hover:border-fg-4 data-popup-open:border-fg-4",
    variant === "field" && "font-medium",
    variant === "value" && "max-w-[14rem]",
  );

const popupClass = cn(
  "origin-(--transform-origin) overflow-hidden rounded-xl border border-line-2 bg-raised text-fg shadow-pop outline-none",
  "transition-[opacity,scale] duration-160 ease-out-expo data-ending-style:duration-100 data-ending-style:ease-out",
  "data-starting-style:scale-[0.96] data-starting-style:opacity-0 data-ending-style:scale-[0.98] data-ending-style:opacity-0",
  "motion-reduce:data-starting-style:scale-100 motion-reduce:data-ending-style:scale-100",
);

const itemClass = cn(
  "group/item relative z-[1] flex h-8 cursor-default scroll-my-1 items-center gap-2.5 rounded-lg px-2 text-[13px] text-fg outline-none select-none pointer-coarse:h-10",
  "data-highlighted:bg-line data-disabled:text-fg-4 [[data-glide]_&]:data-highlighted:bg-transparent",
);

// One highlight for a list: glides after the pointer, jumps for arrow keys.
function useGlide(reduce: boolean) {
  const [list, setList] = useState<HTMLDivElement | null>(null);
  const y = useMotionValue(0);
  const height = useMotionValue(32);
  const opacity = useMotionValue(0);

  useEffect(() => {
    if (!list) return;
    list.setAttribute("data-glide", "");
    let keyboard = false;
    let shown = false;
    let running: AnimationPlaybackControls[] = [];
    const stop = () => {
      running.forEach((c) => c.stop());
      running = [];
    };
    const place = () => {
      const el = list.querySelector<HTMLElement>("[data-highlighted]");
      stop();
      if (!el) {
        shown = false;
        running.push(animate(opacity, 0, { duration: reduce ? 0 : 0.12 }));
        return;
      }
      if (!shown || keyboard || reduce) {
        y.jump(el.offsetTop);
        height.jump(el.offsetHeight);
        opacity.jump(1);
      } else {
        running.push(animate(y, el.offsetTop, spring.follow), animate(height, el.offsetHeight, spring.follow));
        opacity.jump(1);
      }
      shown = true;
    };
    const onKey = () => (keyboard = true);
    const onPointer = () => (keyboard = false);
    const observer = new MutationObserver(place);
    observer.observe(list, { subtree: true, attributes: true, attributeFilter: ["data-highlighted"] });
    document.addEventListener("keydown", onKey, true);
    list.addEventListener("pointermove", onPointer);
    place();
    return () => {
      observer.disconnect();
      document.removeEventListener("keydown", onKey, true);
      list.removeEventListener("pointermove", onPointer);
      stop();
    };
  }, [list, reduce, y, height, opacity]);

  return { setList, y, height, opacity };
}

05Props

FilterBuilder

PropTypeDefaultDescription
fields*FilterField[]{ key, label, type: "option" | "text" | "number", options?, icon?, unit?, placeholder? }.
valueFilterGroup{ conjunction: "and" | "or", rules: { id, field, operator, value }[] }, controlled.
defaultValueFilterGroup{ conjunction: "and", rules: [] }The group, uncontrolled.
onValueChange(group: FilterGroup) => voidCalled on every edit, including incomplete rows.
containerHTMLElement | nullPortal target for the menus. Defaults to document.body.

matchesFilters

PropTypeDefaultDescription
record*Record<string, unknown>One row, read by each field’s key. Returns a boolean.
group*FilterGroupIncomplete rules are skipped; an empty group matches everything.
fields*FilterField[]The same fields the builder uses.

isRuleComplete

PropTypeDefaultDescription
rule*FilterRuleTrue once the rule has what its operator needs.
fields*FilterField[]Used to look up the field’s type and operators.

06Notes

Behavior

  • Adding a filter hands straight over to its value: an option list opens, a text or number field takes the caret.
  • Switching operator carries the value across: “is Proposal” becomes “is any of Proposal”, and back keeps the first. Switching field resets the value.
  • Rows without a value stay visible with a dashed value slot and are left out of matching; the footer says how many still need one.
  • One conjunction governs the group; any “and” in the list toggles all of them, so there is never a mixed sentence.

Motion

  • Rows open to height and drop in from 4px over 240ms on the expo ease-out, and close in 160ms, so the list makes room instead of jumping.
  • Changing and to or rolls the word 10px on the snappy spring, each row 30ms after the one above, so the change reads down the sentence.
  • Segment labels swap in place with a 6px lift and 2px blur. Reduced motion keeps crossfades only.

Accessibility

  • Each segment is a Base UI select or a labeled input, named “Field”, “Operator” and “Stage value”; Add filter is a menu.
  • Removing a row moves focus to the row that slid into its place, or to Add filter when none are left.
  • A polite live region announces added and removed filters and the switch between all and any.