High-performance select components for Web Components, React, Vue, Svelte, SolidJS, Vanilla JS, and React Native.
Smilodon is a shared select runtime built around the enhanced-select custom element and wrapped by framework-specific adapters. The goal is simple: one behavior model, one styling model, one diagnostics model, and one performance story across every supported platform.
Version 1.9.1 is the current stable release for @smilodon/core and all maintained adapters.
Documentation: Live docs & examples ยท Framework integration guide (Vue/Nuxt 4, React, SSR)
Smilodon provides a complete select system for teams that need more than a styled dropdown.
@smilodon/coreAll maintained adapters now expose the same major runtime configuration groups that exist in @smilodon/core, so teams can stay inside their framework adapter without dropping down to the raw custom element for common advanced behavior.
Across the maintained adapters, you can now work with:
selectionConfigmultiSelectDisplayscrollToSelectedstylesconfig (full Partial<GlobalSelectConfig> passthrough)This means adapter users can configure:
The web adapters re-export the shared core config helpers so application code can set defaults from the adapter package directly:
@smilodon/react@smilodon/vue@smilodon/svelte@smilodon/solid@smilodon/vanillaExample:
import { configureSelect } from '@smilodon/react';
configureSelect({
searchable: true,
clearControl: {
enabled: true,
clearSelection: true,
clearSearch: true,
},
selection: {
showSelectedIndicator: false,
removeButtonIcon: 'ร',
},
multiSelectDisplay: {
mode: 'wrap',
},
});
For React Native, prefer per-instance config because the native path runs inside an embedded runtime.
<Select
items={items}
multiple
config={{
dropdownPlacement: { mode: 'auto' },
multiSelectDisplay: {
mode: 'horizontal',
maxHeight: '56px',
dragScroll: true,
},
scrollToSelected: {
enabled: true,
multiSelectTarget: 'last',
},
selection: {
closeOnSelect: false,
allowDeselect: true,
},
styles: {
badgeRemoveIcon: {
color: '#dc2626',
},
},
}}
/>
<Select
:items="items"
v-model="value"
multiple
:config="{
multiSelectDisplay: { mode: 'vertical', maxHeight: '120px' },
selection: { closeOnSelect: false },
styles: {
badgeRemoveIcon: { color: '#dc2626' }
}
}"
/>
const select = createSelect({
items,
multiple: true,
config: {
multiSelectDisplay: { mode: 'horizontal', dragScroll: true },
selection: { closeOnSelect: false },
},
});
<Select
items={items}
multiple
config={{
multiSelectDisplay: { mode: 'horizontal', dragScroll: true },
selection: { closeOnSelect: false },
}}
selectStyle={{
'--select-badge-remove-icon-color': '#dc2626',
}}
/>
The web adapters also expose more of the shared imperative runtime surface, including combinations of:
clearSearch()updateConfig() or adapter-equivalent runtime config methodssetError() / clearError()setRequired()validate()That keeps framework code closer to the shared runtime model and reduces the gap between adapter usage and direct custom-element usage.
Smilodon is not a single framework package. It is a system made of one runtime plus adapters.
| Package | Purpose | Primary targets | Status |
|---|---|---|---|
@smilodon/core |
Base custom element runtime (enhanced-select) |
Browser apps, design systems, direct DOM usage | Primary runtime |
@smilodon/react |
React wrapper around the core element | React, Next.js client components | Maintained |
@smilodon/vue |
Vue 3 wrapper | Vue 3, Nuxt | Maintained |
@smilodon/svelte |
Svelte wrapper | Svelte, SvelteKit | Maintained |
@smilodon/solid |
SolidJS wrapper | SolidJS apps | Maintained |
@smilodon/vanilla |
Helper utilities for direct DOM setup | Vanilla JS / TS | Maintained |
@smilodon/react-native |
React Native adapter with native/web split | React Native, React Native Web | Maintained |
| Platform | Delivery model | SSR / hydration posture | Styling model | Diagnostics support |
|---|---|---|---|---|
| Web Components | Native custom element | Browser-first | CSS variables + ::part() |
Full |
| React | Component wrapper | Client-rendered, Next.js App Router compatible | Shared token surface | Full |
| Vue 3 | Component wrapper | Nuxt-compatible with custom-element compiler config | Shared token surface | Full |
| Svelte | Component wrapper | SvelteKit-safe when mounted in browser lifecycle | Shared token surface | Full |
| SolidJS | Component wrapper | Browser/client usage with safe upgrade handling | Shared token surface | Full |
| Vanilla JS | Helper API + custom element | Browser-first | Shared token surface | Full |
| React Native | WebView bridge on native, direct element on web | Native/mobile oriented | CSS tokens via bridged style map | Full |
1.9.1 package line.All maintained adapters are designed to expose the same core behavior set wherever the host platform makes that practical.
| Capability area | Details |
|---|---|
| Selection | Single, multi, clearable selections, chip rendering, selection limits |
| Search | Local search, debounced remote search hooks, search event emission |
| Data scale | Virtualization for very large lists, incremental rendering, large-list stress handling |
| Rendering | Plain text, templated output, DOM renderers, framework renderers |
| Grouping | Flat items or grouped sections |
| Accessibility | ARIA listbox semantics, keyboard navigation, screen-reader announcements, touch targets |
| Diagnostics | diagnostic events, tracking buckets, capability reports |
| Runtime control | open(), close(), clear(), setItems(), setGroupedItems(), limitation policies |
| Styling | Shared CSS custom properties, parts, dark mode hooks, high-contrast and reduced-motion hooks |
Representative use cases:
Smilodon is designed around keeping work proportional to what the user can actually see.
The performance docs and benchmarks are structured around these operating targets.
| Dataset size | Typical render target | Typical memory target | Scroll target |
|---|---|---|---|
| 100 | <10 ms |
~2 MB | 60 FPS |
| 1,000 | <20 ms |
~4 MB | 60 FPS |
| 10,000 | <50 ms |
~8 MB | 60 FPS |
| 100,000 | <100 ms |
~12 MB | 60 FPS |
| 1,000,000 | <200 ms |
~18 MB | 57โ60 FPS |
The adapters are intentionally thin. The expensive work lives in the shared runtime, so React, Vue, Svelte, SolidJS, and Vanilla JS all benefit from the same virtualization, option management, diagnostics, and selection logic.
scripts/perf.jsSmilodon targets modern environments first.
Tier 1 browser support is documented around the modern evergreen baseline:
| Browser family | Minimum baseline |
|---|---|
| Chrome | 90+ |
| Edge | 90+ |
| Firefox | 88+ |
| Safari | 14.1+ |
| iOS Safari | 14.5+ |
| Android Chrome | 90+ |
| Samsung Internet | 14+ |
Best-effort support exists for some older browsers, but the maintained baseline assumes modern support for:
See docs/compliance/BROWSER-SUPPORT.md for the broader policy.
>=18>=9| Adapter | Expected host compatibility |
|---|---|
| React | React >=16.8.0, React DOM >=16.8.0 |
| Vue | Vue ^3.0.0 |
| Svelte | Svelte >=3, >=4, or >=5 |
| SolidJS | SolidJS ^1.9.0 |
| React Native | React >=18.2.0, React Native >=0.74.0, react-native-webview >=13.12.0 |
@smilodon/react from client components.enhanced-select as a custom element in the Vue compiler path.onMount().@smilodon/core directly when you want the most control.Frameworks without a first-party Smilodon adapter can still integrate through the shared Web Component runtime in @smilodon/core.
| Build tool | Recommended path | Notes |
|---|---|---|
| Vite | Import @smilodon/core in the browser entry and render <enhanced-select> |
Best default for browser-first apps and lightweight custom-element integration |
| Webpack 5 | Import @smilodon/core in the client bootstrap and keep the element client-rendered |
Good fit for custom enterprise stacks and older framework ecosystems |
| Turbopack | Register @smilodon/core from a client boundary and mount the element only on the client |
Best for Next.js/Turbopack-style app shells or custom React-adjacent stacks |
Full build-tool guidance lives in docs/BUILD-TOOL-INTEGRATION.md.
Install the core runtime plus the adapter for your platform.
# Core runtime
npm install @smilodon/core
# React
npm install @smilodon/react @smilodon/core
# Vue
npm install @smilodon/vue @smilodon/core
# Svelte
npm install @smilodon/svelte @smilodon/core
# SolidJS
npm install @smilodon/solid @smilodon/core solid-js
# Vanilla helpers
npm install @smilodon/vanilla @smilodon/core
# React Native
npm install @smilodon/react-native react-native-webview
<enhanced-select id="people"></enhanced-select>
<script type="module">
import '@smilodon/core';
const select = document.getElementById('people');
select.setItems([
{ value: 'ada', label: 'Ada Lovelace' },
{ value: 'grace', label: 'Grace Hopper' },
]);
select.updateConfig({
searchable: true,
selection: { mode: 'single' },
});
</script>
'use client';
import { useState } from 'react';
import { Select } from '@smilodon/react';
export default function Example() {
const [value, setValue] = useState<string | number>('');
return (
<Select
items={[
{ value: 'react', label: 'React' },
{ value: 'vue', label: 'Vue' },
]}
value={value}
onChange={(next) => setValue(next as string)}
searchable
clearable
placeholder="Choose a framework"
/>
);
}
<script setup lang="ts">
import { ref } from 'vue'
import { Select } from '@smilodon/vue'
const value = ref<string | number>('')
</script>
<template>
<Select
v-model="value"
:items="[
{ value: 'vue', label: 'Vue' },
{ value: 'nuxt', label: 'Nuxt' }
]"
searchable
clearable
placeholder="Choose a framework"
/>
</template>
<script lang="ts">
import { Select } from '@smilodon/svelte'
let value: string | number = ''
const items = [
{ value: 'svelte', label: 'Svelte' },
{ value: 'kit', label: 'SvelteKit' },
]
</script>
<Select
{items}
bind:value
searchable
clearable
placeholder="Choose a framework"
/>
import { createSignal } from 'solid-js'
import { Select } from '@smilodon/solid'
export default function Example() {
const [value, setValue] = createSignal<string | number>('')
return (
<Select
items={[
{ value: 'solid', label: 'SolidJS' },
{ value: 'qwik', label: 'Qwik' },
]}
value={value()}
onChange={(next) => setValue(next as string)}
searchable
/>
)
}
import { useState } from 'react'
import { View } from 'react-native'
import { Select } from '@smilodon/react-native'
export default function ExampleScreen() {
const [value, setValue] = useState<string | number>('')
return (
<View style={{ padding: 16 }}>
<Select
items={[
{ value: 'ios', label: 'iOS' },
{ value: 'android', label: 'Android' },
]}
value={value}
onChange={(next) => setValue(next as string)}
searchable
clearable
/>
</View>
)
}
For deeper framework guidance, use the package-level guides:
Smilodon uses custom elements (enhanced-select, select-option) that require proper configuration in Vue/Nuxt environments.
1. Configure custom element handling in Vite/Nuxt:
// vite.config.ts or nuxt.config.ts
export default defineConfig({
vue: {
template: {
compilerOptions: {
isCustomElement: (tag) => tag === 'enhanced-select' || tag === 'select-option'
}
}
}
})
For Nuxt specifically:
// nuxt.config.ts
export default defineNuxtConfig({
vue: {
compilerOptions: {
isCustomElement: (tag) => tag === 'enhanced-select' || tag === 'select-option'
}
}
})
2. Register Smilodon early (before component rendering):
Create a Nuxt plugin to ensure registration happens before components mount:
// plugins/smilodon.client.ts
import '@smilodon/core'
export default defineNuxtPlugin(() => {
// Core registration happens via side-effect import above
// No additional setup needed
})
๐ก Important Note: In Nuxt, it is recommended to place this plugin in the
plugins/directory. If you're encountering SSR issues, you can explicitly configure the plugin withssr: falseor use the extended configuration:// plugins/smilodon.client.ts export default defineNuxtPlugin({ name: 'smilodon', parallel: true, setup() { import('@smilodon/core') }, env: { islands: false } })
3. Use <ClientOnly> for SSR apps:
<template>
<ClientOnly>
<Select
v-model="value"
:items="items"
searchable
/>
</ClientOnly>
</template>
Issue: "Failed to execute 'createElement' on 'Document'"
connectedCallback@smilodon/[email protected] or later (constructor safety fixed)Issue: Custom element not registered
Issue: Vite serving stale code after patching
rm -rf node_modules/.vite && npm run devIssue: Teleport/Modal mounting breaks component
@smilodon/[email protected]+ (fixed constructor safety)Issue: Hydration Mismatch in SSR
<ClientOnly> or use v-if with client-side flag:<template>
<Select v-if="mounted" :items="items" />
</template>
<script setup>
import { ref, onMounted } from 'vue'
const mounted = ref(false)
onMounted(() => { mounted.value = true })
</script>
For best development experience, consider excluding Smilodon from pre-bundling:
// vite.config.ts
export default defineConfig({
optimizeDeps: {
exclude: ['@smilodon/core', '@smilodon/vue']
}
})
React integration is simpler as it doesn't require custom element compiler configuration.
Use client components:
'use client'
import { Select } from '@smilodon/react'
For App Router SSR:
import dynamic from 'next/dynamic'
const Select = dynamic(() => import('@smilodon/react').then(m => m.Select), {
ssr: false
})
Configure custom element handling:
// svelte.config.js
export default {
compilerOptions: {
customElement: true
},
kit: {
// ... your kit config
}
}
Use in components:
<script>
import { Select } from '@smilodon/svelte'
import { onMount } from 'svelte'
let value = ''
onMount(async () => {
// Ensure registration in browser context
await import('@smilodon/core')
})
</script>
<Select bind:value items={items} />
Smilodon uses a shared styling surface so every adapter can be themed in the same way.
--select-input-*, --select-dropdown-*, --select-option-*, and --select-badge-*::part(button), ::part(listbox), ::part(option), ::part(chip), and ::part(clear-button)Recent styling additions are now documented across the shared docs set, including:
bottom, top, and autoltr / rtl at both global and per-instance levelsselection.removeButtonIcon--select-group-header-separator-* adds spacing and dividers between sectionsstyles config sections such as badge, badgeRemove, badgeLabel, groupHeader, and activeOptionThe shared default direction is ltr. Switch it globally or per instance with direction.
Global:
configureSelect({
direction: 'rtl',
});
Per instance:
select.updateConfig({
direction: 'rtl',
});
| Need | Smilodon support |
|---|---|
| Design-system tokens | Shared CSS variables |
| Cross-framework theming | Same token surface in every adapter |
| Tailwind / utility CSS | classMap, host classes, CSS variables, ::part(), custom renderers |
| Bootstrap / traditional CSS | Host selectors, CSS variables, ::part(), framework layout classes |
| Material UI / CSS-in-JS | Host className / style, theme-driven CSS variables, GlobalStyles, sx, ::part() |
| Dark mode | Shared dark theme remapping |
| Reduced motion | Dedicated motion tokens and media-query handling |
| High contrast | Dedicated accessibility tokens |
| Custom option UI | DOM renderers and framework renderers |
Smilodon is designed to work with browser styling systems rather than compete with them.
classMap.sx, styled(), or GlobalStyles, while Smilodon consumes the resulting CSS variables and ::part() rules.For multi-select chip layouts, prefer multiSelectDisplay.mode over raw overflow overrides alone. wrap grows naturally, vertical adds a constrained chip area with vertical scrolling, and horizontal enables the single-row chip contract, drag-scroll handling, and end-of-row spacing for the arrow/clear controls.
In practice that means:
wrap lets the control grow with its chips and does not add an internal chip scrollbar by defaultvertical keeps wrapping enabled but constrains the chip area to maxHeight, so the scrollbar stays inside the value area and stops before the arrow / clear-control regionhorizontal locks the chip row to a single line, keeps the trigger height stable, and lets chips scroll underneath the fixed action areaItems marked with disabled: true are dimmed and non-selectable by default.
If you want a dimmed option to remain partially interactive, use selection.disabledOptionBehavior:
select.updateConfig({
selection: {
disabledOptionBehavior: {
hoverable: true,
focusable: true,
selectable: false,
},
},
});
Styling hooks for dimmed options remain:
styles.disabledOptionclassMap.disabled--select-option-disabled-* CSS variablesSmilodon now makes the selected option indicator and click-state styling easier to control without reaching for internal pseudo-element selectors.
selection.showSelectedIndicator = falsestyles.selectedIndicator or --select-option-selected-indicator-*Example:
select.updateConfig({
selection: {
showSelectedIndicator: false,
},
styles: {
selectedIndicator: {
width: '4px',
background: '#2563eb',
right: '0',
left: 'auto',
},
},
});
If you want the older pressed feedback back, set these tokens explicitly:
--select-option-pressed-transform--select-option-selected-pressed-transform--select-option-active-outline--select-option-selected-active-outlineSelected chips can use a custom remove icon through selection.removeButtonIcon in the shared runtime or removeButtonIcon in adapter props.
The icon can be plain text or inline SVG markup. To style the icon separately from the remove button shell, use:
::part(chip-remove-icon)styles.badgeRemoveIconclassNames.badgeRemoveIcon--select-badge-remove-icon-* CSS variables1.5.5The 1.5.5 release hardens the custom-renderer path used by Tailwind-style and framework-driven option UIs.
.dark, .dark-mode, [data-theme="dark"], and similar theme markers are mirrored into the scoped options subtree so dark variants update immediatelydark\:* classes are preserved correctly during selector scopingFor exact recipes and browser-oriented examples, see docs/CSS-FRAMEWORK-COMPATIBILITY.md.
Dropdown placement can be configured globally or per instance through dropdownPlacement.mode with bottom, top, or auto.
Reference docs:
Smilodon includes runtime inspection APIs so host applications can reason about what the control supports and what limitations apply.
getCapabilities()getKnownLimitations()getTrackingSnapshot()clearTracking(source?)setLimitationPolicies(policies)diagnostic event emissionThe shared runtime tracks known limitation categories such as:
See docs/ADAPTER-CAPABILITY-MATRIX.md and docs/KNOWN-LIMITATIONS.md.
The repository includes:
Useful commands:
npm run test:unit
npm run test:contracts
npm run test:e2e
npm run perf
Testing docs:
| Path | Purpose |
|---|---|
packages/core |
Shared runtime and custom element |
packages/react |
React adapter |
packages/vue |
Vue adapter |
packages/svelte |
Svelte adapter |
packages/solid |
SolidJS adapter |
packages/vanilla |
Vanilla helper layer |
packages/react-native |
React Native adapter |
docs |
Deep-dive documentation |
tests |
Contract and end-to-end test support |
playground |
Maintained interactive demo workspace |
scripts |
Performance and verification utilities |
README.mdpackages/*/README.mdThe playground workspace is still used and has been trimmed to the maintained entry points:
playground/index.htmlplayground/react-demo.htmlplayground/vue-demo.htmlplayground/svelte-demo.htmlplayground/vanilla-demo.htmlplayground/sandbox.htmlRun it with:
npm run dev -w @smilodon/playground
The root scripts folder is retained for repository-level utilities that are still in use:
scripts/perf.js for performance budgets and profilingscripts/verify-playwright.mjs for end-to-end preflight checks[email protected]