Search Autocomplete — Best Practices
The Search Autocomplete page documents the contract — variables, annotation attributes, keyboard modes. This page is about what a good dropdown looks like and how the better shop templates build it. It exists so that every shop's ajax/search.tpl can be reviewed against one standard; the review checklist at the end is the audit form.
The examples come from three production layouts, referred to by what they sell:
- Garden machinery — a multi-country shop with a large spare-parts catalogue and a magazine
- Workwear — a textile shop with product labels and delivery-date promises
- Fishing gear — a shop with brands, articles and product ratings
None of them is perfect; where one gets something wrong it is called out, because the same mistake is probably in other templates too.
The dropdown has three states
The search field is the highest-intent element on the page: a customer who types is telling you what they want to buy. The dropdown has to answer well in all three states the template renders, not only the second one:
| State | Condition in ajax/search.tpl | What the customer expects |
|---|---|---|
| Default panel | q is empty (focus on an empty field, or fewer than 3 characters) | Something worth clicking before they type: popular categories, personal recommendations, recently viewed |
| Results | q set, at least one collection non-empty | Categories, products, articles, pages — each visibly distinct, phrase highlighted, a way to the full listing |
| Nothing found | q set, every collection empty | A clear message and a way forward (contact, alternatives), never an empty box |
A template that handles only the results state throws away the first and the last interaction of every search.
1. The default panel — use it
When the field is focused and empty, the dropdown is a free merchandising surface in the most visible spot of the page. Show it. Do not leave the customer staring at an empty input.


How it opens
Set min-length="0" on the form. The shared script then requests /_search_html on focus with an empty phrase, the presenter blanks anything under 3 characters, and your template renders its q is empty branch:
<i:search-form autocomplete=".site-search" min-length="0" class="form-search" role="search">
<label for="role:search" class="hide">{% trans 'Hledaná fráze' %}</label>
<input data-role="search" placeholder="{% trans 'Co vám pomůžeme najít?' %}" data-testid="global-search-input">
<button data-role="submit">{% trans 'Hledat' %}</button>
</i:search-form>
<div class="site-search" data-testid="global-search-results"></div>The autocomplete attribute value is the CSS selector of the element the fragment is inserted into (default .global-autocomplete). Both layouts with a default panel use this form. min-length accepts a single digit only.
Keep min-length at 0
With the default min-length="3" the panel only opens once the customer has typed three characters, so the default panel is never requested and never shown — unless the container already holds pre-rendered content, which the script then reveals on focus. Rendering it through the ajax template with min-length="0" is simpler and keeps one file in charge of the dropdown.
What to put there
Pick two or three of these, in this order of value:
Personal recommendations — the recsys homepage slate is the natural content: it is personalised per visitor (recently viewed store, person hash) and it is measured. Render it through
i:list-container; templates underajax/are exempt from thei:dynamic lazyrule because the dropdown is the deferred fetch.twig{% if q is empty %} <p class="site-search__head">{% trans 'Mohlo by vás zajímat' %}</p> <i:list-container type="product" source="{'recommendHomepage': true, 'limit': 8}" name="listfeedsearch" hide-empty> <div data-role="list" class="site-search__tiles"></div> </i:list-container> {% endif %}limitis the number of tiles you show (max 20); an untrained shop or an outage yields an empty slate andhide-emptyremoves the block. See Listings — recommendations.Recently viewed products —
repository.productwithviewed: true. The fishing-gear layout shows these when they exist and falls back to a promo listing otherwise:twig{% if repository.product.findBy({'limit': 1, 'viewed': true})|length >= 1 %} <p>{% trans 'Naposledy jste prohlíželi' %}</p> <i:list-container type="product" source="{'limit': 8, 'viewed': true}" name="listfeedsearch" hide-empty> <div data-role="list"></div> </i:list-container> {% else %} …recommendations… {% endif %}Popular searches / entry categories — a short row of chips or tiles the shop manages itself, so marketing can rotate it without a template change. Two admin-managed sources exist:
- Template attributes (
config.json): the garden-machinery layout definesajax_search_categories(typecategory, multiple) andajax_search_products(typeproduct_list) in a "Vyhledávání" group and readsthis.templateAttributes.ajax_search_categoriesin the template. - Content grid: the fishing-gear layout reads categories, brands, products, articles and free links from a
recommendationsrow of a shared footer grid. A dedicated grid file (e.g.content-grid/search.xml) with acategory/product/urlrow is the cleaner variant.
Label the row honestly — "Ostatní nejčastěji hledají" / "Lidé hledají" tells the customer why these are here.
- Template attributes (
Two things to check
- Queries belong inside the branch that renders them. The template renders on every focus, so a
repository.product.findBy({'search': q, …})placed above theq is emptycheck runs with an empty phrase on every focus for nothing. The garden-machinery layout has all four search queries before the state check — move them into theelse. - Default-panel clicks are not in the search click-stream. The search beacon only fires for phrases of 3+ characters, so a plain
<a>in the default panel is invisible to the „Vyhledávání" report. Products rendered throughi:list-containerare measured by the listing beacon instead (the container carries the list identity) — which is why the recsys slate and the recently-viewed list should go through a list container rather than a hand-written loop. Chips linking to categories are the one thing you cannot measure here; keep them few.
2. Results — what to search and how to lay it out

Search everything the shop has
The repositories search five entity types; a dropdown that only lists products makes the customer scroll past twenty lawnmowers to find the "Sekačky" category that would have answered in one click.
{% set categories = repository.category.findBy({'search': q, 'limit': 5}) %}
{% set brands = repository.brand.findBy({'search': q, 'limit': 5}) %}
{% set products = repository.product.findBy({'search': q, 'limit': 5}) %}
{% set articles = repository.article.findBy({'search': q, 'limit': 5}) %}
{% set pages = repository.textPage.findBy({'search': q, 'limit': 3}) %}| Type | Why it belongs in the dropdown | Who does it |
|---|---|---|
| Categories | The best answer to a generic phrase ("triko", "seka"). Name-first matching with inflection and typo tolerance, so every hit is explainable by its visible name. | all three |
| Products | The direct hits; the only type where price and availability matter. | all three |
| Brands | Brand phrases are common; a brand tile beats five random products of that brand. | workwear, fishing gear |
| Articles | Advice content ("jak vybrat správné tričko") turns a research query into a visit instead of a bounce. | all three |
| Text pages | Service questions typed into search: "vrácení", "reklamace", "doprava". Only pages flagged zobrazovat ve vyhledávání are returned — check that the shop flagged them, otherwise the section stays empty. | none yet — add it |
A shop can add its own sections when the catalog has a natural split. The garden-machinery layout runs two product queries — the catalog without the spare-parts category (skipCategory) and spare parts only (category + subcategories) — and renders the parts as a compact "Náhradní díly" list with a wrench icon. Mirror the shop's own navigation, not the repository's type list.
Order and size
- Categories (and brands) first, then products, then articles and pages. Categories narrow intent in one click; putting them after five products (fishing gear) buries them.
- Around 10 items on desktop, 4–8 on mobile. Five products, five categories, three to five articles is plenty. More rows do not get more clicks — the click-position histogram in the „Vyhledávání" report has a fat tail past position 10 that reads as customers hunting, not finding.
- Put the count in the heading — "Produkty (131)", "Nalezeno v kategoriích 6" — from
products.totalCount. It tells the customer whether to click a result or go to the listing. - Distinguish the types visually: an icon per type (folder, document, wrench), a different tile shape (category tiles vs product rows), a section heading. A flat list of links in one style forces the customer to read every line.
Two layouts that work:
- Two-column desktop (garden machinery): categories + articles on the left, products + spare parts on the right, one column on mobile. Uses the width of a wide search field well.
- Single column with tiles (workwear): categories as a link grid, products as five real product cards with labels, delivery date and price, articles as links. Cards reuse the listing look, which the customer already knows.

Product rows
A product row needs, in this order: image, name with the phrase highlighted, availability, price. Discounts belong on the row (garden machinery: "-56 %" badge, crossed-out original price, the 30-day-lowest tooltip). Ratings help when the shop has them (fishing gear shows stars and count).
Never call a repository inside a result row
The template renders on every keystroke. The garden-machinery product row loops over repository.store.findBy(…) and asks availableAmountInShowroom(store) for each store, for each product — that is 5 × N store queries per keystroke to print "Skladem na 44 prodejnách". Precompute such data outside the loop, or use the availability object the product already carries.
The workwear layout fetches the products twice — once into products for the count and again inline in the for — replace the second call with the variable.
Highlight the phrase
Use the highlight filter on names of every type — products, categories, articles, pages:
<span class="site-search__name">{{ product.name|highlight(q) }}</span>It wraps every case-insensitive occurrence of the phrase in <span class="global-highlight-search">…</span>; style that class (yellow background is the convention, see the screenshots). Two limits to know:
- It is a literal substring match: "sekacka" does not highlight Sekačka, and "sekačka trávy" highlights only rows containing that exact sequence. Names that matched by inflection or typo tolerance render unhighlighted — that is acceptable; do not try to be cleverer than the filter.
- Apply it to plain names only. Rich HTML values (
HtmlString) are returned unchanged.
The workwear layout renders names without highlight — add it; a highlighted match is what lets the eye jump to the right row.
Always end with the show-all row
The last item is a link to the full listing, always rendered, annotated data-result-type="more":
<a href="{{ searchPageUrl }}" data-result-type="more" class="site-search__all">
{% trans 'Zobrazit všechny výsledky' %} ({{ products.totalCount }})
</a>searchPageUrl is built by the platform and carries vypis=1, which guarantees the listing (and not a category or brand redirect for single-entity phrases). Do not assemble the URL by hand. Two good labels: "Zobrazit všechny výsledky (317)" with the count, or the fishing-gear layout's "Vyhledat 'prut'" repeating the phrase.

The second, reranked response
On every shop the platform may deliver a second response ~350 ms after the first for phrases of 5+ characters — the same template rendered with a better-ranked hit list. The script swaps the fragment and fires autocomplete:loaded again. Consequences:
Anything you initialise in the fragment (a Swiper slider for category tiles, tooltips) must be initialised in an
autocomplete:loadedhandler, not once on page load — the garden-machinery layout does exactly this:js$(document).on('autocomplete:loaded', '.form-search', function () { customSlider(); });Do not animate rows in; a swap that re-plays an entrance animation looks like a glitch.
3. Nothing found — say so and offer a way out
Render an explicit message the moment every collection is empty. The two better patterns:
- Workwear: a two-column panel — "Bohužel jsme nic nenašli. Zkuste dotaz upravit." on the left, customer support (phone with open/closed indicator, e-mail) on the right, and a "Mohlo by se Vám líbit" product strip from a template attribute below. The customer leaves with a product or a phone number, never with nothing.
- Fishing gear: an admin-editable text (template attribute
page_empty_search) with a default that links to the contact page: "Pokud hledáte konkrétní věc a nenacházíte, kontaktujte nás. Najdeme to za vás, nebo poradíme alternativu."
Add a product strip to the message when the shop has one — the recsys recommendHomepage slate through i:list-container is the obvious choice, and it is measured.
Check the condition covers every collection you render
The fishing-gear layout decides "nothing found" from categories, products and brands — but it also queries and renders articles. A phrase that matches only an article shows the "nic jsme nenašli" panel while an article exists. Whatever you fetch goes into the emptiness check.
4. Tracking — make every click count
Every terminated search interaction is logged by the shared script through one beacon to /_rum/frontend-fulltext-search-log and lands in the shop's „Vyhledávání" report (/admin/analytika/r/fulltext): found rate, top phrases, zero-result phrases, click positions, device split. A template contributes to that report only if it is annotated; nothing else is needed, and nothing else can substitute for it.
| Outcome | Logged when |
|---|---|
success | a result link was clicked or opened with Enter — carries the result type, its rank, the URL and the product id |
direct | the form was submitted to the full listing after a response had arrived |
abandoned | the dropdown was closed while it showed results |
empty | the dropdown was closed with zero results |
skip | the form was submitted before any response arrived |
What the template must do
- Annotate every result with
data-result-type—product,category,brand,article,page,more. Use the vocabulary exactly: the garden-machinery spare-parts macro labels an itemarticlewhen it has a topic andproductotherwise, which is right; a template that labels articles asbrandsilently corrupts the report. - Put
data-product-idon product results. Without it the click is logged, but the „Klikané produkty" tab cannot name the product. The workwear layout omits it — every product click on that shop is anonymous. - Use section mode —
data-search-section="N"on each section wrapper — for new and reworked templates (the fishing-gear layout does). Legacytabindexnumbering works, but it is where real shops lose data:- Six storefronts of one multi-country layout logged not a single result click for months because the number sat on the
<li>above the annotated<a>, a shape the script did not walk up to at the time. The script now handles it, but hand-maintained counters still break on the next template edit. - The garden-machinery price tooltip is a
<span tabindex="4">inside the product link — a decorative tab stop that becomes a keyboard target which highlights nothing and does nothing on Enter. Keeptabindexoff everything that is not a result.
- Six storefronts of one multi-country layout logged not a single result click for months because the number sat on the
- Keep
data-result-typeinajax/search.tplitself. The presenter decides whether the shop's dropdown is "tagged" by scanning that file — even when the form uses a customtemplate="…". A shop whose annotations live only in a differently named file sends no beacons at all. - One click handler per link is automatic — the image link and the title link of a product card may both carry the type; they count as one item for the keyboard and both report the click.
Verify it on the shop
Open DevTools → Network, type a phrase of 3+ characters, click a result, and look for the frontend-fulltext-search-log beacon (filter by that name, check "Preserve log"). Its payload must show status: "success", the right result_type, a result_index_total that matches the visual position, and a numeric product_id for products. Also confirm the _search_html response ends with <!-- tagged-search-results --> — without that comment no beacon is sent. Do this once per shop after any change to the search template; the report cannot tell a shop with no searches from a shop with an unannotated template.
5. Layout and interaction details
Container and overlay
The script toggles search-open on <body> while the dropdown is visible. Use it for the page treatment behind the panel: the garden-machinery layout darkens the page with an overlay (.search-open .overflow--search), the workwear layout blurs it (.blured, .search-open). Both make the dropdown read as a modal layer, which it is.
Position the container directly under the field, at least as wide as the field, and give it a max height with its own scrollbar so a long dropdown never pushes the page.
Mobile
The dropdown competes with the on-screen keyboard for viewport. The patterns that hold up:
- Full-width panel under a full-width field; the garden-machinery search field moves to its own row below the logo on small screens.
- A visible back/close control: the garden-machinery layout adds an arrow-left "cancel" button inside the form, the fishing-gear layout a round close button in the corner. The keyboard-only Esc does not exist on a phone.
- Product rows over product cards (a card grid at 2-up leaves four products visible; the workwear layout hides the fifth card below
large). - Big tap targets: one row per result, the whole row is the link.
Keyboard
Arrow keys walk the results (section order in section mode), Enter opens the highlighted item, Esc once hides the dropdown, Esc twice clears the field. Style .search-current-item clearly — a background change on the row, not only an underline. With section mode you get this for free; verify by pressing ↓ a few times on the live shop and watching the highlight follow the visual order (a sidebar that renders first in markup but sits second on screen needs the higher section number).
Field affordances
- Clear button ("Smazat vše") once the field has a value — customers reformulate often (
attempt≥ 2 in the report). - Placeholder that asks a question — "Co vám pomůžeme najít?" beats "Hledat". The fishing-gear layout rotates shop-configured suggestions through a typewriter effect in the placeholder (template attribute
search_suggestions) — a good way to teach what the shop sells. - Submit button stays visible; the form submit is the
directpath to the full listing and must work without JavaScript.
Cost
The fragment renders on every keystroke after a 200 ms debounce, and the endpoint is rate-limited per IP (429 when exceeded). Keep the template to the five findBy calls with small limits; product images are emitted with native lazy loading already. Anything that costs a query per row multiplies by the number of rows and by every keystroke of every visitor.
6. Testing
Every layout ships a Playwright spec for the dropdown (tests/global/search.spec.js). Use the shared test ids global-search-input and global-search-results on the input and the container so the same spec survives a redesign, and the special phrases %debugSearchAnyResult% / %debugSearchNoResult% so the test does not depend on catalog data (Template Testing — special values):
test('Autocomplete search', async ({page}) => {
await init(page, await Url.index());
await page.getByTestId('global-search-input').fill('%debugSearchAnyResult%');
await page.getByTestId('global-search-input').press('Space');
await expect(page.getByTestId('global-search-results')).toBeVisible();
await expect(page.locator('[data-testid="global-search-results"] [data-result-type="product"]').first()).toBeVisible();
});Add a second test with %debugSearchNoResult% that asserts the nothing-found message, and — when the form has min-length="0" — a third that focuses the empty field and expects the default panel.
Reference implementations compared
| Garden machinery | Workwear | Fishing gear | |
|---|---|---|---|
| Default panel | ✅ categories slider + hand-picked products (template attributes) | ❌ none (min-length 3) | ✅ content-grid chips + recently viewed / promo products via i:list-container |
| Types searched | categories, products, spare parts, articles | categories, brands, products, articles | brands, products, categories, articles |
| Section order | categories → products → articles → parts | categories → brands → products → articles | brands → products → categories → articles |
| Highlight | ✅ | ❌ | ✅ |
| Counts in headings | ✅ | ✅ | ✅ (brands hidden) |
| Product row | image, name, availability + store count, price, discount badge, 30-day-lowest | card: labels, image, name, delivery date, price, discount | image, name, rating, availability/date, price |
| Nothing found | plain sentence | ✅ message + contact + product strip | ✅ admin-editable text + contact link |
| Show-all row | ✅ | ✅ | ✅ "Vyhledat 'q'" |
| Tracking mode | legacy tabindex on <li>; decorative tabindex="4" tooltip | legacy tabindex, no data-product-id | ✅ section mode |
| Known defects | queries run before the state check; store query per row | double product query; no highlight; no product id | articles missing from the nothing-found condition; categories below products |
Review checklist
Use this when auditing a shop's ajax/search.tpl and header form. Every "no" is a task.
Default panel
- [ ] Form has
min-length="0"and the template has aq is emptybranch - [ ] The branch shows recommendations (
recommendHomepagethroughi:list-container), recently viewed, or admin-managed popular categories — at least one - [ ] Products in the panel render through
i:list-container(measured), not a hand loop - [ ] No
searchquery runs before the state check
Results
- [ ] Categories, products, articles searched; brands and text pages where the shop has them
- [ ] Categories (and brands) come before products
- [ ] Section headings carry
totalCount - [ ] ≤ ~10 items on desktop; product rows show image, name, availability, price
- [ ]
highlight(q)on every name;.global-highlight-searchstyled - [ ] Show-all row uses
searchPageUrlanddata-result-type="more" - [ ] No repository call inside a result loop; no collection fetched twice
- [ ] Sliders/tooltips in the fragment initialise on
autocomplete:loaded
Nothing found
- [ ] Explicit message with a way forward (contact, product strip)
- [ ] The emptiness condition includes every collection rendered
Tracking
- [ ] Every result annotated with the exact
data-result-typevocabulary - [ ]
data-product-idon product results - [ ] Section mode (
data-search-section) — or, in legacy mode, positivetabindexon results only, nothing decorative - [ ] Annotations live in
ajax/search.tpl(not only in a custom-named template) - [ ] Verified in DevTools:
frontend-fulltext-search-logbeacon withstatus: successand a product id; response ends with<!-- tagged-search-results -->
Layout & interaction
- [ ]
body.search-openused for overlay/blur - [ ] Container scrolls internally; fits under the field on mobile with a close control
- [ ]
.search-current-itemstyled; arrow keys follow the visual order - [ ] Clear button and a question-style placeholder
- [ ]
data-testid="global-search-input"/global-search-resultspresent; Playwright spec uses%debugSearchAnyResult%