DEV Community

Alexandro Paixao Marques
Alexandro Paixao Marques

Posted on • Edited on

React Multiselect Dropdown 19: controlled state, skins, modals, and accessibility

Modern React applications rarely need just a dropdown.

In real projects, a multiselect dropdown usually sits inside something more important: filters, dashboards, reporting tools, permission editors, workflow screens, admin panels, and data-heavy forms where users need to search, group, select, clear, validate, and keep state consistent. It is almost never the feature. It is the control that everything else in the form depends on.

That is the context behind Stackline React Multiselect Dropdown, maintained for React 19.

This is the React counterpart to the Stackline Angular line, and it is meant to behave like one. The two packages share the same combobox and ARIA contract, the same skin system, the same headless philosophy, the same per-major versioning, and the same live-example model. If your organization runs both Angular and React, the goal is that a multiselect feels and behaves the same way in both stacks, instead of being two unrelated controls that happen to share a name. In fact, the combobox contract was first defined and validated in this React line, then carried into the Angular line — so on the React side you are looking at the reference implementation.

React 19 is a good target for that. It is the version where React moved decisively toward explicit async state and less boilerplate: Actions and the new form hooks, the use hook for reading promises and context during render, ref as a plain prop instead of forwardRef, React Server Components as a production pattern, and the React Compiler taking over manual memoization. A maintained, accessibility-focused selection component should slot cleanly into that world without fighting it.

This release of the library is not just about making a dropdown render on the latest React. The goal is a practical component that can be adopted in real applications, exercised through live examples, and customized at whatever depth a team needs — from a drop-in styled component to a fully headless hook where the application owns every element and every line of CSS.

Install the React 19 line with:

npm install @stackline/react-multiselect-dropdown
Enter fullscreen mode Exit fullscreen mode

Links:

npm:
https://www.npmjs.com/package/@stackline/react-multiselect-dropdown

Documentation (React 19):
https://alexandro.net/docs/react/multiselect/react-19/#/basic

Live demo / interactive playground (StackBlitz):
https://stackblitz.com/github/alexandroit/stackline-react-multiselect-react-19?file=src%2Fexamples%2Fbasic%2Fbasic.component.tsx&startScript=start&initialpath=%2Fbasic

GitHub:
https://github.com/alexandroit/react-multiselect-dropdown

For new releases, documentation updates, and the Angular counterpart, visit:
https://alexandro.net

What the library provides

Stackline React Multiselect Dropdown is a React multiselect component designed for real application forms, dashboards, filters, and enterprise interfaces.

It supports:

  • single-select and multi-select modes
  • controlled and uncontrolled selection
  • search and filtering
  • select all and clear all behavior
  • grouped options
  • custom item and badge render functions
  • a guided Slots API for replacing component structure without losing behavior
  • headless hooks (useMultiSelectDropdown, useMultiSelectState) for fully custom interfaces
  • a type-safe factory (createMultiSelectDropdown<T>()) to bind the item type once
  • imperative ref methods (open, close, focus, select all, clear)
  • lazy loading hooks and dynamic data
  • five built-in skins (classic, material, dark, custom, brand)
  • accessibility-focused keyboard navigation, focus handling, and a documented combobox/ARIA contract
  • body-overlay positioning for dialogs and clipped containers
  • versioned documentation per React major

A multiselect component can look simple from the outside, but once it is used across several forms and workflows, replacing it becomes expensive. It affects state shape, validation, events, styling, selected state, reset behavior, focus and keyboard behavior, and sometimes even backend query logic. The cost is rarely in the first screen. It is in the twentieth screen that quietly depends on the behavior of the first.

That is why a predictable API, accessibility, customization depth, and a clear versioning story matter more than a long feature list.

Why this package exists

There are many dropdowns and select components available for React, but the hard part in real applications is rarely the first implementation.

The hard part is keeping the component stable while React moves forward, while forms evolve, while UI layouts become more complex, while accessibility gets audited, and while a design system needs to take over rendering without rebuilding selection logic from scratch.

React 19 makes that especially relevant. The framework is pushing toward Server Components, form Actions, and the React Compiler, which changes how data arrives and how much manual wiring a component needs. A selection control should be able to live inside that world: take its data from a Server Component or an Action, run as a client component for the parts that genuinely need the browser, and stay out of the way of the compiler.

This package focuses on a practical set of goals:

  • keep React 19 compatibility clear and validated, not optimistic
  • keep a controlled, predictable API: data in, selectedItems bound, updates through onChange
  • offer escalating layers of control, so a team only owns as much markup as it actually needs
  • ship a documented keyboard and ARIA contract instead of leaving accessibility as an afterthought
  • provide working examples that can be tested in the browser before installing
  • keep the same behavior contract as the Angular line, so multi-stack teams get consistency

The intention is not to reinvent how selection works. The intention is to make a commonly needed UI component easy to adopt at any level of customization, and to keep it behaving the same way across React versions and across the Angular counterpart.

React 19 in context

It helps to be explicit about what React 19 changes, because it shapes how any selection component should behave in a 2026 codebase.

Area Before React 19 React 19
Async/form state useState + useTransition + manual pending/error handling Actions, with useActionState, useFormStatus, and useOptimistic
Reading async data and context useEffect + state; context only via useContext use() reads promises and context during render, and can be called conditionally
Refs on function components forwardRef wrapper ref as a regular prop; forwardRef no longer required
Server rendering SSR only React Server Components as a production pattern, with a "use client" boundary
Memoization manual useMemo / useCallback / memo React Compiler inserts memoization automatically
Document head third-party helmet libraries native metadata hoisting for <title>, <meta>, <link>
Types @types/react@18 @types/react@19

There is a clear parallel with where Angular went in the same period. Angular's "signal-first" direction (signals, zoneless change detection, Signal Forms) and React's Actions, use(), and Compiler are different mechanisms aiming at the same outcome: more explicit reactivity, less boilerplate, and rendering that is easier to reason about. That convergence is exactly why a single shared behavior contract across both stacks is worth maintaining.

Where does a styled, controlled multiselect fit in React 19? It is, by nature, a client component. Selection, keyboard handling, focus management, and ARIA state all need the browser, so the component runs under "use client", and that is correct rather than a limitation. The boundary stays clean: a Server Component or an Action can fetch the option list, and the multiselect owns the interaction. For teams that want to push further — fit an RSC-driven design system, drop the bundled CSS, or own every element — the headless hooks provide the selection, filtering, grouping, and ARIA logic without any DOM or CSS, which is the React equivalent of a renderless control.

Installation

For React 19 projects, install the validated 19.x line with an exact version:

npm install @stackline/react-multiselect-dropdown@19.1.1 --save-exact
Enter fullscreen mode Exit fullscreen mode

Pinning with --save-exact keeps compatibility predictable across local environments, CI pipelines, and production builds. The styled component ships its own styles and injects them at runtime; the headless hooks inject no CSS and let your application own the markup and styling.

Unlike the Angular 22 line, which deliberately loosens its peer range to be install-ready for the next framework major, the React 19 line keeps a strict peer range of >=19.0.0 <20.0.0. React's release cadence is slower and less calendar-driven than Angular's, so there is no fixed next-major date to prepare for; bounding the package to the tested React major is the more honest default here. The package was validated against React 19.2.4.

Setup

There is no module to register. React setup is three small steps.

1. Import the component

import { MultiSelectDropdown } from '@stackline/react-multiselect-dropdown';
import type {
  DropdownSettings,
  MultiSelectDropdownHandle
} from '@stackline/react-multiselect-dropdown';
Enter fullscreen mode Exit fullscreen mode

2. Keep selection in React state

const [selectedCountries, setSelectedCountries] = useState<Country[]>([]);
Enter fullscreen mode Exit fullscreen mode

3. Pass a stable settings object

const settings = useMemo<DropdownSettings<Country>>(
  () => ({
    text: 'Select countries',
    enableSearchFilter: true,
    primaryKey: 'id',
    labelKey: 'itemName',
    badgeShowLimit: 3,
    skin: 'classic'
  }),
  []
);
Enter fullscreen mode Exit fullscreen mode

Wrapping the settings object in useMemo keeps its identity stable across renders, which avoids unnecessary work in the component. This is the React analogue of the Angular line's single settings object: most behavior is configured in one place rather than spread across props.

Basic usage

A typical implementation starts with a list of items, selected state, and a settings object.

import { useState, useMemo } from 'react';
import {
  MultiSelectDropdown,
  type DropdownSettings
} from '@stackline/react-multiselect-dropdown';

type Country = {
  id: number;
  itemName: string;
  capital: string;
  region: string;
};

const countries: Country[] = [
  { id: 1, itemName: 'Brazil', capital: 'Brasilia', region: 'South America' },
  { id: 2, itemName: 'Canada', capital: 'Ottawa', region: 'North America' },
  { id: 3, itemName: 'Portugal', capital: 'Lisbon', region: 'Europe' },
  { id: 4, itemName: 'United States', capital: 'Washington, DC', region: 'North America' },
  { id: 5, itemName: 'Argentina', capital: 'Buenos Aires', region: 'South America' },
  { id: 6, itemName: 'Mexico', capital: 'Mexico City', region: 'North America' }
];

export function CountrySelector() {
  const [selectedCountries, setSelectedCountries] = useState<Country[]>([countries[1]]);

  const settings = useMemo<DropdownSettings<Country>>(
    () => ({
      singleSelection: false,
      text: 'Select countries',
      selectAllText: 'Select all',
      unSelectAllText: 'Clear all',
      enableSearchFilter: true,
      searchPlaceholderText: 'Search',
      primaryKey: 'id',
      labelKey: 'itemName',
      badgeShowLimit: 4,
      maxHeight: 260,
      showCheckbox: true,
      noDataLabel: 'No data',
      skin: 'classic',
      appendToBody: false
    }),
    []
  );

  return (
    <MultiSelectDropdown
      data={countries}
      selectedItems={selectedCountries}
      onChange={setSelectedCountries}
      settings={settings}
      onSelect={(item) => console.log('selected', item)}
      onDeSelect={(item) => console.log('removed', item)}
      onSelectAll={(items) => console.log('selected all', items)}
      onDeSelectAll={(items) => console.log('cleared all', items)}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

The controlled contract is the core idea: you hold the selected array, the component renders from it, and every change comes back through onChange. Note the skin key: the React line selects styling through skin. The older theme key still works as a legacy alias, but new code should use skin.

Customization paths

This is the part where React idioms shine, and it is the React equivalent of "use the right amount of control." Instead of one component with escape hatches, the package exposes escalating layers so a team owns only as much markup as it actually needs.

Layer Best for What you own
<MultiSelectDropdown /> Fast forms, filters, dashboards, admin screens Data, selected state, settings, events, optional render functions
createMultiSelectDropdown<T>() Binding the item type once for a feature or design-system wrapper A typed component family, typed settings, typed slots, typed hooks
slots Custom HTML around proven component behavior Specific DOM pieces (trigger, badges, menu, search, groups, options, footer)
useMultiSelectDropdown Fully custom interfaces and design systems All markup and CSS, while the package provides state, ARIA prop getters, keyboard flow, grouping, callbacks
useMultiSelectState Advanced state engines or an existing combobox shell Every element, every event binding, all visual behavior

For most teams, start with the component. Reach for slots when the component works but your layout needs a different shell. Reach for the headless hooks when the application must own the whole combobox contract. The important point is that selection, filtering, keyboard, focus, ARIA, body-overlay positioning, and async behavior stay handled by the package no matter which layer you choose.

Type-safe factory

Use createMultiSelectDropdown<T>() when a feature, package, or design-system wrapper should bind the item type once and reuse it across the component, settings, slots, and hooks. This is optional; the plain <MultiSelectDropdown /> API remains the fastest path for most screens.

import { useState } from 'react';
import { createMultiSelectDropdown } from '@stackline/react-multiselect-dropdown';

type Country = {
  id: number;
  itemName: string;
  capital: string;
  region: string;
};

const CountryMultiselect = createMultiSelectDropdown<Country>();

const countrySettings = CountryMultiselect.defineSettings({
  text: 'Choose countries',
  primaryKey: 'id',
  labelKey: 'itemName',
  searchBy: ['itemName', 'capital'],
  groupBy: 'region',
  enableSearchFilter: true,
  badgeShowLimit: 2
});

const countrySlots = CountryMultiselect.defineSlots({
  Option: ({ props, option, checkbox }) => (
    <div {...props}>
      {checkbox}
      <strong>{option.item.itemName}</strong>
      <small>{option.item.capital} - {option.item.region}</small>
    </div>
  )
});

export function CountryFilter({ countries }: { countries: Country[] }) {
  const [selectedCountries, setSelectedCountries] = useState<Country[]>([]);

  return (
    <CountryMultiselect.Dropdown
      data={countries}
      selectedItems={selectedCountries}
      onChange={setSelectedCountries}
      settings={countrySettings}
      slots={countrySlots}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

With the factory, primaryKey, labelKey, searchBy, and groupBy are checked against keyof Country. Typos such as labelKey: 'itemname' or searchBy: ['city'] fail at compile time instead of silently producing an empty search.

Settings-driven behavior

Most behavior is configured through the settings object instead of a long list of props.

Common settings include singleSelection, enableSearchFilter, searchBy, searchPlaceholderText, maxHeight, badgeShowLimit, limitSelection, disabled, groupBy, selectGroup, showCheckbox, noDataLabel, lazyLoading, labelKey, primaryKey, addNewItemOnFilter, clearAll, keyboard, autoPosition, position, skin (legacy alias: theme), appendToBody, and tagToBody.

Single selection without checkboxes

const settings: DropdownSettings<Country> = {
  singleSelection: true,
  showCheckbox: false,
  enableCheckAll: false,
  text: 'Select one country'
};
Enter fullscreen mode Exit fullscreen mode

Selection limit

const settings: DropdownSettings<Country> = {
  singleSelection: false,
  limitSelection: 2,
  badgeShowLimit: 2,
  text: 'Select up to 2 countries'
};
Enter fullscreen mode Exit fullscreen mode

Grouped data

const settings: DropdownSettings<Country> = {
  groupBy: 'region',
  selectGroup: true,
  enableSearchFilter: true,
  text: 'Select countries by region'
};
Enter fullscreen mode Exit fullscreen mode

A single settings object is easy to standardize and share across an application. The most common cause of "the dropdown behaves slightly differently on this screen" is ten slightly different settings objects scattered across ten components; one shared object removes that drift.

Search and filtering

Search becomes important as soon as datasets grow.

enableSearchFilter turns search on, and searchBy restricts filtering to specific fields:

const settings: DropdownSettings<Country> = {
  enableSearchFilter: true,
  searchPlaceholderText: 'Search countries',
  searchBy: ['itemName', 'capital']
};
Enter fullscreen mode Exit fullscreen mode

This gives two useful modes: broad search across item properties, or controlled search over selected fields. Controlled search matters when items carry internal IDs, flags, or hidden metadata that should not match user input — a user typing "12" should not silently match an internal id of 12 unless you decided that on purpose.

Keyboard and accessibility — the combobox contract

This is the part of the React line that deserves the most attention, and it is the reference contract that the Angular line adopted.

Accessibility in a multiselect is a behavior contract, not a single attribute: what the keyboard does on the trigger versus inside the list, how focus moves after a selection, how selected chips are announced, and what screen readers report for each option. Version 19.1.1 tightens the details that usually break first in production forms.

Behavior Contract
Focus after selection or removal Focus returns to search while the list stays open, or to the trigger when the list closes
Option selection state Options expose matching aria-selected and aria-checked values
Backspace in search Edits the query; with an empty query it does not remove selected values by default
Backspace/Delete on a focused badge remove button Removes that selected badge
Escape Closes the list without clearing selected values
Async add-item Late async responses are ignored when a newer add request has already resolved
Async option refresh Selected objects are merged back into the option source, so values are not lost when data refreshes
Keyboard navigation Trigger ArrowDown/ArrowUp, option Home/End, and stable option ids keep focus and ARIA predictable

Keyboard behavior is enabled by default and configurable through settings.keyboard when an application needs a stricter model:

const settings: DropdownSettings<Country> = {
  text: 'Countries',
  enableSearchFilter: true,
  keyboard: {
    space: true,
    spaceOptionAction: 'toggle',
    tab: true,
    arrows: true,
    escape: true,
    backspaceRemovesLastWhenSearchEmpty: false,
    deleteRemovesFocusedBadge: true
  }
};
Enter fullscreen mode Exit fullscreen mode

Set any key to false to disable that behavior. backspaceRemovesLastWhenSearchEmpty can be turned on for apps that want the legacy "empty search removes last badge" pattern (keyboard.backspace is still accepted as a deprecated alias). spaceOptionAction controls focused options: 'toggle' keeps focus on the current option, while 'toggle-and-next' toggles and advances to the next enabled option. escapeToClose: false is still supported and also disables keyboard.escape.

The edge cases that usually break accessibility — space inside search, tab stealing a selection, escape wiping a form field, a remote refresh dropping the user's selection — were handled deliberately. Because this is the same contract the Angular line follows, the keyboard and screen-reader behavior matches across both stacks.

Custom rendering and the Slots API

There are two levels of visual customization, and they map cleanly to two needs.

Use renderItem and renderBadge when you only need to replace the inner content of an option row or a selected chip:

<MultiSelectDropdown
  data={countries}
  selectedItems={selectedCountries}
  onChange={setSelectedCountries}
  settings={settings}
  renderItem={(item, context) => (
    <span>
      <strong>{context.label}</strong>
      <small>{item.capital}</small>
    </span>
  )}
  renderBadge={(item) => <span>{item.itemName}</span>}
/>
Enter fullscreen mode Exit fullscreen mode

Use the Slots API when you need to replace component structure — the trigger, menu, search shell, option row, badge, footer, and more. The rule is simple: spread the supplied props onto the element that plays that role. Those props carry the required refs, ARIA attributes, keyboard handlers, click handlers, ids, classes, and positioning styles, so you change the markup without losing the tested behavior.

import {
  MultiSelectDropdown,
  type MultiSelectDropdownSlots
} from '@stackline/react-multiselect-dropdown';

const countrySlots: MultiSelectDropdownSlots<Country> = {
  Trigger: ({ props, children, state }) => (
    <div {...props} className={`${props.className} country-trigger`}>
      <span>
        {state.selectedItems.length ? `${state.selectedItems.length} selected` : 'Choose countries'}
      </span>
      {children}
    </div>
  ),
  Badge: ({ props, item, removeButton }) => (
    <span {...props} className={`${props.className} country-badge`}>
      <strong>{item.itemName}</strong>
      {removeButton}
    </span>
  ),
  Option: ({ props, option, checkbox }) => (
    <div {...props} className={`${props.className} country-option`}>
      {checkbox}
      <span>
        <strong>{option.label}</strong>
        <small>{option.item.capital} - {option.item.region}</small>
      </span>
    </div>
  ),
  MenuFooter: ({ props, state }) => (
    <div {...props}>{state.filteredItems.length} visible options</div>
  )
};
Enter fullscreen mode Exit fullscreen mode

The full set of slots covers Root, Trigger, Value, Placeholder, SingleValue, BadgeList, Badge, BadgeLabel, BadgeRemove, Actions, OverflowCounter, ClearAll, Arrow, Menu, Toolbar, BulkActions, SelectAll, AddNewItem, Search, OptionList, Group, GroupHeader, GroupAction, Option, Checkbox, LoadingState, EmptyState, and MenuFooter. This is the React parallel to the Angular line's custom item and badge templates, taken further: in React you can replace almost any structural piece, not just the option and chip.

Headless usage

For the cases where slots are not enough, the package ships headless hooks. This is the React equivalent of the Angular renderless state helper: you write all of the HTML and CSS, and the package gives you state, ARIA prop getters, keyboard flow, grouping, limits, and callbacks.

useMultiSelectDropdown returns prop getters and state for a fully custom interface:

import { useState } from 'react';
import { useMultiSelectDropdown } from '@stackline/react-multiselect-dropdown';

export function HeadlessCountries() {
  const [selectedItems, setSelectedItems] = useState([countries[0]]);

  const dropdown = useMultiSelectDropdown({
    data: countries,
    selectedItems,
    onChange: setSelectedItems,
    settings: {
      text: 'Choose countries',
      enableSearchFilter: true,
      searchPlaceholderText: 'Search country',
      groupBy: 'region',
      primaryKey: 'id',
      labelKey: 'itemName',
      badgeShowLimit: 2,
      clearAll: true
    }
  });

  return (
    <div className="country-picker" {...dropdown.getRootProps()}>
      <button className="country-trigger" {...dropdown.getTriggerProps()}>
        <span>{dropdown.label}</span>
        <strong>{dropdown.isOpen ? 'Close' : 'Open'}</strong>
      </button>

      <div className="country-chips">
        {dropdown.selectedItems.map((item) => (
          <span className="country-chip" key={dropdown.getItemKey(item)}>
            {dropdown.getItemLabel(item)}
            <button {...dropdown.getRemoveButtonProps(item)}>x</button>
          </span>
        ))}
      </div>

      {dropdown.isOpen ? (
        <div className="country-panel" {...dropdown.getListboxProps()}>
          <input className="country-search" {...dropdown.getSearchInputProps()} />
          {dropdown.visibleOptions.map((option) => (
            <div
              key={option.key}
              {...dropdown.getOptionProps(option, {
                className: option.selected ? 'country-option selected' : 'country-option'
              })}>
              <strong>{option.label}</strong>
              <input type="checkbox" checked={option.selected} readOnly />
            </div>
          ))}
        </div>
      ) : null}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

When you want the selection, filtering, and grouping engine without prop getters, use useMultiSelectState:

import { useMultiSelectState } from '@stackline/react-multiselect-dropdown';

const state = useMultiSelectState({
  data: countries,
  selectedItems,
  onChange: setSelectedItems,
  settings: {
    primaryKey: 'id',
    labelKey: 'itemName',
    enableSearchFilter: true
  }
});
Enter fullscreen mode Exit fullscreen mode

The styled component is still available for drop-in usage. The hooks are for teams that want a headless ownership model, including design systems that need to fit React Server Component patterns or drop the bundled CSS entirely.

Forms and controlled state

React does not have ngModel or formControlName; a multiselect participates in a form through controlled state. Keep the selected array in state and derive validity from it:

const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [selectedSkills, setSelectedSkills] = useState<Skill[]>([]);

const formIsValid = email.trim().length > 0 && selectedSkills.length > 0;

return (
  <form onSubmit={(event) => event.preventDefault()}>
    <input value={name} onChange={(event) => setName(event.target.value)} />
    <input value={email} onChange={(event) => setEmail(event.target.value)} />

    <MultiSelectDropdown
      data={skills}
      selectedItems={selectedSkills}
      onChange={setSelectedSkills}
      settings={skillSettings}
    />

    <button type="submit" disabled={!formIsValid}>Submit</button>
  </form>
);
Enter fullscreen mode Exit fullscreen mode

A note on React 19 specifically: the framework's Actions and form hooks (useActionState, useFormStatus, useOptimistic) are now the recommended way to handle async submission. The selected array is plain React state, so it composes naturally with all of them — read selectedItems inside an Action, derive an optimistic update from it, or gate a submit button with useFormStatus. The component does not impose its own form abstraction, which keeps it compatible with whatever submission pattern a screen uses. The docs include both a "template-style forms" example and a "reactive forms" example, named to mirror the Angular line but implemented as ordinary React controlled state.

Events

The package exposes a full set of callbacks for selection workflows:

  • onChange
  • onSelect
  • onDeSelect
  • onSelectAll
  • onDeSelectAll
  • onOpen
  • onClose
  • onScrollToEnd
  • onAddFilterNewItem
  • onGroupSelect
  • onGroupDeSelect

Selection events are useful when a change needs to trigger downstream behavior — refreshing a report, loading dependent filters, updating query parameters, sending analytics, enabling related controls, or validating dependent fields. The event names are kept stable across versions, so upgrading the React major does not silently break the handlers you already wrote.

Ref methods

For imperative control, the component exposes a typed handle through ref. In React 19 this works without forwardRefref is a normal prop on function components.

const dropdownRef = useRef<MultiSelectDropdownHandle<Country>>(null);

dropdownRef.current?.openDropdown();
dropdownRef.current?.closeDropdown();
dropdownRef.current?.focusSearch();
dropdownRef.current?.selectAll();
dropdownRef.current?.deSelectAll();
dropdownRef.current?.clearSelection();
Enter fullscreen mode Exit fullscreen mode

This is handy for wizard-style flows, "clear filters" buttons elsewhere on the page, or opening the dropdown in response to an external event.

Lazy loading and dynamic data

Large datasets are where simple dropdowns start failing. Enable lazy loading through settings and append more rows when the list reaches the end:

const settings: DropdownSettings<Person> = {
  text: 'Select people',
  enableSearchFilter: true,
  lazyLoading: true,
  labelKey: 'name',
  primaryKey: 'id',
  maxHeight: 140
};

<MultiSelectDropdown
  data={people}
  selectedItems={selectedPeople}
  onChange={setSelectedPeople}
  settings={settings}
  onScrollToEnd={() => setPeople((current) => current.concat(loadMorePeople()))}
/>
Enter fullscreen mode Exit fullscreen mode

This matters for users, customers, products, permissions, locations, departments, catalogs, tags, and reporting dimensions. The docs also include a long-list keyboard-scroll example for large in-memory lists, alongside lazy loading and remote-data examples.

One detail worth calling out for async data: selected objects are preserved across asynchronous refreshes. When a remote call replaces the option list, previously selected items are merged back into the source instead of being silently dropped because the array reference changed. That is exactly the kind of bug that is invisible in a small demo and painful in production, and it is part of the shared contract with the Angular line.

Dialogs and overflow containers

A dropdown that works on a simple page can behave differently inside dialogs, modals, drawers, dashboard cards, table filters, and scrollable containers.

Use appendToBody: true or tagToBody: true when the dropdown sits inside a container that sets overflow: hidden or overflow: auto:

const settings: DropdownSettings<Country> = {
  text: 'Dialog dropdown',
  enableSearchFilter: true,
  skin: 'material',
  appendToBody: true,
  tagToBody: true,
  autoPosition: true
};
Enter fullscreen mode Exit fullscreen mode

With body overlay enabled, the open panel renders against document.body, stays aligned to the original trigger, is sized to the trigger, recalculates on open, scroll, resize, and content changes, and is cleaned up when the dropdown closes or the component unmounts. autoPosition: true treats position as a preferred direction: the menu opens upward only when there is meaningfully less room below and enough room above; otherwise it opens below and shrinks the scrollable list height to stay visible without covering the trigger.

Theming and skins

Styling is selected through settings.skin. The React line ships five built-in skins, which is a superset of the Angular line's classic and material.

Skin Usage
classic Compact classic dropdown styling
material Material-style rounded controls and chips
dark Dark UI surfaces
custom CSS-variable starter skin for custom projects
brand Stackline brand skin

Switching skins is just a state change:

setSettings((current) => ({ ...current, skin: 'material' }));
Enter fullscreen mode Exit fullscreen mode

settings.theme is accepted as a legacy alias so existing code keeps rendering, but new usage should configure skin. The styled component injects its CSS at runtime; if you want full control, the custom skin is a CSS-variable starting point, and the headless hooks let you drop the bundled CSS entirely and style your own markup.

Live functional examples and StackBlitz playground

The React 19 documentation includes real interactive examples running in a live React environment, and the editable playground is a dedicated React 19 StackBlitz tied to the maintained repository, so it stays in sync with the latest source instead of spawning stale forks per example.

The examples cover, among others:

  1. basic usage
  2. keyboard contract
  3. ARIA state audit
  4. headless + ARIA
  5. state hook
  6. Slots API
  7. type-safe factory
  8. async object preservation
  9. single selection
  10. search filter and search by property
  11. custom search from an API
  12. search and add a new item
  13. grouped options
  14. custom rendering (templating)
  15. template-style and reactive forms
  16. long-list keyboard scroll
  17. lazy loading from an API and remote data
  18. using the control in a list / loop
  19. using the dropdown inside a dialog
  20. multiple dropdowns on one page
  21. dynamic data loading
  22. methods and events
  23. disabled mode, selection limits, badge limits
  24. custom placeholder and styling
  25. body-overlay auto direction

These examples are not just static documentation. They let developers check the behavior directly in the browser before installing — how search behaves, how selected values are displayed, how empty states look, how keyboard navigation feels, and how the component behaves inside a dialog. For UI libraries, live examples are part of the trust model.

Official React 19 test matrix

The React 19 release was validated in a clean React 19.2.4 application using @stackline/react-multiselect-dropdown@19.1.1. The docs reuse the same examples from that test app, including keyboard navigation, focus, ARIA behavior, badge counters, responsive action buttons, scrollable lists, dialog-safe body overlays, the left-aligned and vertically centered placeholder, guided Slots API customization, headless custom HTML, and the combobox contract checks for Backspace, Escape, focused badge removal, focus, and option ARIA.

The same core scenarios are validated across the visual skins.

# Scenario Main settings tested
01 Basic multi { enableSearchFilter: true }
02 All selected badges visible { badgeShowLimit: 10 }
03 Single selection { singleSelection: true }
04 Search by fields { searchBy: ['itemName', 'capital'] }
05 Grouped options { groupBy: 'category', selectGroup: true }
06 Selection limit { limitSelection: 2, badgeShowLimit: 2 }
07 Custom rendering renderItem and renderBadge
08 Search and add item { addNewItemOnFilter: true }
09 Disabled state { disabled: true }
10 Controlled form validation React state and derived validation
11 Long list with keyboard scroll { maxHeight: 140 }
12 Local lazy loading { lazyLoading: true }
13 Dialog and overflow container { appendToBody: true, tagToBody: true }
14 Body overlay auto direction { autoPosition: true, position: 'top' }
15 Ref methods openDropdown, closeDropdown, selectAll, clearSelection
16 Slots API custom HTML slots.Trigger, slots.Option, slots.Search, slots.GroupHeader, slots.MenuFooter

Publishing the exact scenarios and settings that were tested is a small thing that pays off during adoption: instead of trusting a "tested on React 19" badge, you can see precisely which configurations were exercised, and reproduce them.

React version strategy

Each React major receives its own validated package line, locked to one React family. The compatibility table maps each package family to its React family, its peer range, and the tested release window.

Package family React family Peer range Tested release window
19.x React 19 only >=19.0.0 <20.0.0 19.1.1 -> 19.2.4
18.x React 18 only >=18.0.0 <19.0.0 18.0.0 -> 18.3.1
17.x React 17 only >=17.0.0 <18.0.0 17.0.0 -> 17.0.2

This improves reproducible builds, compatibility validation, migration planning, and versioned-documentation consistency. The ranges are strictly bounded to the tested React major; the React line does not loosen its range the way the Angular 22 line does, because React's slower, less calendar-driven cadence means there is no fixed next-major date to prepare for. For React teams, predictable compatibility is usually more valuable than optimistic version ranges.

Adoption checklist

For teams adopting the React 19 line, I would use the following checklist.

1. Match the package family to your React major

Install the 19.x family for React 19 applications. Keep the package family aligned with the React major your app actually runs.

2. Install the package

npm install @stackline/react-multiselect-dropdown@19.1.1 --save-exact
Enter fullscreen mode Exit fullscreen mode

3. Hold selection in React state

Use useState for the selected array and update it through onChange. Treat the component as fully controlled.

4. Use a stable settings object

Wrap settings in useMemo so its identity stays stable across renders.

5. Choose your customization layer

Start with <MultiSelectDropdown />. Move to slots when the layout needs a different shell, and to the headless hooks when the app must own the whole combobox. Use createMultiSelectDropdown<T>() if a feature or design-system wrapper should bind the item type once.

6. Configure styling through skin

Pick from classic, material, dark, custom, or brand. Move any theme usage to skin; theme still works as a legacy alias.

7. Verify keyboard and accessibility

Confirm that space inside search types a space, that tab does not select an option, that escape closes without clearing the field, and that options report consistent aria-selected and aria-checked. Configure settings.keyboard if your app needs different behavior.

8. Validate real UI scenarios

Test inside modals, scrollable containers, dashboard cards, table filters, and forms with validation. Use appendToBody: true (or tagToBody: true) with autoPosition for dialogs and overflow-clipped containers.

9. Review large-list and async behavior

Evaluate lazyLoading, the long-list keyboard-scroll example, maxHeight, onScrollToEnd, server-side filtering, and selected-object preservation across async refreshes.

10. Standardize settings across the app

Create shared settings presets for your design system to avoid slightly different dropdown behavior on different screens.

When this component is a good fit

This package is a strong fit when you need:

  • React 19 compatibility, validated rather than assumed
  • a controlled, predictable selection API (data in, selectedItems bound, onChange out)
  • escalating customization: component, slots, headless hooks, and a typed factory
  • search, select-all, grouping, and selection limits
  • a documented keyboard and ARIA combobox contract
  • custom item and badge rendering, plus structural slots
  • imperative ref methods for open, close, focus, select all, and clear
  • five built-in skins and full headless styling control
  • lazy loading and object preservation for large async lists
  • live examples and a StackBlitz playground to evaluate before adoption
  • behavioral consistency with the Stackline Angular line for multi-stack teams

It is especially useful for React applications where predictable selection behavior and accessibility matter, and where a design system wants to own rendering without rebuilding selection logic.

It is probably not the right fit if you want a control built natively around Server Components with no client interactivity. A rich combobox is inherently a client component, and that is correct rather than a limitation. If you want to fit an RSC-heavy design system, the right path is the headless hooks: let a Server Component or an Action provide the data, and use the hook to own the interactive client part. Being clear about that boundary is part of the package being trustworthy.

Final thoughts

A good UI component is not only about what appears on the screen.

In real React projects, the important questions are practical: Does it keep selection state predictable? Does it preserve state across async refreshes? Is it accessible by keyboard and to screen readers? Can a design system customize it at the depth it needs? Are the examples reproducible? Is React compatibility clearly validated?

Stackline React Multiselect Dropdown for React 19 focuses on those concerns. The package includes search, grouping, select-all support, custom rendering, a guided Slots API, headless hooks, a typed factory, imperative ref methods, lazy loading, five skins, body-overlay positioning, and live React 19 examples running in a real application environment.

It is also built to be coherent with the Angular line. The combobox and ARIA contract, the skins, the headless philosophy, the per-major versioning, and the live-example model are shared across both ecosystems — with this React line serving as the reference implementation of the shared contract. For teams that run React and Angular side by side, that means a multiselect that behaves the same way in both, which is usually worth more than any single feature.

For new releases, documentation updates, and the Angular counterpart, visit:

https://alexandro.net

Links

npm:
https://www.npmjs.com/package/@stackline/react-multiselect-dropdown

Documentation (React 19):
https://alexandro.net/docs/react/multiselect/react-19/#/basic

Live demo / interactive playground (StackBlitz):
https://stackblitz.com/github/alexandroit/stackline-react-multiselect-react-19?file=src%2Fexamples%2Fbasic%2Fbasic.component.tsx&startScript=start&initialpath=%2Fbasic

GitHub:
https://github.com/alexandroit/react-multiselect-dropdown

All React lines (React 17 to React 19):
https://alexandro.net/docs/react/multiselect/

Top comments (0)