Skip to main content
Nexus Design System
GitHub

DateRangeFilter

DateRangeFilter filters by a range of calendar days, applied when users press Apply. Use it for created, updated or due dates. Presets cover common ranges, and the calendar covers the rest. If your Next.js app uses Cache Components, render DateRangeFilter inside <Suspense> so it can read today's date.

Created
'use client';

import { DateRangeFilterExample } from '@/components/nxds/blocks/filtering/date-range-filter/date-range-filter-example';

export default function DateRangeFilterDefault() {
  return <DateRangeFilterExample />;
}

Installation

npx nxds-cli@latest add date-range-filter

Run npx nxds-cli@latest init first if nxds isn’t set up in your app yet. See Install.

Source

To install by hand, copy these files into your project. Paths are relative to packages/react/src/. Copy each file to the same path under components/nxds/, and the imports between files work without edits.

Install these components first: DatePicker, DropdownMenu, FilterCondition, Popover.

blocks/filtering/date-range-filter/date-range-filter-example.tsx
'use client';

import { useState } from 'react';

import type { FilterRule } from '../../../lib/filter-model';

import { type DateRangeCondition, DateRangeFilter } from './date-range-filter';

export function DateRangeFilterExample() {
  const [value, setValue] = useState<DateRangeCondition | null>({
    operator: 'between',
    from: '2026-09-01',
    to: '2026-09-10',
  });
  return <DateRangeFilter label="Created" value={value} onChange={setValue} />;
}

/** Converts this block's condition to a FilterBuilder rule; null adds no rule. */
export function dateRangeRule(
  id: string,
  field: string,
  condition: DateRangeCondition | null
): FilterRule | null {
  if (!condition) return null;
  const rule = { kind: 'rule', id, field } as const;
  if (condition.operator !== 'between')
    return { ...rule, ...condition, value: '' };
  return {
    ...rule,
    operator: 'between',
    value: [condition.from, condition.to],
  };
}
blocks/filtering/date-range-filter/date-range-filter.tsx
'use client';

import * as React from 'react';
import type { DateRange } from 'react-day-picker';

import { Button } from '../../../components/button/button';
import { DatePicker } from '../../../components/date-picker/date-picker';
import {
  FilterCondition,
  FilterConditionField,
  FilterConditionRemove,
  FilterConditionSegment,
} from '../../../components/filter-condition/filter-condition';
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from '../../../components/popover/popover';
import { isCalendarDay } from '../../../lib/filter-model';
import { ConditionOperator } from '../filter-operator';
import { useConditionEditor } from '../use-condition-editor';

/** `from` and `to` are calendar days (`YYYY-MM-DD`), so stored and shared values keep the same day in every timezone. */
export type DateRangeCondition =
  | { operator: 'between'; from: string; to: string }
  | { operator: 'isEmpty' | 'isNotEmpty' };
export type DateRangeFilterProps = {
  label: string;
  icon?: React.ReactNode;
  value: DateRangeCondition | null;
  onChange: (value: DateRangeCondition | null) => void;
  disabled?: boolean;
  today?: Date;
};

export function toCalendarDay(date: Date) {
  const month = String(date.getMonth() + 1).padStart(2, '0');
  const day = String(date.getDate()).padStart(2, '0');
  return `${date.getFullYear()}-${month}-${day}`;
}
function fromCalendarDay(day: string) {
  const [year = 0, month = 1, date = 1] = day.split('-').map(Number);
  return new Date(year, month - 1, date);
}
export function formatCalendarDay(day: string) {
  return fromCalendarDay(day).toLocaleDateString();
}
/** A malformed controlled range reads as no range rather than "Invalid Date". */
function toDateRange(
  condition: { from: string; to: string } | null
): DateRange | undefined {
  if (!condition) return undefined;
  if (!isCalendarDay(condition.from) || !isCalendarDay(condition.to))
    return undefined;
  return {
    from: fromCalendarDay(condition.from),
    to: fromCalendarDay(condition.to),
  };
}

export function DateRangeFilter({
  label,
  icon,
  value,
  onChange,
  disabled = false,
  today = new Date(),
}: DateRangeFilterProps) {
  const [draft, setDraft] = React.useState<DateRange | undefined>();
  const {
    applied,
    open,
    pending,
    changeOpen,
    changeOperator,
    commit,
    openPending,
    remove,
    focusAdd,
    operatorRef,
    restoreFocus,
  } = useConditionEditor({ value, onChange, disabled, onOpen: seedDraft });
  const appliedRange = toDateRange(applied);
  const [month, setMonth] = React.useState(appliedRange?.from ?? today);
  const valid = Boolean(draft?.from && draft.to && draft.from <= draft.to);
  const summary =
    applied && appliedRange
      ? `${formatCalendarDay(applied.from)} – ${formatCalendarDay(applied.to)}`
      : 'Choose…';
  function seedDraft(current: { from: string; to: string } | null) {
    const range = toDateRange(current);
    setMonth(range?.from ?? today);
    setDraft(range);
  }
  function preset(days: number) {
    const to = new Date(today.getFullYear(), today.getMonth(), today.getDate());
    const from = new Date(to);
    from.setDate(from.getDate() - days + 1);
    setDraft({ from, to });
    setMonth(from);
  }
  function apply(event: React.FormEvent) {
    event.preventDefault();
    event.stopPropagation();
    if (!valid || !draft?.from || !draft.to) return;
    commit({
      operator: 'between',
      from: toCalendarDay(draft.from),
      to: toCalendarDay(draft.to),
    });
  }
  return (
    <Popover open={open} onOpenChange={changeOpen}>
      {value ? (
        <FilterCondition className="nx:flex-wrap nx:gap-y-1">
          <div className="nx:inline-flex nx:max-w-full nx:min-w-0">
            <FilterConditionField
              className="nx:min-w-0 nx:shrink"
              title={label}
            >
              {icon}
              <span className="nx:truncate">{label}</span>
            </FilterConditionField>
            <ConditionOperator
              label={label}
              triggerRef={operatorRef}
              disabled={disabled}
              value={pending ?? value.operator}
              options={['between', 'isEmpty', 'isNotEmpty']}
              onChange={changeOperator}
              onCloseAutoFocus={openPending}
            />
          </div>
          <div className="nx:inline-flex nx:max-w-full nx:min-w-0 nx:border-s-default nx:border-border-default nx:-ms-(--nx-borderwidth-default)">
            {(applied || pending) && (
              <PopoverTrigger asChild>
                <FilterConditionSegment
                  className="nx:min-w-20"
                  disabled={disabled}
                  aria-label={`Edit ${label}: ${summary}`}
                >
                  {summary}
                </FilterConditionSegment>
              </PopoverTrigger>
            )}
            <FilterConditionRemove
              disabled={disabled}
              aria-label={`Remove ${label} filter`}
              onClick={remove}
            />
          </div>
        </FilterCondition>
      ) : (
        <PopoverTrigger asChild>
          <Button
            ref={focusAdd}
            size="sm"
            className="nx:h-(--nx-spacing-8)"
            variant="outline"
            disabled={disabled}
          >
            Add {label.toLowerCase()} filter
          </Button>
        </PopoverTrigger>
      )}
      <PopoverContent
        align="start"
        aria-label={`Filter by ${label.toLowerCase()}`}
        onCloseAutoFocus={restoreFocus}
        className="nx:w-72 nx:max-w-(--radix-popover-content-available-width) nx:max-h-(--radix-popover-content-available-height) nx:overflow-y-auto nx:p-0"
      >
        <form onSubmit={apply}>
          <div className="nx:grid nx:grid-cols-2 nx:gap-2 nx:border-b nx:border-border-default nx:p-3">
            <Button
              size="sm"
              className="nx:h-(--nx-spacing-8)"
              variant="outline"
              disabled={disabled}
              onClick={() => preset(1)}
            >
              Today
            </Button>
            <Button
              size="sm"
              className="nx:h-(--nx-spacing-8)"
              variant="outline"
              disabled={disabled}
              onClick={() => preset(7)}
            >
              Last 7 days
            </Button>
          </div>
          <div className="nx:flex nx:justify-center nx:overflow-x-auto nx:p-3">
            <DatePicker
              mode="range"
              month={month}
              onMonthChange={setMonth}
              today={today}
              selected={draft}
              onSelect={setDraft}
              disabled={disabled}
            />
          </div>
          <div className="nx:flex nx:items-center nx:justify-between nx:gap-2 nx:border-t nx:border-border-default nx:bg-muted-extralight nx:p-3">
            <Button
              size="sm"
              className="nx:h-(--nx-spacing-8)"
              variant="ghost"
              onClick={() => changeOpen(false)}
            >
              Cancel
            </Button>
            <Button
              type="submit"
              size="sm"
              className="nx:h-(--nx-spacing-8)"
              disabled={!valid || disabled}
            >
              Apply
            </Button>
          </div>
        </form>
      </PopoverContent>
    </Popover>
  );
}
blocks/filtering/filter-operator.tsx
import type * as React from 'react';

import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuTrigger,
} from '../../components/dropdown-menu/dropdown-menu';
import { FilterConditionSegment } from '../../components/filter-condition/filter-condition';
import {
  type FilterOperator,
  filterOperatorLabels,
} from '../../lib/filter-model';

/** Dropdown that picks a condition's operator. Your app controls the operator along with the value. */
export function ConditionOperator<Operator extends FilterOperator>({
  label,
  value,
  options,
  onChange,
  triggerRef,
  disabled = false,
  onCloseAutoFocus,
}: {
  triggerRef?: React.Ref<HTMLButtonElement>;
  disabled?: boolean;
  onCloseAutoFocus?: (event: Event) => void;
  label: string;
  value: Operator;
  options: readonly Operator[];
  onChange: (value: Operator) => void;
}) {
  // Radix also fires for the item that is already selected.
  function select(next: string) {
    const operator = options.find((option) => option === next);
    if (disabled || !operator || operator === value) return;
    onChange(operator);
  }
  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <FilterConditionSegment
          ref={triggerRef}
          disabled={disabled}
          className="nx:min-w-16 nx:text-muted-foreground"
          title={filterOperatorLabels[value]}
          aria-label={`Change ${label} operator`}
        >
          {filterOperatorLabels[value]}
        </FilterConditionSegment>
      </DropdownMenuTrigger>
      <DropdownMenuContent
        align="start"
        aria-label={`${label} operators`}
        onCloseAutoFocus={onCloseAutoFocus}
      >
        <DropdownMenuRadioGroup value={value} onValueChange={select}>
          {options.map((operator) => (
            <DropdownMenuRadioItem
              key={operator}
              value={operator}
              disabled={disabled}
            >
              {filterOperatorLabels[operator]}
            </DropdownMenuRadioItem>
          ))}
        </DropdownMenuRadioGroup>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}
blocks/filtering/use-condition-editor.ts
import * as React from 'react';

import {
  type FilterOperator,
  isValuelessOperator,
  type ValuelessOperator,
} from '../../lib/filter-model';

type ValuelessCondition = { operator: ValuelessOperator };
type AppliedCondition<Condition> = Exclude<Condition, ValuelessCondition>;

function isApplied<Condition extends { operator: FilterOperator }>(
  value: Condition
): value is AppliedCondition<Condition> {
  return !isValuelessOperator(value.operator);
}

/**
 * Editor state every filter block shares. Only a complete condition is ever
 * published: choosing an operator that needs a value waits as `pending` until
 * the editor applies. A controlled replacement closes the editor and drops the
 * pending operator. Removing returns focus to the add button.
 */
export function useConditionEditor<
  Condition extends { operator: FilterOperator },
>({
  value,
  onChange,
  disabled,
  onOpen,
}: {
  value: Condition | null;
  /** Typed to receive the valueless variant, so every block's condition must include it. */
  onChange: (value: NoInfer<Condition> | ValuelessCondition | null) => void;
  disabled: boolean;
  /** Seeds the editor's draft from the applied condition each time it opens. */
  onOpen?: (applied: AppliedCondition<Condition> | null) => void;
}) {
  const applied = value && isApplied(value) ? value : null;
  const [open, setOpen] = React.useState(false);
  const [pending, setPending] = React.useState<
    AppliedCondition<Condition>['operator'] | null
  >(null);
  const addRef = React.useRef<HTMLButtonElement | null>(null);
  const operatorRef = React.useRef<HTMLButtonElement>(null);
  const restoreAdd = React.useRef(false);
  const nextSnapshot = JSON.stringify([value, disabled]);
  const [snapshot, setSnapshot] = React.useState(nextSnapshot);
  if (snapshot !== nextSnapshot) {
    setSnapshot(nextSnapshot);
    setOpen(false);
    setPending(null);
  }

  function changeOpen(next: boolean) {
    if (disabled && next) return;
    if (next) onOpen?.(applied);
    setOpen(next);
    if (!next) setPending(null);
  }
  function commit(next: AppliedCondition<Condition>) {
    if (disabled) return;
    onChange(next);
    setOpen(false);
    setPending(null);
  }
  function changeOperator(next: Condition['operator']) {
    if (disabled) return;
    if (isValuelessOperator(next)) {
      setPending(null);
      onChange({ operator: next });
      return;
    }
    if (applied) {
      onChange({ ...applied, operator: next });
      return;
    }
    setPending(next);
  }
  function openPending(event: Event) {
    if (!pending) return;
    event.preventDefault();
    changeOpen(true);
  }
  function remove() {
    if (disabled) return;
    restoreAdd.current = true;
    setOpen(false);
    setPending(null);
    onChange(null);
  }
  function focusAdd(node: HTMLButtonElement | null) {
    addRef.current = node;
    if (node && restoreAdd.current) {
      restoreAdd.current = false;
      node.focus();
    }
  }
  function restoreFocus(event: Event) {
    if (!value) {
      event.preventDefault();
      addRef.current?.focus();
    } else if (!applied) {
      event.preventDefault();
      operatorRef.current?.focus();
    }
  }

  return {
    applied,
    open: open && !disabled,
    pending,
    changeOpen,
    changeOperator,
    commit,
    openPending,
    remove,
    focusAdd,
    operatorRef,
    restoreFocus,
  };
}
lib/filter-model.ts
/**
 * The meaning every evaluator of a rule follows — the blocks' conditions,
 * FilterBuilder trees and the application's own queries:
 *
 * - Text comparisons (`is`, `isNot`, `contains`, `startsWith`) ignore case.
 * - Choice values (`is`, `isNot`, `isAnyOf`, `isNoneOf` on a choice field)
 *   compare option values exactly.
 * - Numbers compare numerically; dates compare as `YYYY-MM-DD` calendar days.
 * - `between` includes both ends. `greaterThan`, `lessThan`, `before` and
 *   `after` exclude the operand.
 * - `isEmpty` matches a record with no value: missing, an empty string or an
 *   empty list. `isNotEmpty` is its negation.
 * - `isNot` and `isNoneOf` are the negations of `is` and `isAnyOf`, so they
 *   also match records with no value.
 */
export type FilterOperator =
  | 'is'
  | 'isNot'
  | 'contains'
  | 'startsWith'
  | 'greaterThan'
  | 'lessThan'
  | 'between'
  | 'before'
  | 'after'
  | 'isAnyOf'
  | 'isNoneOf'
  | 'isEmpty'
  | 'isNotEmpty';

export interface FilterOption {
  value: string;
  label: string;
}
export type FilterField = {
  id: string;
  label: string;
  operators?: readonly FilterOperator[];
} & (
  | { type: 'text' | 'number' | 'date' }
  | { type: 'choice'; options: readonly FilterOption[] }
);
export type ValuelessOperator = 'isEmpty' | 'isNotEmpty';
type ListOperator = 'isAnyOf' | 'isNoneOf';
type FilterValue<Operator extends FilterOperator> =
  Operator extends ValuelessOperator
    ? ''
    : Operator extends ListOperator
      ? string[]
      : Operator extends 'between'
        ? [string, string]
        : string;
/** Numeric and date operands stay strings, so a rule can hold an unfinished draft. */
export type FilterRule = {
  [Operator in FilterOperator]: {
    kind: 'rule';
    id: string;
    field: string;
    operator: Operator;
    value: FilterValue<Operator>;
  };
}[FilterOperator];
export interface FilterGroup {
  kind: 'group';
  id: string;
  conjunction: 'all' | 'any';
  children: (FilterRule | FilterGroup)[];
}
export type FilterErrorCode =
  | 'duplicateId'
  | 'unknownField'
  | 'unknownOperator'
  | 'unexpectedValue'
  | 'valueShape'
  | 'incompleteRange'
  | 'missingValue'
  | 'invalidNumber'
  | 'invalidDate'
  | 'unknownOption'
  | 'reversedRange'
  | 'tooDeep'
  | 'emptyGroup';
export interface FilterError {
  id: string;
  code: FilterErrorCode;
}

export const filterOperatorLabels: Record<FilterOperator, string> = {
  is: 'is',
  isNot: 'is not',
  contains: 'contains',
  startsWith: 'starts with',
  greaterThan: 'is greater than',
  lessThan: 'is less than',
  between: 'is between',
  before: 'is before',
  after: 'is after',
  isAnyOf: 'is any of',
  isNoneOf: 'is none of',
  isEmpty: 'is empty',
  isNotEmpty: 'is not empty',
};
const operatorsByType: Record<FilterField['type'], readonly FilterOperator[]> =
  {
    text: ['contains', 'is', 'isNot', 'startsWith', 'isEmpty', 'isNotEmpty'],
    number: [
      'is',
      'isNot',
      'greaterThan',
      'lessThan',
      'between',
      'isEmpty',
      'isNotEmpty',
    ],
    date: [
      'is',
      'isNot',
      'before',
      'after',
      'between',
      'isEmpty',
      'isNotEmpty',
    ],
    choice: ['is', 'isNot', 'isAnyOf', 'isNoneOf', 'isEmpty', 'isNotEmpty'],
  };
export function getFilterOperators(
  field: FilterField
): readonly FilterOperator[] {
  const compatible = operatorsByType[field.type];
  return field.operators
    ? compatible.filter((operator) => field.operators?.includes(operator))
    : compatible;
}
export function isValuelessOperator(
  operator: FilterOperator
): operator is ValuelessOperator {
  return operator === 'isEmpty' || operator === 'isNotEmpty';
}
function isListOperator(operator: FilterOperator): operator is ListOperator {
  return operator === 'isAnyOf' || operator === 'isNoneOf';
}
export function emptyFilterRule(
  id: string,
  field: string,
  operator: FilterOperator
): FilterRule {
  const rule = { kind: 'rule', id, field } as const;
  if (isValuelessOperator(operator)) return { ...rule, operator, value: '' };
  if (isListOperator(operator)) return { ...rule, operator, value: [] };
  if (operator === 'between') return { ...rule, operator, value: ['', ''] };
  return { ...rule, operator, value: '' };
}
/** Keeps the value when the new operator takes the same shape of value; otherwise starts empty. */
export function changeFilterOperator(
  rule: FilterRule,
  operator: FilterOperator
): FilterRule {
  const next = emptyFilterRule(rule.id, rule.field, operator);
  if (next.operator === 'isEmpty' || next.operator === 'isNotEmpty')
    return next;
  if (next.operator === 'between')
    return rule.operator === 'between' ? { ...next, value: rule.value } : next;
  if (next.operator === 'isAnyOf' || next.operator === 'isNoneOf')
    return rule.operator === 'isAnyOf' || rule.operator === 'isNoneOf'
      ? { ...next, value: rule.value }
      : next;
  return typeof rule.value === 'string' ? { ...next, value: rule.value } : next;
}
/** Plain decimal notation; `Number()` alone also accepts `0x10` and `0b1`. */
const decimalNumber = /^[+-]?(\d+\.?\d*|\.\d+)(e[+-]?\d+)?$/i;
/** A real `YYYY-MM-DD` calendar day. */
export function isCalendarDay(value: string) {
  if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || value.startsWith('0000'))
    return false;
  const parsed = new Date(`${value}T00:00:00Z`);
  return (
    !Number.isNaN(parsed.getTime()) &&
    parsed.toISOString().slice(0, 10) === value
  );
}
export function getFilterRuleError(
  rule: FilterRule,
  fields: readonly FilterField[]
): FilterErrorCode | undefined {
  const field = fields.find((item) => item.id === rule.field);
  if (!field) return 'unknownField';
  if (!getFilterOperators(field).includes(rule.operator))
    return 'unknownOperator';
  if (isValuelessOperator(rule.operator))
    return rule.value === '' ? undefined : 'unexpectedValue';
  const multiple = isListOperator(rule.operator);
  const range = rule.operator === 'between';
  if ((multiple || range) !== Array.isArray(rule.value)) return 'valueShape';
  const values = Array.isArray(rule.value) ? rule.value : [rule.value];
  if (range && values.length !== 2) return 'incompleteRange';
  if (!values.length || values.some((value) => !value.trim()))
    return 'missingValue';
  if (
    field.type === 'number' &&
    values.some(
      (value) =>
        !decimalNumber.test(value.trim()) || !Number.isFinite(Number(value))
    )
  )
    return 'invalidNumber';
  if (field.type === 'date' && values.some((value) => !isCalendarDay(value)))
    return 'invalidDate';
  if (
    field.type === 'choice' &&
    values.some(
      (value) => !field.options.some((option) => option.value === value)
    )
  )
    return 'unknownOption';
  if (range) {
    const [start = '', end = ''] = values;
    if (field.type === 'number' ? Number(start) > Number(end) : start > end)
      return 'reversedRange';
  }
  return undefined;
}
/** Root depth is zero. An empty root means no filters; empty nested groups are incomplete. */
export function getFilterErrors(
  group: FilterGroup,
  fields: readonly FilterField[],
  maxDepth = 3
): FilterError[] {
  const errors: FilterError[] = [];
  const ids = new Set<string>();
  function visit(node: FilterGroup | FilterRule, depth: number) {
    if (!node.id || ids.has(node.id))
      errors.push({ id: node.id, code: 'duplicateId' });
    ids.add(node.id);
    if (node.kind === 'rule') {
      const code = getFilterRuleError(node, fields);
      if (code) errors.push({ id: node.id, code });
      return;
    }
    if (depth > maxDepth) errors.push({ id: node.id, code: 'tooDeep' });
    if (depth > 0 && !node.children.length)
      errors.push({ id: node.id, code: 'emptyGroup' });
    for (const child of node.children)
      visit(child, child.kind === 'group' ? depth + 1 : depth);
  }
  visit(group, 0);
  return errors;
}