Combobox - headless

createCombobox drives the styled SvComboBox and a custom filter-input render, both bound to one value with a readout.

A live, editable Svelte 5 component example from the SvGrid gallery (Headless Editors). Read the SvGrid UI component docs for the full API.

About this example

The headless combobox core of the SvGrid UI kit in Svelte 5. SvComboBox is one styled renderer over createCombobox, which owns type-to-filter, the roving active index, the keyboard model, reverting unmatched text and ARIA, exposed as inputProps, triggerProps, listboxProps and optionProps. The same core drives the styled component and a custom filter-input render, both bound to one value with a readout.

Headless combobox - <SvComboBox> is one styled renderer over the framework- free-in-spirit createCombobox core: type-to-filter, roving active index, full keyboard, unmatched-text revert and ARIA, all exposed as prop-getters you spread onto YOUR OWN markup. Below, the same core drives the styled component AND a totally custom render, both bound to one value.

Imports, features and API used

Imports: @svgrid/grid

Frequently asked questions

What do I have to render myself?

An input with inputProps(), an optional trigger with triggerProps(), a list with listboxProps() and one element per filtered option with optionProps(i). The core supplies the filtered options and the active index.

How does unmatched text revert?

On blur the core compares the input text with the options and, if nothing matches, restores the last valid value, so your render does not need its own blur logic.

Does the core know about the DOM?

No. It holds state and returns attributes; positioning and portalling are the styled component's concern, which is why an inline custom render works without a portal.

Related documentation

Related articles

  • SvGrid vs TanStack Table - A Deep Dive - A concrete architectural comparison of SvGrid and TanStack Table's Svelte adapter - how each handles reactivity, rendering, and feature composition, with code that shows exactly where they diverge.
  • SvGrid vs svelte-headless-table - A practical comparison of SvGrid and svelte-headless-table covering reactivity model, rendering approach, feature scope, and when each is the right choice for a Svelte 5 project.
  • What Is a Headless Data Grid? - Headless grids separate behavior from rendering - sorting, filtering, grouping, and pagination logic with no markup attached. Here is what that means in practice, why it matters, and when the tradeoff is worth it.

Source code (261-headless-combobox.svelte)

<script lang="ts">
  /**
   * Headless combobox - <SvComboBox> is one styled renderer over the framework-
   * free-in-spirit `createCombobox` core: type-to-filter, roving active index,
   * full keyboard, unmatched-text revert and ARIA, all exposed as prop-getters
   * you spread onto YOUR OWN markup. Below, the same core drives the styled
   * component AND a totally custom render, both bound to one value.
   */
  import { SvComboBox, createCombobox, type ListOption } from '@svgrid/grid'

  const options: ListOption[] = [
    { value: 'svelte', label: 'Svelte' },
    { value: 'react', label: 'React' },
    { value: 'vue', label: 'Vue' },
    { value: 'solid', label: 'Solid' },
    { value: 'angular', label: 'Angular', disabled: true },
    { value: 'qwik', label: 'Qwik' },
  ]

  let value = $state<string | number | null>('svelte')

  let inputEl = $state<HTMLInputElement | null>(null)
  const cb = createCombobox({
    options: () => options,
    value: () => value,
    onChange: (v) => (value = v),
    focusInput: () => inputEl?.focus(),
    blurInput: () => inputEl?.blur(),
  })
</script>

<div class="wrap">
  <header>
    <h2>Combobox - headless</h2>
    <p>
      Every editor ships a headless core + a styled component - the same split as the grid
      (<code>createSvGrid</code> / <code>&lt;SvGrid&gt;</code>). Here <code>createCombobox</code>
      drives both renders below; filtering, keyboard, selection and ARIA come from the core, the markup is yours.
    </p>
  </header>

  <div class="cols">
    <section>
      <h3>Styled <code>&lt;SvComboBox&gt;</code></h3>
      <SvComboBox {options} {value} ariaLabel="Framework" onChange={(v) => (value = v)} />
    </section>

    <section>
      <h3>Your markup, same core</h3>
      <div class="field">
        <input bind:this={inputEl} class="input" placeholder="Type to filter…" {...cb.inputProps()} />
        {#if cb.open}
          <div class="menu" {...cb.listboxProps()}>
            {#each cb.filtered as opt, i (opt.value)}
              <!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
              <div class="opt" class:active={cb.isActive(i)} class:on={cb.isSelected(opt)} class:off={opt.disabled} {...cb.optionProps(i)}>{opt.label}</div>
            {:else}
              <div class="empty">No matches</div>
            {/each}
          </div>
        {/if}
      </div>
      <p class="hint">Type to filter, arrow keys move the ring, Enter picks - all from <code>createCombobox</code>.</p>
    </section>
  </div>

  <aside class="readout">
    <h3>Value</h3>
    {#if value}
      <span class="tag">{value}</span>
    {:else}
      <p class="empty">Nothing selected</p>
    {/if}
  </aside>
</div>

<style>
  .wrap { padding: 20px; max-width: 900px; display: flex; flex-direction: column; gap: 18px; }
  header h2 { margin: 0 0 4px; font-size: 20px; font-weight: 700; }
  header p { margin: 0; color: var(--sg-muted, #64748b); font-size: 13.5px; line-height: 1.55; max-width: 680px; }
  code { background: var(--sg-row-hover-bg, #eef2ff); padding: 1px 5px; border-radius: 5px; font-size: 12px; }
  .cols { display: flex; gap: 32px; flex-wrap: wrap; align-items: flex-start; }
  section h3 { margin: 0 0 10px; font-size: 13px; text-transform: uppercase; letter-spacing: 0.03em; color: var(--sg-muted, #64748b); }
  /* Fully custom combobox render - nothing shared with SvComboBox but the core. */
  .field { position: relative; width: 240px; }
  .input {
    box-sizing: border-box; width: 100%; height: 38px; padding: 0 12px; font: inherit; font-size: 13px;
    border: 1px solid var(--sg-border, #e2e8f0); border-radius: 10px;
    background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a); outline: none;
  }
  .input:focus { border-color: var(--sg-accent, #4f46e5); box-shadow: 0 0 0 2px color-mix(in srgb, var(--sg-accent, #4f46e5) 30%, transparent); }
  .menu {
    position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 5; padding: 4px;
    background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e2e8f0); border-radius: 10px;
    box-shadow: 0 16px 40px -14px rgba(15,23,42,0.35);
  }
  .opt { padding: 8px 10px; border-radius: 7px; font-size: 13px; cursor: pointer; }
  .opt.active { background: var(--sg-row-hover-bg, #f1f5f9); }
  .opt.on { color: var(--sg-accent, #4f46e5); font-weight: 600; }
  .opt.off { opacity: 0.4; cursor: not-allowed; }
  .empty { padding: 8px 10px; color: var(--sg-muted, #94a3b8); font-size: 13px; font-style: italic; margin: 0; }
  .hint { margin: 10px 0 0; font-size: 12px; color: var(--sg-muted, #94a3b8); line-height: 1.5; }
  .readout h3 { margin: 0 0 8px; font-size: 13px; text-transform: uppercase; letter-spacing: 0.03em; color: var(--sg-muted, #64748b); }
  .tag { padding: 3px 10px; border-radius: 6px; font-size: 13px; background: var(--sg-row-hover-bg, #f1f5f9); }
</style>

View this example on GitHub

More Headless Editors examples

  • Headless editors - Headless-first, like the grid: createListbox is the state machine behind SvListBox (roving focus, single/multi selection, keyboard, ARIA) exposed as prop-getters you spread onto YOUR own markup. One core drives both the styled SvListBox and a custom chip-cloud render, bound to one value.
  • Dropdown list - headless - createDropdownList drives SvDropDownList and a custom trigger+menu render, sharing one value.
  • Autocomplete - headless - createAutocomplete drives SvAutoComplete and a custom suggestion-list render over one text value.
  • Tags input - headless - createTagsInput drives SvTagsInput and a custom chip-cloud render, both editing one string array.
  • Country input - headless - createCountryInput drives SvCountryInput and a custom searchable picker, sharing one ISO code.