Tom Select

Enhanced searchable select with tagging, remote search, option groups, and custom templates — powered by Tom Select v2.

Tom Select is initialised by calling new TomSelect('#id', options) inside a DOMContentLoaded listener (or after AJAX navigation). Every instance below uses a self-contained <script> block so examples are copy-pasteable. In a real project, consolidate initialisations into one file under /js/.

Single Select — Searchable

Replaces a native <select> with a searchable dropdown. Tom Select adds keyboard navigation, type-ahead filtering, and a clear button. Use allowEmptyOption: true when the placeholder option needs to be selectable.

Basic

With clear button

Disabled

<select id="my-select">
  <option value="">Select a country…</option>
  <option value="id">Indonesia</option>
  <option value="sg">Singapore</option>
</select>

<script>
document.addEventListener('DOMContentLoaded', () => {
  new TomSelect('#my-select', {
    allowEmptyOption: true,
    plugins: ['clear_button'],  // optional ×
  });
});
</script>

Multi-Select with Tags

Each selected option renders as a removable tag (pill) inside the control. The remove_button plugin adds the × per tag. Combine with max_items to cap the number of selections.

Product categories (unlimited)

Tags — max 3 selections

Maximum 3 tags. Further options are hidden once limit is reached.

<select id="my-multi" multiple placeholder="Add tags…">
  <option value="sale" selected>On Sale</option>
  <option value="new">New Arrival</option>
</select>

<script>
new TomSelect('#my-multi', {
  plugins: ['remove_button'],
  maxItems: 3,              // omit for unlimited
  placeholder: 'Add tags…',
  onItemAdd() {
    this.setTextboxValue('');
    this.refreshOptions();
  },
});
</script>

Create New Option

When create: true, typing a value not in the list shows an "Add …" prompt. Pressing Enter or clicking it adds the option on the fly. Useful for tag inputs where the option set is user-defined — e.g. product labels, skills, ingredients.

Free-form tags (type anything + Enter)

Single with create (e.g. city field)

new TomSelect('#my-select', {
  create: true,          // show "Add …" prompt for unknown input
  createOnBlur: false,   // only create on explicit Enter / click
  plugins: ['remove_button'],
  render: {
    option_create(data, escape) {
      return `<div class="create">Add <strong>${escape(data.input)}</strong>…</div>`;
    },
  },
});

Option Groups

Native <optgroup> elements are preserved by Tom Select. Group headers are styled with the project's overline treatment. Use when the option list spans multiple logical categories — shipping methods, role types, time zones.

Shipping method

Time zone

<select id="shipping">
  <option value="">Choose shipping…</option>
  <optgroup label="Standard">
    <option value="reg">Regular (5–7 days)</option>
    <option value="eco">Economy (10–14 days)</option>
  </optgroup>
  <optgroup label="Express">
    <option value="next">Next Day</option>
    <option value="same">Same Day</option>
  </optgroup>
</select>

<script>
new TomSelect('#shipping', {
  allowEmptyOption: true,
  plugins: ['clear_button'],
});
</script>

Disabled Options

Options marked disabled on the native <option> are shown in the list but cannot be selected. Use this to signal unavailability (out of stock, plan restriction, coming soon) without removing the option from context.

Plan upgrade (some locked)

Delivery slot (some taken)

<!-- Mark individual options as disabled on the native element -->
<select id="my-select">
  <option value="free">Free — 5 products</option>
  <option value="business" disabled>Business ✦ Contact sales</option>
</select>

<!-- Tom Select inherits the disabled state automatically -->
<script>
new TomSelect('#my-select', {});
</script>

Custom Option Template

The render.option and render.item callbacks return HTML strings, enabling rich option display — avatars, flags, subtitles, badges. The valueField / labelField / searchField options control which object keys Tom Select reads for value, display, and filtering.

Assignee (avatar + name)

Country (flag emoji)

new TomSelect('#assignee', {
  options: [
    { id: '1', name: 'Arya Wijaya',  role: 'Frontend', initials: 'AW' },
    { id: '2', name: 'Budi Santoso', role: 'Backend',  initials: 'BS' },
  ],
  valueField:  'id',
  labelField:  'name',
  searchField: ['name', 'role'],
  placeholder: 'Assign to…',
  plugins: ['clear_button'],
  render: {
    option(data, escape) {
      return `<div class="flex items-center gap-2">
        <span class="ts-opt-avatar">${escape(data.initials)}</span>
        <span>
          <span class="block">${escape(data.name)}</span>
          <span class="ts-opt-meta">${escape(data.role)}</span>
        </span>
      </div>`;
    },
    item(data, escape) {
      return `<div>${escape(data.initials)} ${escape(data.name)}</div>`;
    },
  },
});

Remote Search (AJAX)

Options are loaded on demand via load(), which fires when the user types. A debounce prevents flooding the server on every keystroke. The example below simulates an API call with a 400 ms delay — replace the setTimeout with a real fetch. The shouldLoad callback skips the request for short queries.

Product search (type "head", "key", or "usb")

new TomSelect('#product-search', {
  valueField:  'id',
  labelField:  'name',
  searchField: ['name', 'sku'],
  placeholder: 'Search products…',
  shouldLoad(q) { return q.length >= 2; },  // skip 0–1 char queries
  load(query, callback) {
    fetch('/api/products?q=' + encodeURIComponent(query))
      .then(r => r.json())
      .then(callback)
      .catch(() => callback());
  },
  plugins: ['clear_button'],
  render: {
    option(data, escape) {
      return `<div class="flex items-center justify-between gap-3">
        <span>
          <span class="block">${escape(data.name)}</span>
          <span class="ts-opt-meta">${escape(data.sku)}</span>
        </span>
        <span class="font-medium text-sm">${escape(data.price)}</span>
      </div>`;
    },
    loading:    () => '<div class="no-results">Searching…</div>',
    no_results: (d, escape) => `<div class="no-results">No results for "${escape(d.input)}"</div>`,
  },
});

Inside a Form — Error & Helper States

Tom Select wraps the original <select> in a .ts-wrapper div. Error state is signalled by adding ts-error on the wrapper after init. A helper / error message sits below as a sibling element — not inside the wrapper.

Product category *

Used to determine shipping rules and tax codes.

Shipping method *

Please select a shipping method to continue.

<fieldset>
  <legend class="fieldset-legend">Shipping method <span class="text-error">*</span></legend>
  <select id="shipping-method">
    <option value="">Select method…</option>
    <option value="reg">Regular</option>
    <option value="exp">Express</option>
  </select>
  <p class="fieldset-label text-error mt-1">Please select a shipping method.</p>
</fieldset>

<script>
// Apply error styling after init via the returned instance
const ts = new TomSelect('#shipping-method', { allowEmptyOption: true });
ts.control.style.borderColor = 'oklch(var(--er))';
</script>