tam-select
Accessible, searchable select component. Keeps the native <select> as the source of truth — form submission, validation, and autofill continue to work. Zero dependencies.
Live Playground · Rails Guide · API Reference
Install
npm install tam-select
# or
bun add tam-selectQuick Start
<select id="my-select">
<option value="">Choose a fruit…</option>
<option value="apple">Apple</option>
<option value="banana">Banana</option>
<option value="cherry">Cherry</option>
</select>
<script type="module">
import TamSelect from "tam-select"
new TamSelect(document.getElementById("my-select"), {
searchable: true,
clearable: true,
placeholder: "Choose a fruit…"
})
</script>Features
- Single and multi-select with inline tags
- Filter-aware bulk selection, live selection counts, and optional hidden selections
- Ranked fuzzy search with highlighted matches
- Remote API search with debouncing and pagination
- Option groups with section headers
- Smooth dropdown animations (11 built-in presets)
- Virtual scrolling for 1000+ items
- RTL support (Arabic, Hebrew, mixed direction)
- Custom transports for GraphQL and authenticated APIs
- Keyboard navigation: arrows, Enter, Escape, Tab, Home, End, Ctrl+A
- Combobox ARIA semantics
- Light and dark themes
- Customizable via Tailwind classes
- No jQuery, Tom Select, Select2, or Floating UI dependency
Theming
Tam Select ships with two built-in themes:
| Theme | Style |
|---|---|
"default" |
Zinc palette, blue focus, subtle hover |
"select2" |
Gray palette, blue hover fill, bordered tags |
new TamSelect(select, { theme: "select2" })Custom themes
Override any class group via the classes option:
new TamSelect(select, {
classes: {
control: "h-10 rounded-lg border-2 border-indigo-500 bg-white",
option: "px-4 py-3 hover:bg-indigo-50"
}
})Dark mode
Tam Select follows the dark class on the document:
document.documentElement.classList.toggle("dark")Remote Search
new TamSelect(select, {
remoteUrl: "/api/users",
queryParam: "q",
minQueryLength: 2,
debounce: 300,
processResults: (data) => ({
items: data.users.map(u => ({
value: u.id,
label: u.name,
detail: u.email,
meta: u.role
})),
pagination: { has_more: data.total > data.page * 20 }
})
})Custom transport
new TamSelect(select, {
transport: async ({ url, query, signal }) => {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
signal
})
return res.json()
},
processResults: (data) => ({
items: data.nodes.map(n => ({ value: n.id, label: n.name })),
pagination: { has_more: data.pageInfo.hasNextPage }
})
})Virtual Scroll
For large datasets, enable virtual scrolling to render only visible options:
new TamSelect(select, {
virtualScroll: true,
virtualItemHeight: 32
})Auto-enables when visible items exceed virtualScrollThreshold (default: 100).
Advanced Multi-select
new TamSelect(select, {
bulkActions: true,
showSelectionCount: true,
hideSelected: true,
maximumSelectionLength: 10
})The dropdown displays count-aware Select all and Clear actions with
icons, a Ctrl/⌘-A shortcut hint, and a live selected-count badge (for example,
3 / 10 selected). Actions apply to the current search,
preserve its text, skip disabled options and groups, and respect cancelable
selecting / unselecting events. Selections outside the search stay unchanged.
With hideSelected, Clear still includes matching selected items that
are hidden from the results. Empty group headings disappear automatically.
Remote actions affect only loaded results; they never fetch or select unseen
pages. Virtual scrolling selects all loaded matches, including rows outside the
rendered viewport. Each bulk action emits one native change, one
tam-select:change, per-item completion events, and a tam-select:bulk-change
event with { action, items, value }. No-op actions emit nothing.
Tab moves from the search field to the action buttons; Shift+Tab returns to search and Escape closes the dropdown. Search remains available when the selection limit is reached. Composition-based keyboards can finish entering text before filtering or creating tags.
Localize the toolbar through language.selectAll, language.deselectAll, and
language.selectionCount (a string or (count, limit) => string). Override its
appearance with classes.selectionToolbar, classes.selectionAction, and
classes.selectionCount. These multi-select UI features are opt-in and do not
change single-select controls.
Animations
Choose a preset:
new TamSelect(select, { animationPreset: "spring" })| Preset | Duration | Feel |
|---|---|---|
"default" |
150ms | Standard |
"material" |
250ms | Material Design |
"spring" |
400ms | Bouncy overshoot |
"bounce" |
500ms | Overshoot bounce |
"elastic" |
600ms | Wide spring |
"snappy" |
100ms | Fast, responsive |
"smooth" |
300ms | Gentle ease |
"fade" |
200ms | Simple opacity |
"pop" |
200ms | Quick pop-in |
"slide" |
250ms | Smooth deceleration |
"none" |
0ms | Instant |
Options
| Option | Default | Description |
|---|---|---|
searchable |
true |
Enable text filtering |
creatable |
false |
Allow typed values to become options |
clearable |
true |
Show clear button (single selects need an empty value="" option) |
closeAfterSelect |
true (single) |
Close dropdown after selection |
placeholder |
"Select…" |
Placeholder text |
remoteUrl |
null |
Remote search endpoint |
minQueryLength |
0 |
Characters before remote search |
debounce |
250 |
Remote request delay (ms) |
maximumSelectionLength |
0 |
Max selectable items (0 = unlimited) |
bulkActions |
false |
Show count-aware Select all / Clear actions on multi-selects |
showSelectionCount |
false |
Show live selected count and configured limit |
hideSelected |
false |
Hide selected results from the option list (single or multiple) |
theme |
"auto" |
"default", "select2", or "auto"
|
classes |
{} |
Override any Tailwind class group |
animationPreset |
null |
Built-in animation preset |
virtualScroll |
false |
Enable virtual scrolling |
virtualItemHeight |
32 |
Row height for virtual scroll |
wrapOptionText |
automatic | Wrap ordinary result labels/details; set true to wrap even large lists (disables fixed-height virtualization), or false to truncate |
optionTooltip |
true |
Show full option text in native hover tooltips |
fuzzySearch |
true |
Tolerate typing mistakes |
highlightMatches |
true |
Highlight search matches |
sortByRelevance |
true |
Rank strong matches first |
dropdownParent |
null |
Portal target used to escape clipped containers |
width |
"resolve" |
"resolve" fills the container (or uses the native select's inline style="width: …"); "auto" sizes the control to its widest option like a native select; or any CSS width |
dropdownWidth |
"auto" |
"auto" is at least the control's width and grows to fit the widest option, like a native select; "trigger" always matches the control ("content" aliases "auto") |
maxDropdownWidth |
480 |
Widest an "auto" menu grows (px) before long options wrap; never narrower than the control, and always kept inside the viewport |
maxDropdownHeight |
360 |
Maximum menu height before results scroll |
viewportPadding |
8 |
Space kept between a portaled menu and viewport edges |
API
const instance = new TamSelect(select, options)
instance.open() // Open dropdown
instance.close() // Close dropdown
instance.focus() // Focus search input
instance.blur() // Remove focus
instance.setValue("apple") // Set selected value
instance.clear() // Clear all selections
instance.selectAll() // Select loaded matches within the selection limit
instance.deselectAll() // Deselect loaded matches, preserving other selections
instance.refresh() // Re-read native options
instance.addData({ value: "new", label: "New" }) // Add option
instance.removeData("old") // Remove option
instance.clearRemoteCache() // Clear cached responses
instance.destroy() // Remove all generated markup
// Read current value
instance.value // "apple" or ["apple", "banana"]
// Get existing instance
TamSelect.getInstance(select)Events
All events bubble on the original <select>:
select.addEventListener("tam-select:change", (e) => console.log(e.detail.value))
select.addEventListener("tam-select:open", () => console.log("opened"))
select.addEventListener("tam-select:close", () => console.log("closed"))
select.addEventListener("tam-select:select", (e) => console.log(e.detail.item))
select.addEventListener("tam-select:create", (e) => console.log(e.detail.item))
select.addEventListener("tam-select:error", (e) => console.error(e.detail.error))
select.addEventListener("tam-select:bulk-change", (e) => console.log(e.detail.action, e.detail.items))Cancelable: opening, closing, selecting, unselecting, clearing, creating.
Rails Integration
Installation
# Gemfile
gem "tam_select", github: "tamiru/tam_select"bundle install
bin/rails generate tam_select:installWith importmap, the gem serves everything itself: the JavaScript, the
tam-select Stimulus controller, TamSelectHelper, the Simple Form input
(when Simple Form is installed), and the TamSelectPaginatable and
TamSelectRemote concerns. The generator adds one line to
app/assets/tailwind/application.css so Tailwind (tailwindcss-rails 4.4+)
finds the classes Tam Select renders:
@import "tailwindcss";
@import "../builds/tailwind/tam_select";With jsbundling (esbuild and similar), the generator adds the tam-select
npm package to package.json and a one-line controller that re-exports it.
Run your package manager's install, then bin/rails stimulus:manifest:update
if you use a controllers manifest. For Tailwind, add an @source for
node_modules/tam-select/src to your CSS entry point.
Every select as a Tam Select
The generated config/initializers/tam_select.rb enables:
config.tam_select.default_select = trueWith it, every select is a Tam Select without changing views: Simple Form's
f.input :region, collection: ... and as: :select, and Rails'
select_tag, f.select, collection_select and grouped_collection_select.
Single selects get a blank option (so they can be cleared and show their
placeholder) unless you pass include_blank or prompt, or the choices
already contain one. Keep a field native with data: { tam_select: false }
(for Simple Form: input_html: { data: { tam_select: false } }).
Updating
bundle update tam_select # importmap
bundle update tam_select && npm update tam-select # jsbundlingNothing is copied into your app, so there is nothing else to update. To
customize a piece, add a file with the same path to your app (for example
app/helpers/tam_select_helper.rb or
app/javascript/controllers/tam_select_controller.js); the app's file wins
over the gem's.
Upgrading from 1.3 or earlier
Earlier versions copied every file into the app. Run this once:
bundle update tam_select
bin/rails generate tam_select:updateIt removes each copied file you never changed, and the old tam_select pin
or esbuild alias, so the gem's versions load instead. Files you changed are
kept and listed; they keep overriding the gem's version until you delete them.
Usage
<%= form.select :region_id,
options_from_collection_for_select(Region.order(:name), :id, :name),
{ prompt: "Select region" },
data: {
controller: "tam-select",
tam_select_options_value: { placeholder: "Search…" }.to_json
} %>Simple Form
<%= form.input :region_id,
as: :tam_select,
collection: Region.order(:name),
label_method: :name,
value_method: :id,
prompt: "Select region" %>Remote search
class RegionsController < ApplicationController
include TamSelectRemote
tam_select_remote(
model: Region,
label: :name,
detail: :description,
search_by: %i[name code],
scope: -> { Region.order(:name) }
)
end<%= form.input :region_id,
as: :tam_select,
collection: [form.object.region].compact,
input_html: {
tam_options: {
remoteUrl: tam_select_options_regions_path(format: :json),
minQueryLength: 1
}
} %>CI/CD
GitHub Actions
The CI workflow validates Ruby, JavaScript, TypeScript declarations, and npm package contents on every push.
Publishing
A matching v*.*.* tag publishes both the gem and npm package.
Required secrets in the release environment:
| Secret | Description |
|---|---|
RUBYGEMS_API_KEY |
Push-only key scoped to tam_select
|
NPM_TOKEN |
Automation token with publish access to tam-select
|
# Release
git tag v1.3.1
git push origin v1.3.1Local development
bundle install
bundle exec rake test # Ruby tests
npm test # JavaScript tests
npm run check # Syntax check
npm run typecheck # TypeScript declarationsLicense
Compact sizing and long option text
Controls use a 36px minimum height; simple dropdown options use 32px rows with 14px regular text and 6px vertical padding. Descriptions use 12px text. Long labels and descriptions wrap by default, and rich options grow to fit their content. For large virtualized lists, set wrapOptionText: true to show every line:
new TamSelect(select, { wrapOptionText: true })Wrapping applies to dropdown labels and details in both themes, including long words. Selected values stay on one line. Fixed-height virtual scrolling is disabled when wrapping is enabled. Set wrapOptionText: false to keep every result on one line.