Skip to content

Tailwind

Skip dist/index.css and pass Tailwind classes for each part through classNames. Each string goes after the part’s msl-* class. With no stylesheet the markup is unstyled, and the select still works: opening, the menu above or below, disabled.

value: []

Tailwind.tsx
import { useState } from 'react'
import MultiSelect from 'react-multiple-select-dropdown-lite'
// no dist/index.css: Tailwind styles every part
import './tailwind-demos.css'
const options = [
{ label: 'Apple', value: 'apple' },
{ label: 'Banana', value: 'banana' },
{ label: 'Cherry', value: 'cherry' },
{ label: 'Mango', value: 'mango' },
{ label: 'Orange', value: 'orange', disabled: true },
{
type: 'group',
title: 'Berries',
childs: [
{ label: 'Blueberry', value: 'blueberry' },
{ label: 'Strawberry', value: 'strawberry' }
]
}
]
const button =
'inline-flex cursor-pointer items-center justify-center rounded-full'
const classNames = {
// open, it holds the options too, in the top layer over the page: it sets its own background
control:
'grid cursor-text grid-cols-[minmax(0,1fr)_auto] overflow-hidden rounded-lg border border-gray-400 not-data-open:hover:border-gray-600 data-open:border-gray-200 data-open:bg-white data-open:shadow-lg in-data-disabled:bg-gray-50',
values: 'flex min-w-0 flex-wrap items-center gap-1 p-1',
// with searchInMenu, the search field's box heading the options
search:
'col-span-full flex border-t border-gray-200 p-1 in-data-up:border-t-0 in-data-up:border-b',
input:
'min-w-0 flex-1 border-0 bg-transparent p-1 [font:inherit] text-inherit outline-none placeholder:text-gray-400 empty:before:text-gray-400 empty:before:content-[attr(data-placeholder)]',
singleValue: 'truncate p-1',
chip: 'inline-flex items-center gap-1 rounded-md bg-gray-100 py-0.5 pr-0.5 pl-1.5',
chipDelete: `${button} size-5 text-black/60 hover:bg-gray-300 [&_svg]:size-2.5`,
actions: 'flex items-center pr-1',
clearButton: `${button} size-6 hover:bg-gray-200 [&_svg]:size-2.5`,
arrowButton: `${button} size-6 hover:bg-gray-200 in-data-open:rotate-180 [&_svg]:size-5`,
// --msl-menu-room is set on the menu; the divider sits on the side toward the control, below it or (data-up) above.
// It fades in, and out: closing, the control has data-closing until the fade ends
menu: 'col-span-full max-h-[min(400px,var(--msl-menu-room))] overflow-auto border-t border-gray-200 py-1 in-data-up:border-t-0 in-data-up:border-b transition-opacity duration-150 starting:opacity-0 in-data-closing:opacity-0',
option:
'mx-1.5 my-1 cursor-pointer rounded-md px-2 py-1 hover:bg-gray-100 aria-selected:text-blue-600 aria-disabled:cursor-default aria-disabled:text-gray-300 data-focused:bg-gray-100',
// a step in per level, the title at its group's level
group: 'ps-3',
groupTitle:
'my-1 me-1.5 -ms-1.5 rounded-md bg-gray-50 px-2 py-1 text-xs text-gray-500',
empty: 'mx-1.5 my-1 px-2 py-1 text-gray-400'
}
export default function Tailwind() {
const [value, setValue] = useState<string[]>([])
return (
<>
<MultiSelect
options={options}
onChange={setValue}
className='w-75 rounded-lg text-sm'
classNames={classNames}
/>
<p className='demo-output'>value: {JSON.stringify(value)}</p>
</>
)
}
Key Element
className the wrapper only (a prop of its own, not in classNames)
control msl: the box with the values and buttons; open, the options too, in the top layer
values msl-input-wrp: chips, single value and search field
input msl-input: the search field, or the placeholder when there is none
search msl-search: the box around the search field with searchInMenu, atop the options
singleValue msl-single-value: the value with singleSelect, the count with disableChip
chip msl-chip (default chips; a customChip gets classNames in its props)
chipDelete msl-chip-delete
actions msl-actions: the clear and arrow buttons
clearButton msl-clear-btn
arrowButton msl-arrow-btn
menu msl-options: the listbox
group msl-grp: a group’s box
groupTitle msl-grp-title; a collapsible group’s has aria-expanded
groupIcon msl-expand-icn after a collapsible group’s title, around groupIcon
groupBody msl-grp-body: a collapsible group’s childs, in one <div> (for height animations)
option msl-option, and a submenu’s title, which has aria-expanded
submenuIcon msl-expand-icn after a submenu’s title, around submenuIcon
submenu msl-submenu: a submenu’s popover, placed beside its title by the stylesheet
empty emptyDataLabel, and an empty group’s

State is in attributes, so Tailwind’s variants reach it:

Variant Matches
aria-selected: a selected option
aria-disabled: a disabled option
data-focused: the option or title highlighted by the keyboard
aria-expanded: an open submenu’s title, an expanded group’s title
data-open: the open control; in-data-open: for parts inside it
data-up: the open control, opened upward
data-closing: what is closing: the control, a submenu’s copy
in-data-closing: parts inside it, the rows of a collapsing group too
in-data-disabled: parts of a disabled select (the wrapper has data-disabled)
  • Open, the control holds the options and shows in the top layer, over the wrapper. Give it a background (data-open:bg-white): nothing is behind it. A grid with the options across both columns (col-span-full) puts them under the values and buttons.
  • It stays in place in the DOM, so it inherits the text color and font around the select, and needs no z-index.
  • Without renderList, the options sit in a display: contents box in the menu, made anew for each search: style them through option, not with *: or space-y-* on menu.
  • Opening upward, the options come before the values in the DOM, and the control gets data-up. A divider on the options’ border-t in-data-up:border-t-0 in-data-up:border-b lands next to the control either way. With searchInMenu, the search field’s box (search) heads the options, on the side away from the control when opening upward, and takes the same divider.
  • --msl-menu-room on the options (classNames.menu) is the room the viewport leaves on the menu’s side. Cap the options with it: max-h-[min(400px,var(--msl-menu-room))]. Register it as non-inherited, as the example’s CSS does, so a scroll restyles the listbox, not every option. Its initial value caps the menu before it first measures the room, for a virtual list that measures itself on mount:
@property --msl-menu-room {
syntax: '*';
inherits: false;
initial-value: 100vh;
}
  • With renderList, the options and submenus get data-custom. Make them flex columns, data-custom:flex data-custom:flex-col, so a virtual list’s scroller shrinks to the menu’s height, and give submenus a width, data-custom:w-75: their rows have none of their own.

starting: animates parts in, and data-closing: / in-data-closing: out: whatever closes stays until its transitions end. The menu in the example above fades in and out with transition-opacity duration-150 starting:opacity-0 in-data-closing:opacity-0.

Below, the open box also grows to fit the options and shrinks back (a grid row from 0fr to 1fr), submenus slide in and fade out, a collapsible group slides open and shut (its body’s one <div> as a grid row), and the chevrons turn. With no stylesheet, the submenu’s classes place it too, with CSS anchor positioning: its title is its position-anchor.

TailwindAnimated.tsx
import MultiSelect from 'react-multiple-select-dropdown-lite'
// no dist/index.css: Tailwind styles and animates every part
import './tailwind-demos.css'
const options = [
{ label: 'All products', value: 'all' },
{
type: 'submenu',
title: 'Electronics',
childs: [
{ label: 'Phones', value: 'phones' },
{ label: 'Laptops', value: 'laptops' },
{
type: 'submenu',
title: 'Accessories',
childs: [
{ label: 'Chargers', value: 'chargers' },
{ label: 'Cases', value: 'cases' }
]
}
]
},
{
type: 'group',
title: 'Clothing',
collapsible: true,
childs: [
{ label: 'Shirts', value: 'shirts' },
{ label: 'Shoes', value: 'shoes' }
]
}
]
const button =
'inline-flex cursor-pointer items-center justify-center rounded-full'
// a title with aria-expanded, open or not: a row, its icon at the end
const opens =
'[&[aria-expanded]]:flex [&[aria-expanded]]:items-center [&[aria-expanded]]:gap-2'
// the icon after it: the arrow button's chevron, turned to point right
const chevron =
'ms-auto flex shrink-0 transition-[rotate] *:size-[15px] *:-rotate-90 *:stroke-2'
const classNames = {
// the open box grows to fit the options, and shrinks back: their grid row goes from 0fr to 1fr.
// Opening upward the options come first: they only fade
control:
'grid cursor-text grid-cols-[minmax(0,1fr)_auto] grid-rows-[max-content_0fr] overflow-hidden rounded-lg border border-gray-400 transition-[grid-template-rows] duration-200 not-data-open:hover:border-gray-600 data-open:grid-rows-[max-content_1fr] data-open:border-gray-200 data-open:bg-white data-open:shadow-lg data-open:data-up:grid-rows-[1fr_max-content] data-closing:not-data-up:grid-rows-[max-content_0fr]',
values: 'flex min-w-0 flex-wrap items-center gap-1 p-1',
input:
'min-w-0 flex-1 border-0 bg-transparent p-1 [font:inherit] text-inherit outline-none placeholder:text-gray-400 empty:before:text-gray-400 empty:before:content-[attr(data-placeholder)]',
chip: 'inline-flex items-center gap-1 rounded-md bg-gray-100 py-0.5 pr-0.5 pl-1.5',
chipDelete: `${button} size-5 text-black/60 hover:bg-gray-300 [&_svg]:size-2.5`,
actions: 'flex items-center pr-1',
clearButton: `${button} size-6 hover:bg-gray-200 [&_svg]:size-2.5`,
arrowButton: `${button} size-6 transition-[rotate] hover:bg-gray-200 in-data-open:rotate-180 [&_svg]:size-5`,
// no vertical padding: squeezed to 0fr, the row keeps the menu's padding in Chrome
menu: 'col-span-full max-h-[min(400px,var(--msl-menu-room))] overflow-auto border-t border-gray-200 transition-opacity duration-200 in-data-up:border-t-0 in-data-up:border-b starting:opacity-0 in-data-closing:opacity-0',
// a popover with no stylesheet: placed beside its title with CSS anchor positioning, on the other side without room
submenu:
'm-0 -mt-1.5 inset-auto [position-area:right_span-bottom] max-h-[400px] w-max min-w-30 overflow-auto rounded-lg border border-gray-200 bg-white p-0 text-inherit shadow-lg transition-[opacity,translate] duration-150 [position-try-fallbacks:flip-inline,flip-block] starting:-translate-x-1.5 starting:opacity-0 data-closing:opacity-0 rtl:[position-area:none] rtl:top-[anchor(top)] rtl:right-[anchor(left)]',
// in and out with the menu, a submenu, or their group
option: `mx-1.5 my-1 cursor-pointer rounded-md px-2 py-1 transition-[opacity,background-color] duration-150 hover:bg-gray-100 aria-selected:text-blue-600 aria-disabled:cursor-default aria-disabled:text-gray-300 aria-expanded:bg-gray-100 data-focused:bg-gray-100 starting:opacity-0 in-data-closing:opacity-0 ${opens}`,
submenuIcon: chevron,
group: 'ps-3',
// a collapsible group slides open and shut: the one box in its body, a grid row from 0fr to 1fr
groupBody:
'grid grid-rows-[1fr] transition-[grid-template-rows] duration-200 starting:grid-rows-[0fr] data-closing:grid-rows-[0fr] *:min-h-0 *:overflow-hidden',
groupTitle: `my-1 me-1.5 -ms-1.5 rounded-md bg-gray-50 px-2 py-1 text-xs text-gray-500 data-focused:bg-gray-100 [&[aria-expanded]]:cursor-pointer [&[aria-expanded]]:hover:bg-gray-100 group ${opens}`,
// down while the group is open
groupIcon: `${chevron} group-aria-expanded:rotate-90`,
empty: 'mx-1.5 my-1 px-2 py-1 text-gray-400'
}
export default function TailwindAnimated() {
return (
<MultiSelect
options={options}
placeholder='Animated'
className='w-75 rounded-lg text-sm'
classNames={classNames}
/>
)
}

See Animations for what closes, and how.

To restyle a few parts on top of the default look, import the stylesheet into a cascade layer below Tailwind’s utilities, or its rules beat them:

@import 'tailwindcss';
@import 'react-multiple-select-dropdown-lite/dist/index.css' layer(components);