Skip to navigation

Search Widget

Product Search is in beta with design partners. Endpoints roll out per tenant.

<captain-search> is a search box for one Product Search index. Results update on every keystroke, matched words are highlighted, and the arrow keys, Enter and Escape work as expected. It runs on any page: plain HTML, React, Next.js, Vue, or a Shopify theme. The widget has no dependencies.

For a full search page (facets, sort, pagination), the same file family adds elements that share one search inside <captain-search-root>. See Search pages.

The widget calls the same REST API documented in Search and Analytics. No SDK is involved.

Add it to a page

Load the script once, then place the element where the search box belongs.

<script type="module"
src="https://cdn.jsdelivr.net/gh/runcaptain/captain-search@1.7.3/captain-search.js"
integrity="sha384-er2Nbxzoh1k6jdNJk6gYd2V1TPm3K26BJ7zT1WUtWL+tKoI9sxnrXVc2qwXyczLE"
crossorigin="anonymous"></script>
<captain-search
endpoint="https://acme.captain.dev"
index="products"
key="YOUR_SCOPED_KEY"
placeholder="Search products"
></captain-search>

@1.7.3 pins this exact release, and the integrity hash makes the browser refuse any other bytes. Each release lists its hashes in integrity.json in the release repository. To always get the newest 1.x release instead, load @1 and leave out integrity and crossorigin: a moving version cannot carry a hash, and a new release can change the default markup.

The box loads recent searches, suggestion, completion and category rows, prices, badges, built image URLs and button render functions from a second file, captain-search-extras.js. It fetches the file only when the box needs it: on first focus for the row attributes, as soon as the page loads for price-field, image-template and badge-field (so the first results already show prices), and when a page sets a button render function with el.templates. It fetches it from the same exact release on jsDelivr and checks it against a hash built into the box, so the snippet stays one script tag. If your page has a Content Security Policy, allow https://cdn.jsdelivr.net in script-src.

Operators can build this snippet per index in the Product Search console: the tenant’s Integrate tab, Embed search, mints a scoped key locked to your sites and fills it in, for the search box or a full search page.

FileContainsSize, gzipped
captain-search.jsThe search box and createSearchabout 13 KB
captain-search-extras.jsLoaded by the box when needed (see above)about 5 KB
captain-instantsearch.jsThe same, plus <captain-search-root> and the search page elementsabout 32 KB

In React and Next.js, render the element as JSX and load the script once, for example with next/script. The attributes are plain strings.

Get a browser key

The key in the page is visible to anyone who views its source, so it must be a scoped key limited to your site. Do not put the search key or the write key in a page. Besides searching, the search key reads the index’s analytics, which include what your users search for.

A scoped key can search and send events, but it cannot read analytics. Create one on your server with a write key for every index:

curl -X POST "https://acme.captain.dev/v1/keys" \
-H "Authorization: Bearer $CAPTAIN_WRITE_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "scoped",
"indexes": ["products"],
"filter": {"status": "published"},
"allowed_origins": ["https://www.example.com", "https://example.com"],
"description": "website search box"
}'

The response holds the key once. Copy it into the page.

  • allowed_origins lists the sites that may use the key. A browser request from any other site is refused with 403 ORIGIN_NOT_ALLOWED. List every origin the page is served from, including staging. Origins only bind browsers: a server or script can still send the key, so treat it as public.
  • filter is optional. When set, it is applied to every search, and a request can narrow it but never widen it. Use it to say which records the page may show, for example only published ones. Without it, the key reads every record of its indexes.
  • expires_at is optional. Without it the key lasts until it is revoked. To rotate a key, create the new one, deploy the page, then revoke the old one with DELETE /v1/keys/{key_id}.

When records carry access lists (see Per-record access control), mint a short-lived scoped key per signed-in user on your server, with the issuer key, and set it from script instead of an attribute:

document.querySelector('captain-search').key = keyFromYourServer;

Attributes

AttributeDefaultPurpose
endpointrequiredThe tenant URL from onboarding.
indexrequiredThe index to search.
keyrequiredA scoped key. Can also be set with el.key.
placeholderSearchPlaceholder text. Also the accessible name unless label is set.
labelnoneAccessible name for screen readers.
modedropdownpage shows results under the box instead of in a dropdown, runs a full search on Enter, and pages with a More results button.
k8, or 20 in page modeResults per request, up to 50.
debounce60Milliseconds to wait after a keystroke before searching. Fast typing and pastes send one request.
min-chars1Characters typed before the first search.
filtersnoneFilters for every search, as JSON: {"category": "shoes"}. Same syntax as the filters search parameter. Can also be set with el.filters.
title-fieldtitle or nameField shown as the result title.
body-fielddescription, text or contentField shown under the title, clipped to two lines.
meta-fieldsnoneComma-separated fields shown in small text, for example brand,price.
image-fieldimage_urlField holding a thumbnail URL.
image-templatenoneBuilds the thumbnail URL from fields, for example https://img.shop.example/{image_id}/500x500.jpg. Values are URL-encoded. Falls back to image-field when a field in it is empty or the image fails to load.
price-fieldnoneField holding the price. Shows a price line under the title.
compare-at-fieldnoneField holding the old price. It shows struck through, with the saving, only when it’s higher than the price.
currencynoneISO currency code, for example USD. Prices are formatted with Intl.NumberFormat. Without it, prices show as plain numbers.
localethe browser’sLocale for prices, for example en-US or de-DE.
discountpercentHow the saving shows: percent (40% off), amount ($80.00 off) or none.
fraction-digitsthe currency’sauto drops the cents on a whole price ($400, but $399.50), 0 rounds to whole units, 2 always shows two.
badge-fieldnoneField shown as a badge above the result title, for example tag.
sale-badgenoneBadge text for a result whose old price is higher, for example Price Drop. A badge-field value wins.
completions0Up to this many rows that complete the word being typed, above the results.
category-fieldnoneField to show category rows for, above the results: its top values for what’s typed, counted on the same request.
categories3How many category rows.
category-urlnoneWhere a picked category goes, for example /c/{value}?q={query}. Values are URL-encoded.
url-fieldurlField holding the link a result opens.
href-templatenoneBuilds the link from fields instead, for example https://shop.example.com/p/{id}. Values are URL-encoded.
highlight-fieldsengine defaultComma-separated fields to highlight.
results-urlnoneWhere Enter goes, for example /search?q={query}. Without it, Enter runs a full search in place.
query-paramnoneOn a results page, the URL parameter to read the starting query from, for example q.
themelightdark, or auto to follow the visitor’s system setting.
warmonGets the first search ready before the first keystroke. See Behavior under load. warm="false" turns it off.
show-submitoffShows a search button. Without it the magnifier is decoration.
show-resetonShows a clear button while the box has text. show-reset="false" hides it.
show-loading-indicatoronShows a spinner once a search takes longer than stall-delay (200 ms).
search-as-you-typeonfalse searches only on Enter or the search button.
ignore-compositionoffWaits for IME input (Japanese, Chinese, Korean) to finish before searching.
autofocusoffFocuses the box when the page loads.
rendershadowlight renders into the page instead of a shadow root, so page CSS and css-classes style it.
css-classesnoneExtra classes, as JSON: {"input": "my-input", "submit": ["btn", "btn-primary"]}. Keys: root, form, input, submit, submitIcon, reset, resetIcon, loadingIndicator, loadingIcon, panel, list, item. Also el.cssClasses.
analyticsonoff records nothing: no user token, no click events, and searches stay out of the search log.
user-tokengeneratedThe signed-in user’s ID for analytics. Without it the widget generates a random token and keeps it in the browser.

Only http and https links and images are used. Any other URL in a record is ignored.

To send requests through your own code, for example to add a header or go through a proxy, set el.fetch. It takes the same arguments as fetch and must return a Response. On a search page, set it on <captain-search-root>, whose search serves every element inside it:

const box = document.querySelector('captain-search');
box.fetch = (url, init) => fetch(url, { ...init, headers: { ...init.headers, 'X-Store': 'eu' } });

Product dropdown

Completion and category rows sit above the products, each group under its own heading. The products show a price, the old price struck through when it’s higher, and the saving:

<captain-search
endpoint="https://acme.captain.dev"
index="products"
key="YOUR_SCOPED_KEY"
href-template="https://shop.example.com/p/{id}"
image-template="https://img.shop.example.com/{image_id}/500x500.jpg"
price-field="price"
compare-at-field="original_price"
currency="USD"
completions="3"
category-field="category"
category-url="https://shop.example.com/c/{value}?q={query}"
></captain-search>

Picking a completion searches for it. Picking a category fires captain-search:category, then opens category-url. Without category-url, handle the event yourself. Every row is reachable with the arrow keys.

Results page

A dropdown in the site header can send Enter to a results page, and the results page can show the full list:

<!-- header, on every page -->
<captain-search endpoint="https://acme.captain.dev" index="products" key="YOUR_SCOPED_KEY"
results-url="/search?q={query}"></captain-search>
<!-- /search -->
<captain-search endpoint="https://acme.captain.dev" index="products" key="YOUR_SCOPED_KEY"
mode="page" query-param="q"></captain-search>

Styling

The widget looks finished without any CSS. To match a site, set CSS variables on the element or any parent:

captain-search {
--cs-accent: #ff6600;
--cs-radius: 6px;
--cs-font: "Inter", sans-serif;
}
VariableControls
--cs-accentFocus ring, selected result, highlight tint
--cs-bg, --cs-fg, --cs-muted, --cs-borderBackground, text, secondary text, borders
--cs-active-bgBackground of the selected result
--cs-radius, --cs-shadowCorner radius, dropdown shadow
--cs-font, --cs-font-size, --cs-input-heightTypography and box height
--cs-width, --cs-max-height, --cs-zMaximum width, dropdown height, stacking order

For anything else, style the widget’s parts with ::part(): root, form, input, submit, reset, loading-indicator, panel, listbox, option, link, image, title, body, meta, mark, message, more and footer. With render="light" the widget has no shadow root: style its cs-SearchBox-* classes, or add classes with css-classes.

Templates replace the buttons and the spinner: <template slot="submit">, <template slot="reset"> and <template slot="loading">. Render functions work too, for example box.templates = { submit: (data, { html }) => html`<b>Go</b>` }. On a page with only captain-search.js, a render function loads captain-search-extras.js first, so that button draws a moment after the others. A <template slot> draws at once.

To change or skip a query before it is sent, set queryHook:

document.querySelector('captain-search').queryHook = (query, search) => {
if (query.length >= 3 || !query) search(query);
};
captain-search::part(mark) { background: none; text-decoration: underline; }

Custom result layout

Put a <template> inside the element to replace the layout of each result. data-field fills an element with a field’s text, highlighted when it matched. data-src on an img sets its source from a field. With price-field set, an element with data-price gets the price line.

To lay out prices yourself, bind the values Captain computes for each result. They sit under _captain and need price-field, image-template or badge-field on the box:

FieldHolds
_captain.priceThe formatted price
_captain.compare_atThe formatted old price, only when it’s higher
_captain.discountThe saving, for example 40% off, only when on sale
_captain.on_saletrue when the old price is higher
_captain.badgeThe badge-field value, else sale-badge when on sale
_captain.imageThe thumbnail from image-template, else image-field
_captain.urlThe result’s link from href-template, else url-field
<template>
<img data-src="_captain.image" width="48" height="48" alt="">
<strong data-field="title"></strong>
<span data-field="_captain.price"></span> <s data-field="_captain.compare_at"></s>
</template>
<captain-search endpoint="https://acme.captain.dev" index="products" key="YOUR_SCOPED_KEY">
<template>
<img data-src="image_url" width="48" height="48" alt="">
<div>
<strong data-field="title"></strong>
<span data-field="brand"></span> · $<span data-field="price"></span>
</div>
</template>
</captain-search>

Record values are always inserted as text, never as HTML, so a record cannot inject markup or script into the page.

Analytics

The widget sends user_token with every search and a click event when a result is opened, with the search’s query_id and the result’s position. Click-through rate, no-click searches and top hits in Analytics work without extra code. Set user-token to the signed-in user’s ID to tie searches across devices.

Set analytics="off" when a visitor opts out. The widget then sends no user token and no click events, and sends every search with "analytics": false, so it isn’t logged or counted in Analytics. An index on an engine older than RC11.9.1 refuses that field once. The widget then searches without it, still with no user token, so those searches are counted without being tied to the visitor.

Events

The element dispatches DOM events for pages that need to react:

EventdetailCancel it to
captain-search:queryquery
captain-search:resultsquery, hits, total, queryId, facets
captain-search:selecthit, position, queryId, hrefHandle navigation yourself, for example with a client-side router
captain-search:submitqueryHandle Enter yourself
captain-search:categoryfield, value, query, hrefHandle a picked category row yourself
captain-search:errorerror
document.querySelector('captain-search').addEventListener('captain-search:select', (e) => {
e.preventDefault();
router.push(`/products/${e.detail.hit.id}`);
});

The click event is sent before captain-search:select fires, so cancelling navigation still records the click.

Keyboard and accessibility

The widget follows the ARIA combobox pattern. Up and Down move through results, Home and End jump to the first and last, Enter opens the selected result, and Escape closes the list (a second Escape clears the box). Focus stays in the input, screen readers hear the selected result and the result count, and mouse users can open a result in a new tab as with any link.

Behavior under load

Each keystroke cancels the request still in flight, and an answer that arrives late never replaces a newer one. The previous results stay on screen until new ones arrive, so the list does not flicker. When the tenant answers 429 or 503, the widget waits for Retry-After and retries once before showing an error.

The first search of a visit is the slowest: the browser opens a connection and checks that your page may call the tenant. The widget does that work early. When the page loads, it tells the browser to connect to your tenant. When the shopper first focuses the box, or when a search page loads, it sends one small request that the edge answers on its own, so no search runs and nothing is counted in Analytics. It sends it at most once per page. In our tests from a fresh browser, the first answer arrived about 100 ms sooner. To turn it off, set warm="false" on the box or on <captain-search-root>. A page that sends requests through its own el.fetch is left alone.

Search pages

Load captain-instantsearch.js and put the elements inside one <captain-search-root>. They share one search: typing in the box, ticking a facet or changing the sort updates every element.

<script type="module"
src="https://cdn.jsdelivr.net/gh/runcaptain/captain-search@1.7.3/captain-instantsearch.js"
integrity="sha384-ffOTefDQQjKDOi6udKCEnrcDhpxiwXdHQyNXwZrqof1DPs8/jxUGNzV7mRbLU3rx"
crossorigin="anonymous"></script>
<captain-search-root endpoint="https://acme.captain.dev" index="products" key="YOUR_SCOPED_KEY" hits-per-page="20" url-sync>
<captain-search placeholder="Search products"></captain-search>
<captain-stats></captain-stats>
<captain-sort-by>
<option value="">Most relevant</option>
<option value="price:asc">Price, low to high</option>
</captain-sort-by>
<captain-refinement-list attribute="brand" label="Brand" searchable show-more></captain-refinement-list>
<captain-range-input attribute="price" label="Price" min="0" max="500"></captain-range-input>
<captain-current-refinements></captain-current-refinements>
<captain-clear-refinements></captain-clear-refinements>
<captain-hits href-template="https://shop.example.com/p/{id}">
<template slot="empty">Nothing matches <q data-query></q>.</template>
</captain-hits>
<captain-pagination></captain-pagination>
</captain-search-root>
ElementShowsAttributes
captain-search-rootNothing. Holds the search.endpoint, index, key, filters, hits-per-page (20), debounce, stall-delay, search-on-empty (on: results before anything is typed), url-sync, url-prefix
captain-searchThe inputAs above. Inside a root it shows no dropdown.
captain-hitsResultstitle-field, body-field, meta-fields, image-field, url-field, href-template, and the box’s price and image attributes: price-field, compare-at-field, currency, locale, discount, image-template, badge-field, sale-badge. fraction-digits works here too. A custom item template reads the computed values as _captain.price, _captain.compare_at, _captain.discount, _captain.on_sale, _captain.badge, _captain.image and _captain.url, for example <s data-if="_captain.on_sale" data-field="_captain.compare_at"> or <a data-attr-href="_captain.url">. A <template> for the layout, named slot="item" or unnamed, (data-field, data-snippet, img data-src), <template slot="empty"> for no results.
captain-infinite-hitsResults with a Show more buttonAs captain-hits, plus show-more-label
captain-paginationPage linkspadding (3), show-first, show-previous, show-next, show-last, scroll-to
captain-stats”1,234 results in 12 ms”A <template> whose elements carry data-stat: nbHits, ms, query, page or nbPages
captain-refinement-listFacet values with countsattribute, operator (or or and), limit (10), show-more, show-more-limit (20), searchable, sort-by (count, name, name:desc), label
captain-sort-byA sort menu<option> children: value="price:asc" or "price:desc", and "" for relevance
captain-hits-per-pageA page-size menu<option value="20"> children, up to 50
captain-current-refinementsRemovable chips for each filterlabels (JSON, field to label), include-query
captain-clear-refinementsA clear buttonincluded-attributes, include-query, label
captain-range-inputMin and max inputsattribute, min, max, step, input-type="date", label
captain-toggleOne checkbox filterattribute, value, label
captain-no-resultsIts own content, only when nothing matches
captain-completionsWord completions while typing, or the spelling the engine searched insteadlimit

Every element takes css-classes and renders into the page with cs-* class names (cs-Hits-item, cs-RefinementList-count, and so on). The default look comes from one stylesheet whose rules have no specificity, so any page CSS overrides it. The same --cs-* variables apply.

URL sync

With url-sync on the root, the query, page, filters, ranges, sort and page size live in the address bar, for example ?q=boots&brand=acme&price=50:200&sort=price:asc&page=2. Typing replaces the current history entry, filtering and paging add one, and Back and Forward restore the search. Other parameters are left alone. url-prefix namespaces the parameters when one page has two searches.

How the elements call the API

WhenRoute
Typing, with no facets, sort or paging on the pagePOST /v1/indexes/{index}/typeahead
A query with facets, sort or pagingPOST /v1/indexes/{index}/search
No query yet (search-on-empty)POST /v1/indexes/{index}/browse
A refined OR facetPOST /v1/multi_search: the main search plus one per refined facet, so each facet’s counts ignore its own selection
A searchable refinement listsearch or browse with facet_query
A click or conversionPOST /v1/events

Limits

  • Facet counts cover the most relevant matches, not every match. When a list’s counts cover fewer records than the result count, the refinement list says so under the list (“Counts cover the top 53 of 268 matches”).
  • Sort orders the top 1,000 relevant records for a query. Pages stop at the 1,000th record, and a page holds at most 50.
  • Range bounds come from facet_stats only for number fields that are facets. For other fields, set min and max on captain-range-input.
  • AND inside one facet (operator="and") works on indexes with typed fields, through the filter expression.
  • Counting facets with many distinct values over a whole large index is slow. A browse that counts authors across 349,000 records took about 4 seconds. On such an index, set search-on-empty="false" or leave high-cardinality lists off the empty page.
  • Not available: hierarchical menus and breadcrumbs, Query Suggestions and recent searches, dynamic facet ordering, a geo map, voice search, and AI answers.

Headless

createSearch is the same search with no UI, for pages that draw their own:

import { createSearch } from 'https://cdn.jsdelivr.net/gh/runcaptain/captain-search@1.7.3/captain-search.js';
const search = createSearch({ endpoint: 'https://acme.captain.dev', index: 'products', key: 'YOUR_SCOPED_KEY' });
input.addEventListener('input', () => search.refine(input.value));
search.on('results', (r) => {
list.replaceChildren(...(r?.hits ?? []).map((hit) => {
const li = document.createElement('li');
li.textContent = hit.title; // record text as text, never as HTML
return li;
}));
});
MemberDoes
query, state, results, isSearchStalledRead the current search
refine(query), clear()Set or clear the query
setPage(n), setHitsPerPage(n), setSort('field:asc')Page, size and order
addFacet(field), toggleRefinement(field, value), setRange(field, min, max), clearRefinements()Filters
searchFacetValues(field, prefix)Search inside one facet’s values
sendClick(hit, position), sendConversion(hit, name)Analytics events
on(event, fn)Subscribe to query, results, state or error. Returns a function that unsubscribes.

createSearch also takes fetch (your own request function), analytics: false (the same opt-out as analytics="off") and suggestFacets (fields to count on every search, typeahead included).

Inside a root, the same object is document.querySelector('captain-search-root').controller.