A modern, feature-rich, and fully accessible select component for Svelte 5 applications. Built with react-select API compatibility, virtual scrolling, drag-drop, fuzzy search, and WCAG AAA accessibility.
⚠️ v3.x requires Svelte 5! If you're using Svelte 4, use v2.2.1 instead.
highlightSearchMatch prop (default: true)highlightClassName propmenuPlacement="auto" now properly calculates positionshowOptionDescriptions prop (default: true)description property display subtitle textgetOptionLabel, getOptionValue, isOptionDisabledRequirements: Svelte 5.0.0 or later
Install using npm:
npm i svelte-perfect-select
Install using yarn:
yarn add svelte-perfect-select
Install using pnpm:
pnpm add svelte-perfect-select
<script>
import Select from 'svelte-perfect-select';
let value = $state(null);
let options = [
{id: 'sl', label: 'Sri Lanka', value: 'sl'},
{id: 'ind', label: 'India', value: 'ind'},
{id: 'pak', label: 'Pakistan', value: 'pak'}
];
</script>
<Select {options} bind:value placeholder="Select a country..." />
| Prop | Type | Default | Description |
|---|---|---|---|
options |
Array |
[] |
Array of options. Each option should have id, label, and value properties |
value |
any |
null (single) / [] (multi) |
The selected value(s). Supports two-way binding with bind:value |
placeholder |
string |
"Select..." |
Placeholder text when no option is selected |
selectSize |
string |
"medium" |
Size variant: "smaller", "small", "medium", "large", "larger" |
maxHeight |
string |
"300px" |
Maximum height of the dropdown |
name |
string |
"svelte-perfect-select" |
Name attribute for form compatibility |
id |
string |
"svelte-perfect-select" |
ID attribute for form compatibility |
| Prop | Type | Default | Description |
|---|---|---|---|
isMulti / multiple |
boolean |
false |
Enable multi-select mode |
isSearchable / searchable |
boolean |
true |
Enable/disable search functionality |
isClearable / clearable |
boolean |
true |
Show/hide the clear button |
isDisabled / disabled |
boolean |
false |
Disable the entire select component |
isLoading / loading |
boolean |
false |
Show loading spinner |
isRtl |
boolean |
false |
Enable right-to-left mode |
closeMenuOnSelect |
boolean |
!multiple |
Close menu when an option is selected |
hideSelectedOptions |
boolean |
false |
Hide already selected options in multi-select |
| Prop | Type | Default | Description |
|---|---|---|---|
loadOptions |
Function |
null |
Async function to load options: (inputValue) => Promise<options> |
cacheOptions |
boolean |
true |
Cache loaded options for better performance |
defaultOptions |
boolean |
false |
Load default options on mount |
isCreatable |
boolean |
false |
Allow users to create new options |
allowCreateWhileLoading |
boolean |
false |
Allow creation during async loading |
createOptionPosition |
string |
"last" |
Position of create option: "first" or "last" |
formatCreateLabel |
Function |
(inputValue) => "Create \"inputValue\"" |
Format the create option label |
| Prop | Type | Default | Description |
|---|---|---|---|
theme |
string |
"blue" |
Color theme: "blue", "purple", "green", "red", "orange", "pink", "dark" |
containerSize |
string |
"md" |
Physical container size: "xs", "sm", "md", "lg", "xl" |
borderRadius |
string |
"8px" |
Border radius for modern look (e.g., "4px", "12px", "16px") |
customStyles |
object |
{} |
Custom style overrides: { container, control, menu, option, tag } |
| Prop | Type | Default | Description |
|---|---|---|---|
getOptionLabel |
Function |
(option) => option.label || option.value |
Extract label from option object |
getOptionValue |
Function |
(option) => option.id || option.value |
Extract value from option object |
isOptionDisabled |
Function |
(option) => option.disabled || false |
Determine if option is disabled |
filterOption |
Function |
null |
Custom filter function: (option, inputValue) => boolean |
noOptionsMessage |
Function |
() => "No options" |
Message when no options available |
loadingMessage |
Function |
() => "Loading..." |
Message during async loading |
| Prop | Type | Default | Description |
|---|---|---|---|
menuPlacement |
string |
"auto" |
Menu placement: "auto", "top", "bottom" |
menuPosition |
string |
"absolute" |
Menu position: "absolute", "fixed" |
autoFocus |
boolean |
false |
Auto focus on mount |
openMenuOnFocus |
boolean |
false |
Open menu when focused |
openMenuOnClick |
boolean |
true |
Open menu on click |
tabSelectsValue |
boolean |
true |
Select highlighted option on Tab |
backspaceRemovesValue |
boolean |
true |
Remove last value on Backspace in multi-select |
escapeClearsValue |
boolean |
false |
Clear value on Escape |
| Prop | Type | Default | Description |
|---|---|---|---|
maxSelected |
number |
null |
Maximum number of selections allowed in multi-select mode |
maxSelectedMessage |
Function |
(max) => "Maximum ${max} items can be selected" |
Message when max selections reached |
maxTagsDisplay |
number |
null |
Maximum tags to display before showing "+X more" |
showTagCount |
boolean |
true |
Show "+X more" badge when tags exceed maxTagsDisplay |
validationState |
string |
null |
Validation state: "error", "success", "warning", or null |
validationMessage |
string |
"" |
Validation message to display below the select |
showCheckboxes |
boolean |
false |
Show checkboxes for multi-select options |
usePortal |
boolean |
false |
Render dropdown in a portal (document.body) |
portalTarget |
HTMLElement |
null |
Custom portal target element |
loadMoreOptions |
Function |
null |
Function for infinite scroll: () => Promise<options> |
hasMore |
boolean |
false |
Whether more options are available for infinite scroll |
loadingMore |
boolean |
false |
Loading state for infinite scroll |
| Prop | Type | Default | Description |
|---|---|---|---|
enableVirtualScroll |
boolean |
true |
Enable virtual scrolling for 10,000+ options |
virtualScrollOverscan |
number |
5 |
Extra items to render outside viewport |
enableDragDrop |
boolean |
false |
Enable drag & drop tag reordering |
commandPaletteMode |
boolean |
false |
Enable command palette (Cmd+K) mode |
commandPaletteKey |
string |
"k" |
Key for command palette shortcut |
enableFuzzySearch |
boolean |
false |
Enable fuzzy search algorithm |
fuzzySearchThreshold |
number |
0.6 |
Fuzzy match threshold (0-1) |
enableCopyPaste |
boolean |
true |
Enable copy/paste for multi-select |
pasteDelimiter |
string |
"," |
Delimiter for paste: "," or "newline" |
touchOptimized |
boolean |
true |
Enable touch optimizations |
swipeToRemove |
boolean |
true |
Enable swipe-to-remove tags |
collapsibleGroups |
boolean |
false |
Enable collapsible option groups |
defaultGroupsExpanded |
boolean |
true |
Initial group state |
useSpringAnimations |
boolean |
false |
Use spring physics animations |
springStiffness |
number |
0.3 |
Spring stiffness (higher = snappier) |
springDamping |
number |
0.7 |
Spring damping (higher = less bouncy) |
keyboardShortcuts |
object |
{} |
Custom keyboard shortcuts: { "Ctrl+A": (e) => {...} } |
enhancedAccessibility |
boolean |
true |
Enable WCAG AAA features |
announceChanges |
boolean |
true |
Screen reader announcements |
optionTemplate |
Snippet |
null |
Custom option template (Svelte 5 snippet) |
tagTemplate |
Snippet |
null |
Custom tag template (Svelte 5 snippet) |
noOptionsTemplate |
Snippet |
null |
Custom empty state template (Svelte 5 snippet) |
| Prop | Type | Default | Description |
|---|---|---|---|
highlightSearchMatch |
boolean |
true |
Highlight matched text in option labels |
highlightClassName |
string |
"search-highlight" |
CSS class for highlighted text |
showOptionDescriptions |
boolean |
true |
Show option.description if available |
Note: In v3.0.0, events use callback props instead of
on:eventsyntax.
| Callback | Parameters | Description |
|---|---|---|
onChange |
{ value, option, action } |
Called when selection changes |
onInputChange |
{ value, action } |
Called when search input changes |
onFocus |
- | Called when component receives focus |
onBlur |
- | Called when component loses focus |
onMenuOpen |
- | Called when dropdown menu opens |
onMenuClose |
- | Called when dropdown menu closes |
onCreateOption |
{ option } |
Called when a new option is created |
onOptionsLoaded |
{ options } |
Called when async options load |
onLoadError |
{ error } |
Called when async loading fails |
onMaxSelected |
{ max, message } |
Called when max selection reached |
onClear |
- | Called when clear button clicked |
onKeyboardShortcut |
{ key, event } |
Called on custom keyboard shortcut (v3.0.0) |
{
id: 'unique-id', // Optional: Unique identifier (auto-generated if missing)
label: 'Display Text', // Optional: Text to display (uses value if missing)
value: 'option-value', // Required: Value to be stored when selected
description: 'Optional', // Optional: Additional description text
disabled: false, // Optional: Disable this specific option
icon: '🎨', // Optional: Icon (string URL or HTML/SVG)
badge: 'New', // Optional: Badge text
badgeColor: '#10B981', // Optional: Badge background color
group: 'Category' // Optional: Group name for grouping
}
<script>
import Select from 'svelte-perfect-select';
let selectedCountry = $state(null);
let countries = [
{id: 'us', label: 'United States', value: 'us'},
{id: 'uk', label: 'United Kingdom', value: 'uk'},
{id: 'ca', label: 'Canada', value: 'ca'}
];
function handleChange(event) {
console.log('Selected:', event);
}
</script>
<Select
options={countries}
bind:value={selectedCountry}
placeholder="Choose a country..."
onChange={handleChange}
/>
<script>
import Select from 'svelte-perfect-select';
let selectedSkills = $state([]);
let skills = [
{id: 'js', label: 'JavaScript', value: 'js'},
{id: 'py', label: 'Python', value: 'py'},
{id: 'go', label: 'Go', value: 'go'},
{id: 'rust', label: 'Rust', value: 'rust'}
];
</script>
<Select
options={skills}
bind:value={selectedSkills}
multiple={true}
enableDragDrop={true}
placeholder="Select and reorder skills..."
/>
<p>Selected: {selectedSkills.join(', ')}</p>
<script>
import Select from 'svelte-perfect-select';
// Generate 10,000 options
let largeDataset = $state(
Array.from({ length: 10000 }, (_, i) => ({
id: `option-${i}`,
label: `Option ${i + 1}`,
value: `opt-${i}`
}))
);
let selected = $state(null);
</script>
<!-- Virtual scrolling handles this smoothly! -->
<Select
options={largeDataset}
bind:value={selected}
enableVirtualScroll={true}
virtualScrollOverscan={10}
placeholder="Search 10,000 options..."
/>
<script>
import Select from 'svelte-perfect-select';
let frameworks = [
{id: 'react', label: 'React', value: 'react'},
{id: 'svelte', label: 'Svelte', value: 'svelte'},
{id: 'vue', label: 'Vue.js', value: 'vue'}
];
</script>
<!-- Try typing "rct" - it will match "React"! -->
<Select
options={frameworks}
enableFuzzySearch={true}
fuzzySearchThreshold={0.6}
placeholder="Try fuzzy search..."
/>
<script>
import Select from 'svelte-perfect-select';
let commands = [
{id: 'new', label: 'New File', value: 'new', icon: '📄'},
{id: 'open', label: 'Open File', value: 'open', icon: '📂'},
{id: 'save', label: 'Save', value: 'save', icon: '💾'}
];
</script>
<!-- Press Cmd/Ctrl+K to open! -->
<Select
options={commands}
commandPaletteMode={true}
commandPaletteKey="k"
showOptionIcons={true}
placeholder="Press Cmd+K..."
/>
<script>
import Select from 'svelte-perfect-select';
let selectedTags = $state([]);
let tags = [
{id: 'js', label: 'JavaScript', value: 'JavaScript'},
{id: 'ts', label: 'TypeScript', value: 'TypeScript'},
{id: 'py', label: 'Python', value: 'Python'}
];
</script>
<!-- Try pasting: "JavaScript, TypeScript" -->
<Select
options={tags}
bind:value={selectedTags}
multiple={true}
enableCopyPaste={true}
pasteDelimiter=","
placeholder="Paste comma-separated values..."
/>
<script>
import Select from 'svelte-perfect-select';
let users = [
{id: '1', label: 'John Doe', value: 'john', email: '[email protected]'},
{id: '2', label: 'Jane Smith', value: 'jane', email: '[email protected]'}
];
</script>
<Select
options={users}
placeholder="Select user..."
>
{#snippet optionTemplate(option, isSelected)}
<div style="display: flex; flex-direction: column;">
<span style="font-weight: 500;">{option.label}</span>
<span style="font-size: 0.875em; color: #6B7280;">{option.email}</span>
</div>
{/snippet}
{#snippet tagTemplate(option)}
<span>{option.label} ({option.email})</span>
{/snippet}
</Select>
<script>
import Select from 'svelte-perfect-select';
let languages = [
{id: 'js', label: 'JavaScript', value: 'js', group: 'Frontend'},
{id: 'ts', label: 'TypeScript', value: 'ts', group: 'Frontend'},
{id: 'py', label: 'Python', value: 'py', group: 'Backend'},
{id: 'go', label: 'Go', value: 'go', group: 'Backend'}
];
</script>
<!-- Click group headers to collapse/expand -->
<Select
options={languages}
isGrouped={true}
groupBy={(option) => option.group}
collapsibleGroups={true}
defaultGroupsExpanded={true}
placeholder="Select language..."
/>
<script>
import Select from 'svelte-perfect-select';
let value = $state([]);
let options = [{id: '1', label: 'Option 1', value: '1'}];
let shortcuts = {
'Ctrl+Shift+A': (event) => {
console.log('Custom shortcut triggered!');
}
};
</script>
<Select
{options}
bind:value
multiple={true}
keyboardShortcuts={shortcuts}
onKeyboardShortcut={(e) => console.log('Shortcut:', e.key)}
placeholder="Try Ctrl+Shift+A..."
/>
<script>
import Select from 'svelte-perfect-select';
let value = $state(null);
let options = [
{ id: '1', label: 'JavaScript', value: 'js' },
{ id: '2', label: 'TypeScript', value: 'ts' },
{ id: '3', label: 'Java', value: 'java' }
];
</script>
<Select
{options}
bind:value
highlightSearchMatch={true}
highlightClassName="search-highlight"
placeholder="Type to see highlighting..."
/>
<script>
import Select from 'svelte-perfect-select';
let value = $state(null);
let options = [
{ id: '1', label: 'React', value: 'react', description: 'A JavaScript library for building UIs' },
{ id: '2', label: 'Vue', value: 'vue', description: 'The Progressive JavaScript Framework' },
{ id: '3', label: 'Svelte', value: 'svelte', description: 'Cybernetically enhanced web apps' }
];
</script>
<Select
{options}
bind:value
showOptionDescriptions={true}
placeholder="Select a framework..."
/>
<script>
import Select from 'svelte-perfect-select';
let value = $state(null);
let options = [{id: '1', label: 'Option 1', value: '1'}];
</script>
<!-- Dropdown will open above if near bottom of viewport -->
<Select
{options}
bind:value
menuPlacement="auto"
placeholder="Auto-positioned..."
/>
<script>
import Select from 'svelte-perfect-select';
let selectedCountry = $state(null);
async function loadCountries(inputValue) {
const response = await fetch(`/api/countries?search=${inputValue}`);
const data = await response.json();
return data.map(country => ({
id: country.code,
label: country.name,
value: country.code
}));
}
</script>
<Select
bind:value={selectedCountry}
loadOptions={loadCountries}
defaultOptions={true}
cacheOptions={true}
placeholder="Search countries..."
/>
<script>
import Select from 'svelte-perfect-select';
let selectedCountry = $state(null);
let countries = [
{id: 'us', label: 'United States', value: 'us'},
{id: 'uk', label: 'United Kingdom', value: 'uk'}
];
</script>
<!-- Error state -->
<Select
options={countries}
bind:value={selectedCountry}
validationState="error"
validationMessage="Please select a country"
/>
<!-- Success state -->
<Select
options={countries}
bind:value={selectedCountry}
validationState="success"
validationMessage="Country selected successfully"
/>
<!-- Warning state -->
<Select
options={countries}
bind:value={selectedCountry}
validationState="warning"
validationMessage="This country may have restrictions"
/>
keyboardShortcuts prop (v3.0.0)npm install svelte@^5 @sveltejs/vite-plugin-svelte@^5 vite@^6
<!-- Before (v2.x) -->
<Select on:change={handleChange} on:focus={handleFocus} />
<!-- After (v3.0.0) -->
<Select onChange={handleChange} onFocus={handleFocus} />
<!-- Before (v2.x) -->
<script>
let value = null;
</script>
<!-- After (v3.0.0) - Using Svelte 5 runes -->
<script>
let value = $state(null);
</script>
Replace slots with snippet props. See custom templates example above.
Full TypeScript support included out of the box.
import Select, { type SelectOption, type SelectProps } from 'svelte-perfect-select';
const options: SelectOption[] = [
{ id: '1', label: 'Option 1', value: '1' },
{ id: '2', label: 'Option 2', value: '2' }
];
let selectedValue = $state<string | null>(null);
This component follows WAI-ARIA best practices and achieves WCAG 2.1 Level AAA compliance:
MIT
Ishan Karunaratne - [email protected]
Website: https://ishansasika.dev
Contributions are welcome! Please feel free to submit a Pull Request.
See CHANGELOG.md for detailed release notes.