Changelog
Changelog
All notable changes to this project are documented here. The format follows Keep a Changelog and the project uses Semantic Versioning.
[Unreleased]
Added
searchInMenuprop: the search field sits at the top of the open menu instead of after the values, so the closed control shows only the values (orplaceholder) and no search placeholder next to the chips. Single select anddisableChipcan then search with a value selected. The field’s box is.msl-search(classNames.search), and its placeholder is the newsearchPlaceholderprop ('Search...').deleteOnBackspaceprop (true): set it tofalseso Backspace in the empty search field leaves the chips, and only their delete buttons remove them.- Collapsible groups:
collapsible: trueon a group makes its title show and hide its options, on a click or Enter.collapsed: truestarts it closed. The open state lasts from one menu open to the next. The title is an option witharia-expandedthat the arrow keys reach, styled through.msl-grp-title[aria-expanded](classNames.groupTitle, Tailwind’saria-expanded:). Its childs sit in.msl-grp-body(classNames.groupBody) after the title, in one inner<div>, so select them with.msl-grp .msl-option, not>. A grid row of that<div>from0frto1franimates the height. - Submenus:
{ type: 'submenu', title, childs }is a row that opens itschildsin a popover beside the menu. It opens on a short pause over it, a click, Enter or Arrow Right, and closes on Arrow Left or Escape with the highlight in it. A pause over another row closes it, so crossing rows on the way in leaves it open. Arrow keys stay inside the submenu that has the highlight. Submenus nest to any depth and can hold groups, and search lists their options flat. The popover is.msl-submenu(classNames.submenu), arole="group"in the listbox named by its title, which is an option witharia-expanded. It shows in the top layer, and the stylesheet places it with CSS anchor positioning: beside its title, on the other side or above where there is no room, and over its title where neither side has room. A browser without CSS anchor positioning gets it under its title, in the menu, not in the top layer. Right-to-left pages get it on the left, where Arrow Left opens it and Arrow Right closes it: right to left by adir="rtl"attribute, which the stylesheet reads with:dir(), not by CSSdirectionalone. Like'group',type: 'submenu'is reserved: options of yours can’t have it. - Exit animations: whatever closes stays until the CSS animations and transitions that its
data-closingattribute starts have ended. That is the open box (.msl[data-closing], still.msl-activeanddata-open), a copy of a closed submenu where it was (.msl-submenu[data-closing]), and a copy of a collapsed group’s body where it was (.msl-grp-body[data-closing]). Tailwind reaches them withdata-closing:andin-data-closing:. Without exit CSS things close at once, and transitions already running, such as an option’s hover background, don’t hold them. While closing, the menu and the copies are inert, focus leaves the menu, andonMenuClosehas fired. Opening again mid animation brings the same menu back. Enter animations already worked through@starting-style; the docs have examples of both, in CSS and in Tailwind: the open box grows and shrinks with its content, and collapsible groups slide open and shut. - The dropdown keeps the control’s height while CSS animates the open box’s height: it measures the control to the menu’s edge, not the box less the menu.
renderListprop, for long lists through the virtual list library of your choice (react-window, TanStack Virtual, …), which you install yourself: the package still has no dependencies. It gets the open list’s rows as{ count, row(index), activeIndex }, the newMultiSelectListPropstype. Only the rows the library renders get built, so 100,000 options open at once. Each row comes in adisplay: contentsbox, so a focused row (a click focuses one) the library unmounts as it scrolls leaves focus in the listbox, where keys still work. Groups are flattened into the rows, each row in amsl-grpbox per group around it so it indents as in the group, with norole="group"around them. A collapsible group’s title is a row, and its rows come and go as it opens and closes. An open submenu’s rows go throughrenderListtoo, as a list of their own in its popover. The listbox and the submenus getdata-custom, which the stylesheet makes flex columns, so the library’s scroller shrinks to the menu’s height. Options carryaria-posinsetandaria-setsize, as only the rows in view are there for screen readers to count. A submenu is then as wide as--msl-input-width, as its rows have no width of their own: give.msl-submenua length if that is a percentage, which would size it from the room beside its title. Position rows withtop, nottransform: Firefox places a submenu where its title would be without the transforms around it. Recipes are in the virtual lists guide.groupIconandsubmenuIconprops: the icon after a collapsible group’s title and a submenu’s. Both sit in a<span class="msl-expand-icn" aria-hidden="true">(classNames.groupIcon,classNames.submenuIcon), which the stylesheet holds at the end of the title withmargin-inline-start: autoand turns: down while its group is open, to the left right to left, so draw yours pointing to the inline end. By default it holds the arrow button’s chevron SVG,.msl-chevron-icn, which the stylesheet sizes, thickens (stroke-width) and turns to point to the end; yours goes in a<bdo dir="ltr">, so right to left the browser doesn’t mirror a text arrow such as›that the stylesheet turns.order: -1; margin-inline-start: 0puts it before the title. See Expand icons.renderGroupTitleprop: renders the title row of a group or a submenu, in place of itstitleand icon, from{ group, title, total, selected, open, icon }, the newMultiSelectGroupTitlePropstype.totalcounts the options in it, those in nested groups and submenus and disabled ones too,selectedhow many of those are picked,openwhether a collapsible group shows its childs or a submenu is open (truefor a plain group), andiconis the.msl-expand-icnspan to place or leave out (nullfor a plain group):({ title, selected, total, icon }) => <>{title} {selected}/{total}{icon}</>. The row keeps its box and attributes.- Collapsible groups, submenus, their icons and titles, exit animations and
renderListgrow the JS bundle about 2.3 kB gzipped (4.7 to 7.0 kB), and the stylesheet about 0.4 kB (1.7 to 2.1 kB).
Changed
type: 'submenu'is reserved, as'group'is. An option of yours with thattypenow reads as a submenu, and withoutchildsit throws on render. Rename that field, or map your data to options without it.- Options render inside a
<div style="display: contents">in.msl-options(not withrenderList), so DOM snapshots change. It takes no layout room, but options are no longer children of.msl-options: select them with.msl-options .msl-option, not>, and style them throughclassNames.option, as child utilities onclassNames.menu(*:,space-y-*,divide-y) no longer reach them. Each search’s options go in at once, in a new box: Safari froze for minutes when a long list came back after a search (a pick, or the search cleared) on a page with a:has(~ …)rule, as Starlight docs have, as WebKit rechecks such a rule for every option added to the open list, one at a time. - The registered
--msl-menu-roomhas an initial value,100vh, so the menu is capped before it first measures the room, for a virtual list that measures itself on mount. Without the stylesheet, addinitial-value: 100vh;to your@property --msl-menu-room.
3.0.0-beta.1 - 2026-10-16
Breaking release: one value format, React 18+, modern bundles, a native search input, and an open menu that is one box in the top layer. The JS bundles are about 19% smaller, about 6% smaller gzipped. The stylesheet grew from 1.4 to 1.7 kB gzipped.
Upgrade notes
valueanddefaultValuetake an array of option values. The comma separated string and option object forms are gone.onChange(values, options)always gets the selected values and their option objects. A created value’s option is{ label: value, value }. The hidden form input still posts the values joined by commas.jsonValueis removed. Read the option objects fromonChange’s second argument.defaultValueis read once, on mount, like an uncontrolled input. To change the selection later, usevalue, or remount with a newkey.- React 18 or 19 is required.
- No UMD build and no
dist/index.esm.js. Import the package through a bundler. The ES module build isdist/index.modern.js, whichmodulealready points to. - No ES5 build. The bundles use modern syntax and import
react/jsx-runtime. Transpile them yourself if you target old browsers. Even then, chip delete needs:focus-visible: Chrome 86, Firefox 85, Safari 15.4. - Types:
MultiSelectValue,MultiSelectBaseProps,MultiSelectStringPropsandMultiSelectJsonPropsare gone.MultiSelectPropsis a single interface, soOmitworks on it directly. labelis a string, and options losetitle. For JSX, passgetLabel={(opt) => <Jsx />}. Search and the tooltip then uselabel.- CSS variables start with
--msl-, so they can’t clash with an app’s own--border-radiusor--font-size:--input-widthis--msl-input-width, and so on for all of them. Rename them in your CSS. - The search field is an
<input>, not acontentEditablediv. Rules on.msl-input::beforeno longer reach its placeholder: set the new--msl-placeholder-colorvariable instead. It fits its text through CSSfield-sizing: content; browsers without that show it at the default input width. - Chips render inside a
<div data-clickable="true" style="display: contents">, so DOM snapshots change. It takes no layout room..msl-chipand.msl-chip-deletekeep their classes, but the clickable attribute moves from them to the wrapper, and chips are no longer children of.msl-input-wrp: use.msl-input-wrp .msl-chip, not>. - The
clickableanddropdown-handleattributes are nowdata-clickableanddata-handle, valid HTML. Update selectors and DOM snapshots that use the old names. - No
data-mslattribute. It marked internal elements and multi select options, and nothing read it. Update DOM snapshots and selectors that use it:.msl-optionand the othermsl-*classes stay. - Layout and the arrow flip live in the CSS, not in inline styles. Left inline: the chip wrapper’s
display: contentsand, while open, the dropdown’smin-heightand the open box’s position (see below)..mslis a grid:.msl-input-wrptakes the free space (it had an inlinewidth: calc(100% - 60px)),.msl-actionssits in the flow instead ofposition: absolute, and the options span both columns. Recheck custom CSS that sizes.msl-input-wrpor positions.msl-actions. The open arrow turns throughrotateon.msl-active .msl-arrow-btn, not an inlinetransform, so without the stylesheet it no longer flips. - No “Empty” option. Without
options, the menu showsemptyDataLabel, as it does for[]. - Empty states are
<div>s, not<option>s, so they can hold JSX. Use.msl-option-disable, notoption.msl-option-disable. - The open menu is one box, a popover over the dropdown. While open,
.msl(the input box, now with the options attached: one border and shadow) is apopover="manual"in the top layer, over the dropdown, which keeps its height. It stays in place in the DOM, inside a new unstyled<div>around.msl, so your CSS reaches it through its ancestors and it inherits the text look there, whileoverflow: hiddenparents, stacking contexts and z-indexes no longer clip or cover it. This needs the Popover API: Chrome 114, Firefox 125, Safari 17. Check:.msl-active/.msl-active-upmark the open box, which holds the options after the values (before them, opening upward). The seam rules are gone (clip-path, the stripped border, the radius transitions), as are.msl-options { position: absolute; max-height: 0 }and.msl-wrp { position: relative }.- The open box is as wide as the dropdown and clips the options, so a wider
widthormin-widthon.msl-optionsis cut off. Widen the open box instead:.msl-active { min-width: 400px }. - While open, the box follows the dropdown every animation frame. With fake timers that fake
requestAnimationFrame(Jest’s, Vitest’s),runAllTimers()never runs dry: close the menu first, or useadvanceTimersByTime(). - In the top layer, transforms on the dropdown’s ancestors don’t apply to the open box. It copies their scale (a zoomable canvas), not a rotation or skew.
- Without the Popover API (jsdom, for one), the box is a plain element in the dropdown, so tests that query the container keep working.
- A new default look, and a rewritten stylesheet.
--msl-font-sizeis14px(was16px), options and group titles pad4px 8px(was7px 5px), the selected option’s background is a greyhsl(209.49deg 10.01% 93.31%)(was#e6f4fa), the chip delete button stays transparent until hovered (--msl-chip-delete-background, wasrgb(218, 218, 218)), and the open menu has a softer shadow, a faded border (--msl-active-menu-border-color) and a divider between the input and the options (--msl-divider-color). Set the variables back, and.msl-option, .msl-grp-title { padding: 7px 5px }, for the old look. The stylesheet uses native CSS nesting (Chrome 120, Firefox 117, Safari 17.2) and logical properties, so chip and input spacing follow right-to-left text. Option rules no longer go through.msl-options, so a lone.msl-optionor.msl-option-focusedrule of yours matches their specificity. The arrow turns withrotate, nottransform. One class with the same state overrides any rule: the closed box’s hover border no longer outranks it, and.msl-disabled’spointer-events: noneis no longer!important. The line height is set on.msl-wrpand inherited, not forced on every element inside, so a class on a part sets it for the part’s content too, and your markup in labels or chips (a<button>) keeps its own. Keep--msl-line-heightunitless: a%oremvalue is worked out once, against the wrapper’s font size. largeDatais removed: options always render only while the menu is open. Delete the prop.clearAllis removed. To clear the value, passvalue={[]}to a controlled select, or remount it with a newkey.widthis removed from the types. It has done nothing since 2.0.1, and TypeScript now rejects it: set the--msl-input-widthCSS variable instead.- Tab closes the menu and moves on in page order. It used to step through the chips and the clear button with the menu still open. Focusable JSX in the menu (a button in
emptyDataLabelor a grouptitle) is still a Tab stop: while the menu has any, Tab steps through the open box in page order, chips and the clear button included, and focus leaving it closes the menu. - Closing the menu clears the search text.
- Markup for utility CSS. Options sit right inside
.msl-options(the<div>around them is gone), and the open box stacks by DOM order (withmsl-active-upthe options come before the input). - No
msl-flxclass..msl-btnand.msl-actionscenter their content themselves. Move rules on.msl-flxto those classes. - ARIA labels: the clear button is
Clear(wasclose-menu), set it withclearLabel. The default chip’s delete button isRemove Applefor anApplechip (wasdelete-value), orRemovewhen the label has no text; translate it throughcustomChip. Update tests that query these labels.
// v2<MultiSelect options={options} value='apple,banana' onChange={(csv) => ...} /><MultiSelect jsonValue options={options} onChange={(opts) => ...} /><MultiSelect options={[{ label: <b>Apple</b>, title: 'Apple', value: 'apple' }]} />
// v3<MultiSelect options={options} value={['apple', 'banana']} onChange={(values) => ...} /><MultiSelect options={options} onChange={(values, opts) => ...} /><MultiSelect options={[{ label: 'Apple', value: 'apple' }]} getLabel={(opt) => <b>{opt.label}</b>} />Added
clearLabel,createLabel(text)andcustomValuesTitleprops, so no built-in text is left untranslatable:createLabel={(text) => `Ajouter « ${text} »`}.emptyDataLabel,chipAlternateTextand a group’stitleandemptyDataLabeltake JSX.- CSS variables for every color:
--msl-border-color,--msl-hover-border-color,--msl-disabled-background,--msl-chip-background,--msl-chip-delete-background,--msl-chip-delete-color,--msl-button-hover-background,--msl-option-hover-background,--msl-option-active-background,--msl-option-active-color,--msl-option-disabled-color,--msl-group-title-background,--msl-focus-color,--msl-background(the closed box),--msl-scrollbar-color,--msl-active-menu-border-colorand--msl-divider-color(the line between the input and the open menu). A dark theme is one rule. customChip(props)prop, to render chips yourself:customChip={({ label, onDelete }) => <button type='button' onClick={onDelete}>{label} ×</button>}. It getslabel,value,option,disabledandonDelete, typed asMultiSelectChipProps. It is a render function, not a component, so it can’t use hooks: return<MyChip {...props} />for that.filterOption(option, search)prop, to replace the search match.getLabel(option)andgetValue(option)props.getLabelrenders options, chips and the single value. Together they read options of any shape, e.g.{ id, name }straight from an API. The types infer the shape fromoptions, and requiregetLabelorgetValuewhen it has no stringlabelorvalue. WithcustomValue,onChange’s options are typed to also hold created{ label, value }options.classNamesprop: classes for each part (control,values,input,singleValue,chip,chipDelete,actions,clearButton,arrowButton,menu,group,groupTitle,option,empty), to style the select with Tailwind or other utility CSS and no stylesheet.customChipgets them in its props. A Tailwind page in the docs has a full example.- State attributes for utility variants:
data-focusedon the highlighted option (next toaria-selectedandaria-disabled),data-openanddata-upon the open box,data-disabledon the wrapper. - Group options are indented one step per level, nested groups included, with the group’s title at the level of the group:
--msl-group-offset(12px,0for flat). Groups carry anmsl-grpclass. --msl-placeholder-colorCSS variable, for the search field’s placeholder and the static one shown withdisableSearch.
Changed
- The open menu follows the dropdown as the page or a parent scrolls, the window resizes or the dropdown grows, and closes once a scrolling parent hides the dropdown. In a scaled part of the page (
transform: scale()) it scales as the dropdown does. Scrolling past the end of the options doesn’t scroll the page. - The menu is no taller than the room left in the viewport, as well as
--msl-menu-max-height. The room is--msl-menu-roomon.msl-options, registered non-inherited, so your own CSS can cap the menu with it. - Without a listbox to point at, the closed combobox has no
aria-controls. - All
aria-*inattrreach the combobox, not onlyaria-labelandaria-labelledby: anaria-describedbyerror oraria-invalidstays announced in the search field. - The open and close radius transitions are gone.
- A disabled select ignores clicks without the stylesheet too: it no longer leaned on
pointer-events: noneto stay closed and keep its value. Disabling a select with its menu open closes the menu, and focus moves from it to the dropdown. onMenuOpenruns once the open menu is in the DOM and focused, after a click as after a key. It used to run before the open rendered.- The Escape that closes the menu is canceled and stops at the select, so a surrounding
<dialog>, popover or modal library’s dialog (MUI’s) stays open. With the menu closed, Escape is theirs. - Options that arrive after mount resolve the selection when they render. There is no longer an effect that re-resolves it.
- Values missing from
optionsare left out of the display and ofonChange, unlesscustomValueis set. They show up once matching options arrive. WithcustomValuethey are listed under Custom Values only while selected: once deselected they leave the menu (v2 kept them listed), unlike values created by typing. - Once
limitis reached, unselected options getaria-disabled="true"andmsl-option-disable, and the keyboard highlight skips them. Picking one only callsonChange, the value unchanged: a Create item no longer adds the typed text to Custom Values, and the search text and the menu stay. Single select ignoreslimit: its wrapper no longer getsmsl-limit-reached. - Enter and comma pick the first search result that isn’t
disabled, no longer adisabledone. - While the select is disabled, the chip delete and clear buttons have
aria-disabled="true", so screen readers announce them unavailable. - Search results keep option order. Label matches no longer come before value-only matches.
- The menu’s scrollbar thumb no longer darkens on hover in Safari, matching Chrome and Firefox.
- Keys pressed on focusable JSX in the menu, such as a button in
emptyDataLabelor a grouptitle, are left to it: Enter no longer picks the highlighted option or refocuses the search field. - Long search text scrolls on one line instead of wrapping, and no longer runs under the clear and arrow buttons. A placeholder too long for the input ends in an ellipsis.
react-domis no longer a peer dependency: the component doesn’t import it.
2.1.0 - 2026-10-12
React 19 support, TypeScript types, keyboard and screen reader support, a controlled value prop, and a long list of bug fixes. Existing props, events, CSS variables and class names keep working.
Upgrade notes
Most apps need no changes. Check these if they apply to you:
MultiSelect.defaultPropsis gone. React 19 ignoresdefaultPropson function components, so the defaults are now built in. If you changedMultiSelect.defaultPropsto set app-wide defaults, pass those props instead (or wrap the component).- Single select closes after a pick. It used to close only when the parent fed the value back through
defaultValue. PasscloseOnSelect={false}to keep it open. Multi select still stays open unlesscloseOnSelectistrue, which now works there too. - Keyboard highlight is a class, not focus. Options no longer take focus; the highlighted one gets
msl-option-focused. Move custom.msl-option:focusstyles to.msl-option-focused. - Tab order. Options and the down arrow are no longer Tab stops. The chip delete and clear buttons act on Enter and Space only (any key used to trigger them). The menu closes when Tab moves focus out.
- UMD build reads
window.React, the global React’s own UMD build sets (it used to readwindow.react). - DOM snapshots change: options, the listbox and the combobox carry ARIA roles and attributes, and stray
undefined/falseclass names are gone.
Added
- React 19 support
- TypeScript types, bundled as
dist/index.d.ts valueprop for a controlled selection, in the same forms asdefaultValueonClearevent after the clear button empties the value (#10)msl-limit-reachedclass on the wrapper oncelimitis reached- Keyboard: Arrow Up / Down, Home / End to move, Enter to pick, Backspace to remove the last chip, Escape to close
- Accessibility: combobox / listbox / option roles with
aria-expanded,aria-activedescendant,aria-selectedandaria-disabled; groups userole="group"named by their title. Name the dropdown withattr={{ 'aria-label': '...' }}or'aria-labelledby' 'use client'directive in the CJS and ESM bundles, so Next.js App Router Server Components can import it- Works inside a shadow root (React 17+)
Changed
- Peer dependencies:
reactandreact-dom^16.9.0 || ^17 || ^18 || ^19 closeOnSelectis unset by default: single select closes, multi select stays open (see upgrade notes)dist/index.cssships as authored (readable, same rules)- A disabled dropdown is no longer focusable or keyboard-openable
- The menu opens upward only when there is more room above than below
- Search matches typed text literally (it was a regular expression)
- Pasting into the search inserts plain text
Fixed
closeOnSelectcloses the menu on pick, also in multi select (#9, #11)defaultValueresolves when options load after mount (#12)- Typing
(,[,*or other regex characters in the search no longer crashes - Pressing Enter after pasting into the search no longer crashes
- Enter no longer adds a line break to the search field
- Text pasted from the context menu or dropped into the search now filters
- Enter or comma confirming an IME composition (Chinese, Japanese, Korean input) no longer picks an option
- Numeric option values or labels (untyped JS) no longer crash the search
onMenuClosefires on Escape, and only when an open menu closes- Clicks inside the menu no longer fire
onMenuOpenagain - Custom
downArrowIcon/closeIconof any nesting depth toggle and clear correctly customValue: the chip andonChangeget the typed text, notCreate "..."; clicking Create adds the value; double quotes are kept; created values survive a parent re-render with a newoptionsarraydefaultValueobjects not found inoptions(withcustomValue) keep theirvaluestring, soonChangeno longer gets[object Object]- Falsy
optionsshowemptyDataLabel - SSR: aria ids match between server and client (no hydration warning)
- Missing React key on an empty group’s placeholder
- CSS: alpha colors and
clip-pathin older Safari
2.0.6 - 2022-08-22
Fixed
- Options update when the
optionsprop changes
Changed
- Softer default menu shadow (
--active-menu-shadow)
2.0.5 - 2022-01-30
Added
clearAllprop clears the value when it turnstruedisableSearchprop hides the search input
Fixed
closeOnSelectin single select- Placeholder shows when the search is disabled
Changed
- Peer dependency
react>= 16.13.1
2.0.4 - 2020-12-30
Changed
- CSS variables updated
2.0.1 - 2020-12-17
Added
- Menu open / close events (
onMenuOpen,onMenuClose) - Menu opens upward at the bottom of the page
Changed
- Width moved from the
widthprop to the--input-widthCSS variable
2.0.0 - 2020-12-08
Added
- JSX labels (with a
titlefor search and chips) - Custom values listed in their own group
Changed
- Search improved
Fixed
- Single select bugs