Fluent UI DetailsList custom sorting and filtering can transform how structured data is displayed. While the default DetailsList component is powerful, it doesn’t include built‑in features like advanced sorting, flexible filtering, lazy loading, or selection‑driven filter chips. In this blog, we’ll show you how to extend Fluent UI DetailsList with these enhancements, making it more dynamic, scalable, and user‑friendly.
We’ll also introduce simple, reusable hooks that allow you to implement your own filtering and sorting logic, which will be perfect for scenarios where the default behavior doesn’t quite fit your needs. By the end, you’ll have a flexible, feature-rich Fluent UI DetailsList setup with sorting and filtering that can handle complex data interactions with ease.
Here’s what our wrapper brings to the table:
- Context‑aware column menus that enable sorting beyond simple A↔Z ordering
- Filter interfaces designed for each data type (.i.e. freeform text, choice lists, numeric ranges, or time values)
- Selection chips that display active filters and allow quick deselection with a single click
- Lazy loading with infinite scroll, seamlessly integrated with your API or pagination pipeline
- One orchestrating component that ties all these features together, eliminating repetitive boilerplate
Core Architecture
The wrapper includes:
- Column Definitions: To control how each column sorts/filters
- State & Refs: To manage final items, full dataset, and UI flags
- Default Logic By overriding hooks – onSort, onFilter
- Selection: Powered by Fluent UI Selection API
- Lazy Loading: Using IntersectionObserver
- Filter Chips: Reflect selected rows
Following are the steps to achieve these features:
Step 1: Define Column Metadata
Each column in the DetailsList must explicitly describe its data type, sort behavior, and filtering behavior. This metadata helps the wrapper render the correct UI elements such as combo boxes, number inputs, or time pickers.
Each column needs metadata describing:
- Field type
- Sort behavior
- Filter behavior
- UI options (choice lists, icons, etc.)
export interface IDetailsListColumnDefinition {
fieldName: string;
displayName: string;
columnType?: DetailsListColumnType; // Text, Date, Time, etc.
sortDetails?: { fieldType: SortFilterType };
filterDetails?: {
fieldType: SortFilterType;
filterOptions?: IComboBoxOption[];
appliedFilters?: any[];
};
}
Following is the example:
const columns = [{
fieldName: 'status',
displayName: 'Status',
columnType: DetailsListColumnType.Text,
sortDetails: {
fieldType: SortFilterType.Choice
},
filterDetails: {
fieldType: SortFilterType.Choice,
filterOptions: [{
key: 'Active',
text: 'Active'
},
{
key: 'Inactive',
text: 'Inactive'
}]
}
}];
Step 2: Implement Type-Aware Fluent UI DetailsList Custom Sorting
The sorting mechanism dynamically switches based on the column’s data type. Time fields are converted to minutes to ensure consistent sorting, while text and number fields use their native values. It supports following:
- Supports Text, Number, NumberRange, Date, and Time (custom handling for time via minute conversion).
- Sort direction is controlled from the column’s context menu.
- Works with default sorting or lets you inject custom sorting via onSort.
- Default sorting uses lodash orderBy unless onSort is provided
Sample code for its implementation can be written as follows:
switch (sortColumnType) {
case SortFilterType.Time:
sortedItems = orderBy(sortedItems, [item = >getTimeForField(item, column.key)], column.isSortedDescending ? ['desc'] : ['asc']);
break;
default:
sortedItems = orderBy(sortedItems, column.fieldName, column.isSortedDescending ? 'desc': 'asc');
}
Step 3: Implement Fluent UI DetailsList Custom Filtering (Text/Choice/Range/Time)
Filtering inputs change automatically based on column type. Text and choice filters use combo boxes, while numeric fields use range inputs. Time filters extract and compare HH:mm formatted values.
Text & Choice Filters
Implemented using Fluent UI ComboBox as follows:
<ComboBox
allowFreeform={!isChoiceField}
multiSelect={true}
options={comboboxOptions}
onChange={(e, option, index, value) =>
_handleFilterDropdownChange(e, column, option, index, value)
}
/>
Number Range Filter
Implemented as two input boxes, min & max for defining number range.
- Min/Max chips are normalized in order [min, max].
- Only applied if present; absence of either acts as open‑ended range.
Time Filter
For filtering time, we are ignoring date part and just considering time part.
- Times are converted to minutes since midnight(HH:mm) to sort reliably regardless of display format.
- Filtering uses date-fns format() for display and matching.
Step 4: Build the Filtering Pipeline
This step handles the filtering logic as capturing user-selected values, updating filter states, re-filtering all items, and finally applying the active sorting order. If custom filter logic is provided, it overrides the defaults. It will work as follows:
- User changes filter
- Update column.filterDetails.appliedFilters
- Call onFilter (if provided)
- Otherwise run default filter pipeline as follows:
allItems → apply filter(s) → apply current sort → update UI
Following are some helper functions that can be created for handing filter/sort logic:
- _filterItems
- _applyDefaultFilter
- _applyDefaultSort
Step 5: Display Filter Chips
When selection is enabled, each selected row appears as a dismissible chip above the grid. Removing the chip automatically deselects the row, ensuring tight synchronization between UI and data.
<FilterChip key={filterValue.key} filterValue={filterValue} onRemove={_handleChipRemove} />
Note: This is a custom subcomponent used to handle filter chips. Internally it display selected values in chip form and we can control its values and functioning using onRemove and filterValue props.
Chip removal:
- Unselects row programmatically
- Updates the selection object
Step 6: Implementing Lazy Loading (IntersectionObserver)
The component makes use of IntersectionObserver, to detect if the user reaches the end of the list. Once triggered, it calls the lazy loading callback to fetch the next batch of items from the server or state.
- An additional row at the bottom triggers onLazyLoadTriggered() as it enters the viewport.
- Displays a spinner while loading; attaches the observer when more data is available.
A sentinel div at the bottom triggers loading:
observer.current = new IntersectionObserver(async entries => {
const entry = entries[0];
if (entry.isIntersecting) {
observer.current ? .unobserve(lazyLoadRef.current!);
await lazyLoadDetails.onLazyLoadTriggered();
}
});
Props controlling behavior:
lazyLoadDetails ? :{
enableLazyLoad: boolean;
onLazyLoadTriggered: () => void;
isLoading: boolean;
moreItems: boolean;
};
Step 7: Sticky Headers
Sticky headers keep the column titles visible as the user scrolls through large datasets, improving readability and usability. Following is the code where, maxHeight property determines the scrollable container height:
const stickyHeaderStyle = {
root: {
maxHeight: stickyHeaderDetails ? .maxHeight ? ?450
},
headerWrapper: {
position: 'sticky',
top: 0,
zIndex: 1
}
};
Step 8: Putting It All Together — Minimal Example for Fluent UI DetailsList custom filtering and sorting
Following is an example where we are calling our customizes details list component:
<CustomDetailsList
columnDefinitions={columns}
items={data}
allItems={data}
checkboxVisible={CheckboxVisibility.always}
initialSort={{ fieldName: "name", direction: SortDirection.Asc }}
filterChipDetails={{
filterChipKeyColumnName: "key",
filterChipColumnName: "name",
}}
stickyHeaderDetails={{ enableStickyHeader: true, maxHeight: 520 }}
lazyLoadDetails={{
enableLazyLoad: true,
isLoading: false,
moreItems: true,
onLazyLoadTriggered: async () => {
// load more
},
}}
/>;
Accessibility & UX Notes
- Keyboard: Enter key applies text/number inputs instantly; menu remains open so users can stack filters.
- Clear filter: Context menu shows “Clear filter” action only when a filter exists; there’s also a “Clear Filters (n)” button above the grid that resets all columns at once.
- Selection cap: To begin, maxSelectionCount helps prevent accidental bulk selections; next, it provides immediate visual feedback so users can clearly see their limits in action.
Performance Guidelines
- Virtualization: For very large datasets, you can enable virtualization and validate both menu positioning and performance. For current example, onShouldVirtualize={() => false} is used to maintain a predictable menu experience.
- Server‑side filtering/sorting: If your dataset is huge, pass onSort/onFilter and do the heavy lifting server‑side, then feed the component the updated page through items.
- Lazy loading: Use moreItems to hide the sentinel when the server reports the last page; set isLoading to true to show the spinner row.
Conclusion
Finally, we have created a fully customized Fluent UI DetailsList with custom filtering and sorting which condenses real‑world list interactions into one drop‑in component. CustomDetailsList provides a production-ready, extensible, developer-friendly data grid wrapper with following enhanced features:
- Clean context menus for type‑aware sort & filter
- Offers selection chips for quick, visual interaction and control
- Supports lazy loading that integrates seamlessly with your API
- Allows you to keep headers sticky to maintain clarity in long lists
- Delivers a ready‑to‑use design while allowing full customization when needed
GitHub repository
Please refer to the GitHub repository below for the full code. A sample has been provided within to illustrate its usage:
https://github.com/pk-tech-dev/customdetailslist
