Lightweight multiselect web component with typeahead search, rich content support, and excellent keyboard navigation
npm install @keenmate/web-multiselect

A lightweight, accessible multiselect web component with typeahead search, RTL language support, rich content, and excellent keyboard navigation.
> ⚠️ Security Notice: This component intentionally allows raw HTML in rendering callbacks to give developers full control over content display. If you display user-generated content, you must sanitize it yourself. See HTML Injection (XSS) Notice for the complete list of affected callbacks.
- 📝 Declarative HTML - Use standard and elements - no JavaScript required for simple cases!
- ⚡ Virtual Scrolling - Handle 15,000+ options instantly (25× faster opening, 99.8% memory reduction)
- 🔍 Flexible Search Modes - Filter (hide non-matches) or navigate (jump to matches, keep all visible)
- ⌨️ Keyboard Navigation - Full keyboard support (arrows, Enter, Esc, Tab)
- 🎨 Rich Content - Icons, subtitles, and multiline text support
- 📊 Multiple Display Modes - Badges, count, compact, partial, or none (minimal UI)
- 💬 Badge Tooltips - Customizable tooltips on selected items with placement control
- 🎯 Single & Multi-Select - Switch between single and multiple selection modes
- 🔄 Async Data Loading - On-demand data fetching support
- 📦 Grouped Options - Organize options into collapsible groups
- 🎉 Smart Positioning - Uses Floating UI for intelligent dropdown placement
- 🌍 i18n Support - Customizable callbacks for pluralization and localization
- 🌐 RTL Support - Full right-to-left language support (Arabic, Hebrew, Persian, Urdu, etc.)
- ✨ Modern - Web Component with Shadow DOM, TypeScript, bundled with Vite
- 🌐 Framework Agnostic - Works with any framework or vanilla JS
``bash`
npm install @keenmate/web-multiselect
Perfect for simple forms - just use standard HTML
`html
`
For dynamic data and advanced features:
`html`
search-placeholder="Search options..."
initial-values='["js","ts"]'>
`typescript
// Import the component (includes styles)
import '@keenmate/web-multiselect';
// Or import styles separately if needed
import '@keenmate/web-multiselect/style.css';
const multiselect = document.querySelector('web-multiselect');
// Set options programmatically
multiselect.options = [
{ value: 'js', label: 'JavaScript', icon: '🟨' },
{ value: 'ts', label: 'TypeScript', icon: '🔷' },
{ value: 'py', label: 'Python', icon: '🐍' }
];
// Listen for events
multiselect.addEventListener('change', (e) => {
console.log('Selected:', e.detail.selectedOptions);
console.log('Values:', e.detail.selectedValues);
});
// Public API
const selected = multiselect.getSelected();
multiselect.setSelected(['js', 'ts']);
`
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| multiple | boolean | true | Allow multiple selections |search-placeholder
| | string | 'Search...' | Placeholder text for search input |search-hint
| | string | - | Hint text shown above input when focused |allow-groups
| | boolean | true | Enable option grouping |show-checkboxes
| | boolean | true | Show checkboxes next to options |close-on-select
| | boolean | false | Close dropdown after selecting |dropdown-min-width
| | string | - | Min width for dropdown (e.g., '20rem') |badges-display-mode
| | 'pills' \| 'count' \| 'compact' \| 'partial' \| 'none' | 'pills' | How to display selected items. compact: first item + count. none: no display |badges-threshold
| | number | - | Auto-switch mode when exceeded (see badges-threshold-mode) |badges-threshold-mode
| | 'count' \| 'partial' | 'count' | Mode after threshold: 'count' shows badge, 'partial' shows limited badges + more badge |badges-max-visible
| | number | 3 | Max badges shown in partial mode |badges-position
| | 'top' \| 'bottom' \| 'left' \| 'right' | 'bottom' | Position of badges container |show-counter
| | boolean | false | Show [3] badge next to toggle icon |enable-badge-tooltips
| | boolean | false | Enable tooltips on selected badges |badge-tooltip-placement
| | 'top' \| 'bottom' \| 'left' \| 'right' | 'top' | Tooltip placement relative to badge |badge-tooltip-delay
| | number | 100 | Delay in ms before showing tooltip |badge-tooltip-offset
| | number | 8 | Distance in pixels between badge and tooltip |max-height
| | string | '20rem' | Maximum height of dropdown |empty-message
| | string | 'No results found' | Message when no options found |loading-message
| | string | 'Loading...' | Message while loading async data |min-search-length
| | number | 0 | Minimum search length for async |keep-options-on-search
| | boolean | true | Keep initial options visible when searchCallback is active (hybrid search) |should-keep-search-on-close
| | boolean | true | Preserve search text and filtered results when dropdown closes |sticky-actions
| | boolean | true | Keep action buttons fixed at top while scrolling |actions-layout
| | 'nowrap' \| 'wrap' | 'nowrap' | Layout mode for action buttons: 'nowrap' (single row) or 'wrap' (multi-row) |lock-placement
| | boolean | true | Lock dropdown placement after first open to prevent flipping |enable-search
| | boolean | true | Enable/disable search functionality |search-input-mode
| | 'normal' \| 'readonly' \| 'hidden' | 'normal' | Search input display mode |search-mode
| | 'filter' \| 'navigate' | 'filter' | Search behavior: 'filter' hides non-matches, 'navigate' jumps to matches |allow-add-new
| | boolean | false | Allow adding new options not in the list |value-member
| | string | - | Property name for value/ID extraction from custom objects |display-value-member
| | string | - | Property name for display text extraction from custom objects |search-value-member
| | string | - | Property name for search text extraction from custom objects |icon-member
| | string | - | Property name for icon extraction from custom objects |subtitle-member
| | string | - | Property name for subtitle extraction from custom objects |group-member
| | string | - | Property name for group extraction from custom objects |disabled-member
| | string | - | Property name for disabled state extraction from custom objects |name
| | string | - | HTML form field name for form integration (creates hidden input) |value-format
| | 'json' \| 'csv' \| 'array' | 'json' | Format for form value serialization |initial-values
| | string (JSON array) | - | Pre-selected values |enable-virtual-scroll
| | boolean | false | Enable virtual scrolling for large datasets |virtual-scroll-threshold
| | number | 100 | Minimum items before virtual scroll activates |option-height
| | number | 50 | Fixed height for each option in pixels (required for virtual scroll) |virtual-scroll-buffer
| | number | 10 | Buffer size - extra items rendered above/below viewport |
`typescript
// Get/set options
multiselect.options = [
{ value: 'js', label: 'JavaScript' },
{ value: 'ts', label: 'TypeScript' }
];
// Async data loading
multiselect.onSearch = async (searchTerm) => {
const response = await fetch(/api/search?q=${searchTerm});
return await response.json();
};
// Pre-process search terms before calling searchCallback
multiselect.beforeSearchCallback = (searchTerm) => {
// Remove accents: "café" → "cafe"
const normalized = searchTerm.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
// Block search if too short (return null to prevent search)
if (normalized.length < 2) return null;
return normalized; // Return transformed term
};
// Event callbacks
multiselect.onSelect = (option) => {
console.log('Selected:', option);
};
multiselect.onDeselect = (option) => {
console.log('Deselected:', option);
};
multiselect.onChange = (selectedOptions) => {
console.log('Changed:', selectedOptions);
};
// Badge display customization (show different text in badges vs dropdown)
multiselect.getBadgeDisplayCallback = (item) => {
// Show shorter text in badges (e.g., just name instead of "name (email)")
return item.name; // Dropdown might show "John Doe (john@example.com)"
};
// Badge tooltip customization
multiselect.getBadgeTooltipCallback = (item) => {
return ${item.label} - ${item.subtitle};
};
// Action buttons (Select All, Clear All, custom actions)
multiselect.actionButtons = [
{
action: 'select-all',
text: 'Select All',
tooltip: 'Select all items',
cssClass: 'my-custom-class',
isVisibleCallback: (multiselect) => multiselect.getSelected().length < 5 // Hide if 5+ selected
},
{
action: 'clear-all',
text: 'Clear All',
tooltip: 'Clear selection',
isVisible: true, // Static visibility
isDisabled: false // Static disabled state
},
{
action: 'custom',
text: 'Invert',
tooltip: 'Invert selection',
onClick: (multiselect) => {
// Custom action - invert selection
const allValues = multiselect.options.map(opt => opt.value);
const selectedValues = multiselect.getValue();
const inverted = allValues.filter(v => !selectedValues.includes(v));
multiselect.setSelected(inverted);
},
// Dynamic callbacks (take priority over static properties)
isDisabledCallback: (multiselect) => multiselect.getSelected().length === 0,
getTextCallback: (multiselect) => multiselect.getSelected().length > 0 ? 'Invert' : 'Select Items First',
getClassCallback: (multiselect) => multiselect.getSelected().length > 0 ? 'active' : 'inactive'
}
];
// Counter i18n/pluralization
multiselect.getCounterCallback = (count, moreCount) => {
if (moreCount !== undefined) {
return +${moreCount} more; // Partial mode badge${count} selected
}
return ; // Count mode display
};
// Data extraction - Member properties (for simple property names)
multiselect.valueMember = 'id';
multiselect.displayValueMember = 'name';
multiselect.iconMember = 'icon';
multiselect.subtitleMember = 'description';
multiselect.groupMember = 'category';
multiselect.disabledMember = 'isDisabled';
// Data extraction - Callback functions (for complex logic)
multiselect.getValueCallback = (item) => item.id || item.value;
multiselect.getDisplayValueCallback = (item) => item.label || item.name;
multiselect.getSearchValueCallback = (item) => ${item.name} ${item.tags.join(' ')};${item.price} - ${item.stock} in stock
multiselect.getIconCallback = (item) => item.icon || '📄';
multiselect.getSubtitleCallback = (item) => ;
multiselect.getGroupCallback = (item) => item.category;
multiselect.getDisabledCallback = (item) => item.stock === 0;
// Custom rendering - Full HTML control
multiselect.renderGroupLabelContentCallback = (groupName) => {
// Customize group header display (HTML string or HTMLElement)
return 📦 ${groupName.toUpperCase()};
};
multiselect.renderOptionContentCallback = (item, context) => {
// Customize option content (HTML string or HTMLElement)
return ${item.name} ${item.status};
};
multiselect.renderBadgeContentCallback = (item, context) => {
// Customize badge content (HTML string or HTMLElement)
return context.isInPopover
? ${item.icon} ${item.name} - ${item.description}${item.icon} ${item.name}
: ;
};
multiselect.renderSelectedContentCallback = (item) => {
// Customize selected item text in single-select mode (plain text only)
return item.firstName; // Show just first name when closed
};
// Form integration
multiselect.name = 'selected_items';
multiselect.valueFormat = 'json'; // 'json' | 'csv' | 'array'
multiselect.getValueFormatCallback = (values) => values.join('|'); // Custom format
// Read-only properties
const selectedValue = multiselect.selectedValue; // string | number | array | null
const selectedItem = multiselect.selectedItem; // First selected item object
// Add new option callback
multiselect.addNewCallback = async (value) => {
// Validate and create new option
const newOption = await fetch('/api/options', {
method: 'POST',
body: JSON.stringify({ name: value })
}).then(r => r.json());
return newOption;
};
`
| Method | Description |
|--------|-------------|
| getSelected() | Get currently selected options as array of option objects |setSelected(values: (string \| number)[])
| | Set selected values by ID/value |getValue()
| | Get selected value(s) - returns single value in single-select mode, array in multi-select mode |destroy()
| | Clean up and destroy instance |
| Event | Detail | Description |
|-------|--------|-------------|
| select | { option, selectedOptions } | Fired when an option is selected |deselect
| | { option, selectedOptions } | Fired when an option is deselected |change
| | { selectedOptions, selectedValues } | Fired when selection changes |
- ↑ ↓ - Navigate up/down through options
- Ctrl+↑ Ctrl+↓ - Jump between matched items (navigate mode only)
- Enter - Select focused option
- Escape - Close popover → Clear search → Close dropdown (priority order)
- Tab - Close dropdown and move to next field
- Type - Filter options by search term
Icons support multiple formats - emojis, SVG markup, Font Awesome, images, or any HTML:
`html
`
`javascript`
select.options = [
{ value: 'js', label: 'JavaScript', group: 'Frontend' },
{ value: 'ts', label: 'TypeScript', group: 'Frontend' },
{ value: 'python', label: 'Python', group: 'Backend' },
{ value: 'java', label: 'Java', group: 'Backend' }
];
`html
min-search-length="2"
loading-message="Searching..."
empty-message="No products found">
`
Show popular items initially, then switch to full database search when the user types. Perfect for showing "Top 10" items while supporting comprehensive search:
`html
min-search-length="3"
keep-options-on-search="true">
`
How it works:
1. Dropdown opens → Shows 5 popular frameworks
2. User types "rea" → Calls API, shows all matching results from database
3. User clears search → Shows 5 popular frameworks again
4. User types "café" → beforeSearchCallback converts to "cafe", then searches
Key options:
- keep-options-on-search="true" (default) - Keep initial options visible when search is empty/shortbeforeSearchCallback
- - Transform search text or block search by returning nullmin-search-length
- - Minimum characters before triggering search (shows initial options below this)
Handle 10,000+ options with smooth 60fps performance by rendering only visible items:
`html
enable-virtual-scroll="true"
virtual-scroll-threshold="100"
option-height="50"
virtual-scroll-buffer="10"
search-mode="filter"
max-height="400px">
`
Performance Comparison (15,000 items):
| Metric | Without Virtual Scroll | With Virtual Scroll | Improvement |
|--------|------------------------|---------------------|-------------|
| Initial render | 750ms | 30ms | 25× faster |
| Search keystroke | 200-500ms | 15ms | 13-33× faster |
| DOM nodes | 15,000 | ~30 | 99.8% reduction |
| Memory usage | ~7.5 MB | ~15 KB | 500× less |
Configuration:
- enable-virtual-scroll="true" - Enable virtual scrolling (default: false)virtual-scroll-threshold="100"
- - Auto-activate when this many items are present (default: 100)option-height="50"
- - Fixed height per option in pixels (default: 50px)virtual-scroll-buffer="10"
- - Extra items rendered above/below viewport for smooth scrolling (default: 10)
How it works:
- Only renders ~30 visible items instead of all 15,000 DOM elements
- Uses absolute positioning with calculated offsets
- Maintains 10-item buffer zones above/below viewport for smooth scrolling
- Automatically calculates visible range based on scroll position
- Works seamlessly with search filtering and selection
Requirements:
- All options must have the same fixed height (enforced via CSS)
- Not compatible with grouped options (automatically falls back to normal rendering)
- Works with both filter and navigate search modes
Example with search:
`html
enable-virtual-scroll="true"
search-mode="filter"
value-member="id"
display-value-member="name"
max-height="400px">
`
Live Demo:
See examples-performance.html for a working demo with 15,000 randomly generated options.
Handle massive datasets (10,000+ items) with instant performance using virtual scrolling. Only visible items (~30) are rendered in the DOM, dramatically reducing memory usage and improving responsiveness.
Enable virtual scrolling:
`html`
virtual-scroll-threshold="100"
option-height="50"
virtual-scroll-buffer="10">
Performance improvements with 15,000 items:
- Dropdown opening: 750ms → 30ms (25× faster)
- Search performance: 200-500ms → 15ms per keystroke (13-33× faster)
- Memory usage: 7.5 MB → 15 KB (99.8% reduction)
- DOM nodes: 15,000 → ~30 visible items
Configuration:
- enable-virtual-scroll="true" - Opt-in to virtual scrollingvirtual-scroll-threshold="100"
- - Auto-activates at 100+ items (default)option-height="50"
- - Fixed height per option in pixels (default: 50px)virtual-scroll-buffer="10"
- - Extra items rendered above/below viewport (default: 10)
Features:
- Full keyboard navigation (arrows, Page Up/Down, Home/End)
- Smooth mouse wheel scrolling
- Drag scrollbar support
- Works with search in both filter and navigate modes
- Automatic activation based on threshold
Limitations:
- Groups (
Live Demo:
See examples-performance.html for a working demo testing virtual scroll with 15,000 randomly generated options.
Choose between two search behaviors:
Filter Mode (default) - Hide non-matching options as you type:
`html`
Navigate Mode - Keep all options visible, jump to matches:
`html
`
When to use each mode:
- Filter Mode: Large datasets where narrowing down is essential (product catalogs, user lists, search results)
- Navigate Mode: Quick selection from familiar lists (countries, states, keyboard shortcuts, known options)
Key differences:
- Filter mode hides non-matches, navigate mode highlights matches with a left border
- Navigate mode keeps previous focus if no match is found (type "xyz" → stays on current option)
- Navigate mode only works with local data (automatically falls back to filter mode when using searchCallback)beforeSearchCallback
- Both modes respect for search term preprocessing (accent removal, validation)
- Ctrl+↑/↓ jumps between matches only (navigate mode) - regular arrows navigate through all items
Perfect for different use cases and space constraints:
`html
badges-threshold-mode="count"
show-counter="true">
badges-threshold-mode="partial"
badges-max-visible="3">
`
Display Mode Behavior:
- pills: Individual removable badges for each selected item. Calls getBadgeDisplayCallback for each item.count
- : Shows "X selected" text with clear button. Calls getCounterCallback(count).compact
- : Shows first item + count in single badge (e.g., "JavaScript (+2 more)"). Calls getBadgeDisplayCallback(firstItem) and getCounterCallback(count, remainingCount).partial
- : Shows first N badges + "+X more" badge. Calls getBadgeDisplayCallback for visible items and getCounterCallback(count, remainingCount) for badge.none
- : No display in badges area. No callbacks invoked. Use with show-counter="true" for minimal UI.
Badge Styling:
- Data badges (selected items like "JavaScript", "Python"): Blue styling by default
- BadgeCounters ("+3 more", "5 selected", compact mode display): Gray styling to distinguish from data
- Both can be customized via CSS variables (see --ms-badge- and --ms-badge-counter-)
Counter (show-counter="true"): Independent feature showing [X] next to toggle icon. Works with all display modes. Not affected by callbacks.
Control where selected item badges appear relative to the input:
`html
`
Inline Vertical Alignment: For left/right positioning, control vertical alignment with --ms-inline-align:
`html
`
Note: In RTL mode, left/right positions are automatically mirrored - badges-position="left" will appear on the physical right side in RTL languages.
Enable tooltips on selected item badges with customizable placement and delay:
`html
badge-tooltip-placement="top">
badge-tooltip-delay="100">
`
Customize counter text for proper pluralization and localization:
`html
badges-threshold="5"
badges-threshold-mode="partial"
badges-max-visible="3">
`
Full RTL support for Arabic, Hebrew, Persian, Urdu, and other right-to-left languages with automatic detection and complete UI mirroring:
`html
`
RTL Features:
- ✅ Auto-detection - Detects dir="rtl" on component or any ancestor elementbadges-position="left"
- ✅ Complete UI mirroring - Toggle icon, text alignment, badges, dropdown, badges
- ✅ Logical positioning - becomes physically right in RTLdir="rtl"
- ✅ Badge remove buttons - Flip to left side in RTL mode
- ✅ Text direction - All text content properly right-aligned
- ✅ No configuration needed - Just set attribute
The component provides powerful custom rendering callbacks that allow you to fully customize how options, badges, and selected items are displayed while maintaining the component's structure and functionality.
#### Overview
Three rendering callbacks are available:
- renderOptionContentCallback - Customize dropdown option content
- renderBadgeContentCallback - Customize badge (selected item) content
- renderSelectedContentCallback - Customize selected value text (single-select mode)
All callbacks can return either HTML strings or HTMLElement objects (except renderSelectedContentCallback which returns plain text).
#### HTML Injection (XSS) Notice
The following callbacks allow raw HTML injection and are intentionally NOT XSS-safe. This gives developers full control over rendering but requires sanitizing untrusted data:
| Callback | Output Used In | Risk Level |
|----------|---------------|------------|
| renderOptionContentCallback | Dropdown options (innerHTML) | HTML injection |renderBadgeContentCallback
| | Badges (innerHTML) | HTML injection |renderSelectedItemContentCallback
| | Selected items popover (innerHTML) | HTML injection |renderGroupLabelContentCallback
| | Group headers (innerHTML) | HTML injection |getIconCallback
| | Option icons (innerHTML) | HTML injection |getSubtitleCallback
| | Option subtitles (innerHTML) | HTML injection |getDisplayValueCallback
| | Option titles, badges (innerHTML) | HTML injection |getBadgeDisplayCallback
| | Badge text (innerHTML) | HTML injection |getCounterCallback
| | Count badges (innerHTML) | HTML injection |getBadgeTooltipCallback
| | Tooltips (innerHTML if HTMLElement) | HTML injection |customStylesCallback
| | Style tag (textContent) | CSS injection |
Safe callbacks (output is escaped or used as data):
- getValueCallback, getSearchValueCallback, getGroupCallback, getDisabledCallbackgetBadgeClassCallback
- , getSelectedItemClassCallback (CSS class names only)beforeSearchCallback
- , searchCallback, addNewCallbackselectCallback
- , deselectCallback, changeCallbackgetRemoveButtonTooltipCallback
- (used as title attribute)getValueFormatCallback
- (form value)
If displaying user-generated content, sanitize it before returning from these callbacks.
#### Custom Option Rendering
Customize how options appear in the dropdown:
`html
`
Context object (OptionContentRenderContext):index: number
- - Index of the option in the filtered listisSelected: boolean
- - Whether the option is currently selectedisFocused: boolean
- - Whether the option is currently focused (keyboard navigation)isMatched: boolean
- - Whether the option matches the current search term (navigate mode only)isDisabled: boolean
- - Whether the option is disabled
#### Custom Badge Rendering
Customize how selected items appear as badges:
`javascript
const select = document.querySelector('web-multiselect');
select.options = [
{ id: 1, name: 'John Doe', role: 'Admin', avatar: '👨💼' },
{ id: 2, name: 'Jane Smith', role: 'Developer', avatar: '👩💻' },
{ id: 3, name: 'Bob Johnson', role: 'Designer', avatar: '🎨' }
];
// Custom badge rendering in main badges area
select.renderBadgeContentCallback = (item, context) => {
// Compact view in badges area
return ${item.avatar} ${item.name};
};
// Custom rendering for selected items popover (separate callback)
select.renderSelectedItemContentCallback = (item) => {
// Full details in popover - has more space
return
;
};
`Separate Callbacks for Badges vs. Popover:
-
renderBadgeContentCallback - Renders badges in the main badges area (compact display)
- renderSelectedItemContentCallback - Renders items in the selected items popover (can be more detailed)
- If renderSelectedItemContentCallback is not defined, falls back to renderBadgeContentCallback
- Users can assign the same function to both if identical rendering is desiredContext object (
BadgeContentRenderContext for renderBadgeContentCallback):
- displayMode: BadgesDisplayMode - Current badges display mode ('pills', 'count', 'compact', 'partial', 'none')
- isInPopover: boolean - Whether the badge is being rendered in the selected items popover (always false for this callback)#### Custom Group Label Rendering
Customize how group headers are displayed using
renderGroupLabelContentCallback:`javascript
const select = document.querySelector('web-multiselect');select.options = [
{ value: 'react', label: 'React', group: 'frontend' },
{ value: 'vue', label: 'Vue', group: 'frontend' },
{ value: 'nodejs', label: 'Node.js', group: 'backend' },
{ value: 'postgres', label: 'PostgreSQL', group: 'database' }
];
select.isGroupsAllowed = true;
select.groupMember = 'group';
// Customize group label display
select.renderGroupLabelContentCallback = (groupName) => {
const emojis = {
'frontend': '🎨',
'backend': '🔧',
'database': '🗄️'
};
const emoji = emojis[groupName] || '📦';
return
${emoji} ${groupName.toUpperCase()};
};
`Signature:
(groupName: string) => string | HTMLElementUse cases:
- Capitalize or format group names
- Add icons, emojis, or badges to group headers
- Apply HTML formatting (bold, colors, etc.)
- Internationalization (i18n) - translate group names
- Add group-specific metadata or counts
Notes:
- Keeps standard
.ms__group-label wrapper for consistent styling
- Can return HTML string or HTMLElement
- Group name is passed as a string parameter#### Custom Badge Styling with CSS Classes
Add custom CSS classes to badges based on item data for semantic styling:
`javascript
const select = document.querySelector('web-multiselect');select.options = [
{ id: 1, task: 'Fix security bug', priority: 'urgent' },
{ id: 2, task: 'Update docs', priority: 'normal' },
{ id: 3, task: 'Refactor code', priority: 'low' }
];
// Add CSS class based on priority
select.getBadgeClassCallback = (item) => {
return
badge-${item.priority}; // Returns 'badge-urgent', 'badge-normal', etc.
};// Can also return array of classes
select.getBadgeClassCallback = (item) => {
const classes = [
badge-${item.priority}];
if (item.urgent) classes.push('badge-blink');
return classes;
};
`Then style with CSS:
`css
/ Target specific badges with custom classes /
.badge-urgent {
--ms-badge-text-bg: #fee2e2;
--ms-badge-text-color: #dc2626;
--ms-badge-remove-bg: #dc2626;
}.badge-normal {
--ms-badge-text-bg: #dbeafe;
--ms-badge-text-color: #2563eb;
--ms-badge-remove-bg: #2563eb;
}
.badge-low {
--ms-badge-text-bg: #d1fae5;
--ms-badge-text-color: #059669;
--ms-badge-remove-bg: #059669;
}
`The callback:
- Takes the item as a parameter
- Returns a string (single class) or array of strings (multiple classes)
- Classes are added to the badge's base
.ml__badge element
- Works across all rendering locations (main badges, partial mode, popover)Separate Class Callbacks for Badges vs. Popover:
Similar to rendering callbacks, you can use different class callbacks for badges and selected items:
`javascript
// Add classes to badges in main area
select.getBadgeClassCallback = (item) => {
return badge-${item.priority};
};// Add different/additional classes to selected items in popover
select.getSelectedItemClassCallback = (item) => {
// Could add more detailed classes for popover items
return [
badge-${item.priority}, 'badge-detailed'];
};
`-
getBadgeClassCallback - Adds classes to badges in the main badges area
- getSelectedItemClassCallback - Adds classes to items in the selected items popover
- If getSelectedItemClassCallback is not defined, falls back to getBadgeClassCallback
- Users can assign the same function to both if identical styling is desiredShadow DOM CSS Injection:
Since the component uses Shadow DOM, regular page CSS cannot style shadow elements. Use
customStylesCallback to inject CSS directly into the Shadow DOM:`javascript
const select = document.querySelector('web-multiselect');// Add CSS classes to badges based on item data
select.getBadgeClassCallback = (item) => {
return
badge-${item.priority};
};// Inject CSS into Shadow DOM to style those classes
select.customStylesCallback = () =>
.badge-normal {
--ms-badge-text-bg: #dbeafe;
--ms-badge-text-color: #2563eb;
--ms-badge-remove-bg: #2563eb;
}
.badge-low {
--ms-badge-text-bg: #d1fae5;
--ms-badge-text-color: #059669;
--ms-badge-remove-bg: #059669;
};`
The customStylesCallback:getBadgeClassCallback
- Returns a CSS string (not HTML)
- Styles are injected into the Shadow DOM on initialization
- Can be updated dynamically - new styles replace old ones
- Works with all custom classes (from , renderOptionContentCallback, etc.)
#### Custom Selected Item Rendering (Single-Select)
Customize the text shown in the input field when in single-select mode:
`javascript
const select = document.querySelector('web-multiselect[multiple="false"]');
select.options = [
{ id: 1, firstName: 'John', lastName: 'Doe', email: 'john@example.com' },
{ id: 2, firstName: 'Jane', lastName: 'Smith', email: 'jane@example.com' }
];
// Show just first name when closed
select.renderSelectedContentCallback = (item) => {
return item.firstName; // Returns plain text (not HTML)
};
// While dropdown shows full details
select.getDisplayValueCallback = (item) => {
return ${item.firstName} ${item.lastName} (${item.email});`
};
#### Conditional Rendering Example
Use JavaScript logic for conditional rendering:
`javascript
select.renderOptionContentCallback = (item, context) => {
const classes = [];
if (context.isSelected) classes.push('selected');
if (context.isFocused) classes.push('focused');
return
${item.description}
: ''} : ''};
};
`#### Returning HTMLElement
You can also return DOM elements for more complex rendering:
`javascript
select.renderOptionContentCallback = (item, context) => {
const div = document.createElement('div');
div.style.display = 'flex';
div.style.alignItems = 'center';
div.style.gap = '0.5rem'; const img = document.createElement('img');
img.src = item.avatarUrl;
img.style.width = '32px';
img.style.height = '32px';
img.style.borderRadius = '50%';
const span = document.createElement('span');
span.textContent = item.name;
div.appendChild(img);
div.appendChild(span);
return div; // Return HTMLElement instead of string
};
`#### Virtual Scroll Compatibility
When using
renderOptionContentCallback with virtual scroll enabled:⚠️ Important: Custom option content must fit within the configured
optionHeight (default: 50px)`html
id="large-dataset"
enable-virtual-scroll="true"
option-height="60">
`Virtual scroll requirements:
- Content height must be fixed and match
optionHeight
- Overflow will be clipped
- Variable-height content only works in non-virtual mode#### Callback Priority
The component uses a fallback chain when callbacks are not provided:
For options:
1.
renderOptionContentCallback (full HTML control)
2. Default: icon + getDisplayValueCallback + subtitleFor badges:
1.
renderBadgeContentCallback (full HTML control)
2. getBadgeDisplayCallback (text only)
3. getDisplayValueCallback (text only)For selected item (single-select):
1.
renderSelectedContentCallback (text only)
2. getDisplayValueCallback (text only)#### Checkbox Control
Control checkbox appearance and alignment with CSS variables and attributes:
Checkbox Alignment (via attribute):
`html
`Checkbox Size/Scale (via CSS):
`html
`CSS Grid/Flexbox in Custom Content:
Custom rendering callbacks support full CSS layout control:
`javascript
// CSS Grid example
multiselect.renderOptionContentCallback = (item, context) => {
return ;
};// Flexbox example
multiselect.renderOptionContentCallback = (item, context) => {
return
;
};
`Available CSS Variables:
-
--ms-checkbox-size: Checkbox width/height (default: 16px)
- --ms-checkbox-scale: Scale multiplier (default: 1)
- --ms-checkbox-margin-top: Top margin for vertical alignment (default: 0.125rem)
- --ms-checkbox-margin-right: Right margin (default: 0)
- --ms-checkbox-margin-bottom: Bottom margin (default: 0)
- --ms-checkbox-margin-left: Left margin (default: 0)
- --ms-checkbox-align: Alignment value (default: flex-start)
- --ms-option-gap: Gap between checkbox and content (default: 0.5rem)Note: Horizontal and bottom margins default to
0 since spacing is handled by flexbox gap. Override for custom layouts.$3
The component supports any data structure through a member/callback pattern, allowing you to work with custom objects, tuple arrays, or existing API responses without transformation.
#### Member Properties (Simple Property Names)
For objects with consistent property names, use member attributes:
`html
id="products"
value-member="productId"
display-value-member="productName"
icon-member="icon"
subtitle-member="description"
group-member="category">
`#### Callback Functions (Complex Logic)
For complex data extraction or conditional logic, use callbacks:
`javascript
const select = document.querySelector('web-multiselect');// Custom value extraction
select.getValueCallback = (item) => item.id || item.code || item.value;
// Combine multiple fields for display
select.getDisplayValueCallback = (item) => {
return
${item.firstName} ${item.lastName};
};// Include multiple fields in search
select.getSearchValueCallback = (item) => {
return
${item.name} ${item.sku} ${item.tags.join(' ')};
};// Conditional icons
select.getIconCallback = (item) => {
return item.inStock ? '✅' : '❌';
};
// Dynamic subtitles
select.getSubtitleCallback = (item) => {
return
$${item.price} - ${item.stock} in stock;
};// Disable based on conditions
select.getDisabledCallback = (item) => {
return item.stock === 0 || item.discontinued;
};
// Customize badge display (show different text in badges vs dropdown)
select.getBadgeDisplayCallback = (item) => {
// Badges show just the name for space efficiency
return item.name;
// While dropdown can show full details: "Laptop - $999 - Electronics"
};
`#### Tuple Array Auto-Detection
The component automatically detects
[key, value] tuple arrays:`javascript
select.options = [
['js', 'JavaScript'],
['ts', 'TypeScript'],
['py', 'Python']
];
// First element becomes value, second becomes display text
`#### Priority Order
When multiple extraction methods are defined, the component uses this priority:
1. Callbacks (highest priority) -
getValueCallback, getDisplayValueCallback, etc.
2. Member properties - valueMember, displayValueMember, etc.
3. Default properties (lowest priority) - Falls back to value, label, name, etc.#### TypeScript Support
The component is fully typed with generics:
`typescript
import type { MultiSelectElement } from '@keenmate/web-multiselect';interface Product {
id: string;
name: string;
price: number;
category: string;
}
const select = document.querySelector>('web-multiselect');
select.options = [
{ id: 'p1', name: 'Laptop', price: 999, category: 'Electronics' }
];
`$3
The component seamlessly integrates with standard HTML forms by automatically creating hidden inputs in the light DOM (outside Shadow DOM) so FormData can access them.
#### Basic Form Integration
`html
`#### Value Formats
Choose how selected values are serialized in forms:
JSON Format (default):
`html
`CSV Format:
`html
`Array Format (multiple inputs):
`html
`#### Custom Value Formatting
For advanced use cases, provide a custom formatting function:
`javascript
const select = document.querySelector('web-multiselect');select.name = 'product_ids';
select.getValueFormatCallback = (values) => {
// Custom format: pipe-separated with prefix
return values.map(v =>
ID:${v}).join('|');
};// When submitted, FormData will have:
// product_ids = "ID:123|ID:456|ID:789"
`#### Using getValue() for JavaScript Submissions
For JavaScript-based form submissions (AJAX, fetch), use
getValue():`javascript
// Single-select mode
const select = document.querySelector('multi-select[multiple="false"]');
const selectedId = select.getValue();
// Returns: "js" or null// Multi-select mode
const multiSelect = document.querySelector('multi-select[multiple="true"]');
const selectedIds = multiSelect.getValue();
// Returns: ["js", "ts", "py"] or []
// Submit with fetch
const response = await fetch('/api/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
skills: multiSelect.getValue()
})
});
`#### Working with Numeric Values
The component handles both string and numeric values correctly:
`javascript
select.options = [
{ value: 1, label: 'Option 1' },
{ value: 2, label: 'Option 2' },
{ value: 3, label: 'Option 3' }
];// getValue() preserves types
const values = select.getValue();
// Returns: [1, 2, 3] (numbers, not strings)
// FormData serialization
// JSON format: [1,2,3]
// CSV format: 1,2,3
// Array format: items[]=1, items[]=2, items[]=3
`$3
`javascript
select.options = [
{ value: 'basic', label: 'Basic License', subtitle: 'Free forever' },
{ value: 'pro', label: 'Pro License', subtitle: 'Available for purchase' },
{
value: 'enterprise',
label: 'Enterprise License',
subtitle: 'Contact sales',
disabled: true
}
];
`Option Structure
`typescript
interface MultiSelectOption {
value: string; // Required: Unique identifier
label: string; // Required: Display text
icon?: string; // Optional: Icon or emoji
subtitle?: string; // Optional: Subtitle/description
group?: string; // Optional: Group name
disabled?: boolean; // Optional: Disable selection
}
`Styling
The component uses Shadow DOM for style encapsulation, but exposes CSS custom properties (CSS variables) that you can override to customize the appearance.
$3
The component uses
--ms-rem as a base unit for proportional scaling. Default is 10px, meaning calc(1.4 * var(--ms-rem)) equals 14px.Global Scaling:
`html
`Via CSS class:
`css
web-multiselect.compact { --ms-rem: 8px; }
web-multiselect.large { --ms-rem: 12px; }
`Shadow DOM Note: CSS variables must be set on the
element itself, not on wrapper divs.Fine-grained Control:
Override individual sizing variables for specific adjustments:
-
--ms-input-height - Input field height (default: 35px)
- --ms-input-font-size - Input font size
- --ms-input-padding - Input padding
- --ms-badge-height - Badge height
- --ms-option-height - Option height in dropdownInput Size Variants:
Five size variants for consistent input sizing across KeenMate components:
| Size | Variable | Height | Base Variable |
|------|----------|--------|---------------|
| XS |
--ms-input-size-xs-height | 31px | --base-input-size-xs-height |
| SM | --ms-input-size-sm-height | 33px | --base-input-size-sm-height |
| MD | --ms-input-size-md-height | 35px | --base-input-size-md-height |
| LG | --ms-input-size-lg-height | 38px | --base-input-size-lg-height |
| XL | --ms-input-size-xl-height | 41px | --base-input-size-xl-height |Heights reference
--base-input-size-*-height from the Theme Designer, ensuring consistent input heights across all KeenMate components.`css
/ Set consistent input heights across all components /
:root {
--base-input-size-md-height: 4.0; / All components: 40px at 10px rem /
}
`$3
The easiest way to customize the appearance of this component is using the KeenMate Theme Designer at:
#### How It Works
1. Choose 3 base colors - background, text, and accent
2. Preview changes live - see your theme applied instantly
3. Fine-tune individual variables - lock specific values while adjusting others
4. Export your theme - copy CSS, JSON, or SCSS to your project
#### CSS Variable Layers
KeenMate components support a two-layer theming architecture:
Standalone Mode (Simple) - Just override the component-specific variables you need:
`css
:root {
--ms-accent-color: #your-brand-color;
--ms-primary-bg: #your-background;
--ms-text-primary: #your-text-color;
}
`Cascading Mode (Multi-Component) - When using multiple KeenMate components, you can define a shared base layer:
`css
:root {
/ Base layer - single source of truth /
--base-accent-color: #3b82f6;
--base-primary-bg: #ffffff;
--base-text-primary: #111827; / Components reference base layer /
--ms-accent-color: var(--base-accent-color);
--drp-accent-color: var(--base-accent-color);
}
`Change
--base-accent-color once → all components update automatically.#### Unified Variable Naming
All KeenMate components follow a consistent naming convention for Tier 1 variables (core theming):
| Purpose | web-multiselect | web-daterangepicker |
|---------|-----------------|---------------------|
| Brand color |
--ms-accent-color | --drp-accent-color |
| Background | --ms-primary-bg | --drp-primary-bg |
| Text color | --ms-text-primary | --drp-text-primary |
| Text on accent | --ms-text-color-on-accent | --drp-text-on-accent |
| Border color | --ms-border-color | --drp-border-color |Learn the pattern once, apply it across all components.
#### Component Variables Manifest
This package exports a
component-variables.manifest.json file that documents all supported CSS variables for tooling integration (e.g., Theme Designer, IDE autocomplete):`javascript
import manifest from '@keenmate/web-multiselect/component-variables.manifest.json';
// manifest.baseVariables - list of --base-* variables the component responds to
// manifest.componentVariables - list of --ms-* component-specific variables
`$3
You can customize the component using CSS variables even with just a