Template Customization
HTML Templates
All widgets accepttemplates for customizing their HTML output:
import { hits } from 'instantsearch.js/es/widgets';
hits({
container: '#hits',
templates: {
item: (hit, { html, components }) => html`
<article class="product">
<img src="${hit.image}" alt="${hit.name}" />
<div class="product-info">
<h3>${components.Highlight({ hit, attribute: 'name' })}</h3>
<p class="brand">${hit.brand}</p>
<p>${components.Snippet({ hit, attribute: 'description' })}</p>
<div class="product-footer">
<span class="price">$${hit.price}</span>
<span class="rating">★ ${hit.rating}</span>
</div>
</div>
</article>
`,
empty: ({ html, query }) => html`
<div class="no-results">
<p>No results found for <strong>"${query}"</strong></p>
<p>Try different keywords or clear some filters</p>
</div>
`,
},
})
Template Helpers
The template function receives helper utilities:templates: {
item: (hit, { html, components, sendEvent }) => {
// html - Tagged template function for rendering
// components - Highlight, Snippet, ReverseHighlight, ReverseSnippet
// sendEvent - Track insights events
return html`
<article onClick="${() => sendEvent('click', hit, 'Product Clicked')}">
<!-- Highlight matching parts -->
${components.Highlight({ hit, attribute: 'name' })}
<!-- Show snippet with highlighting -->
${components.Snippet({ hit, attribute: 'description' })}
<!-- Reverse highlighting (show non-matching parts) -->
${components.ReverseHighlight({ hit, attribute: 'tags' })}
<!-- Reverse snippet -->
${components.ReverseSnippet({ hit, attribute: 'content' })}
</article>
`;
},
}
Component Options
Highlight and Snippet components accept additional options:components.Highlight({
hit,
attribute: 'name',
highlightedTagName: 'mark', // default: 'mark'
})
components.Snippet({
hit,
attribute: 'description',
highlightedTagName: 'em',
})
Conditional Rendering
Render different templates based on data:hits({
container: '#hits',
templates: {
item: (hit, { html, components }) => {
const hasDiscount = hit.price < hit.originalPrice;
const inStock = hit.inventory > 0;
return html`
<article class="${!inStock ? 'out-of-stock' : ''}">
<h3>${components.Highlight({ hit, attribute: 'name' })}</h3>
${hasDiscount
? html`
<span class="original-price">$${hit.originalPrice}</span>
<span class="sale-price">$${hit.price}</span>
<span class="discount">${Math.round((1 - hit.price / hit.originalPrice) * 100)}% off</span>
`
: html`<span class="price">$${hit.price}</span>`
}
${!inStock
? html`<span class="stock-status">Out of stock</span>`
: html`<button>Add to Cart</button>`
}
</article>
`;
},
},
})
Transform Items
Modify widget data before rendering:import { hits, refinementList } from 'instantsearch.js/es/widgets';
// Transform search results
hits({
container: '#hits',
transformItems: (items) =>
items.map((item, index) => ({
...item,
// Add position for tracking
position: index + 1,
// Format price
formattedPrice: `$${(item.price / 100).toFixed(2)}`,
// Add computed fields
isNew: Date.now() - item.createdAt < 7 * 24 * 60 * 60 * 1000,
hasDiscount: item.price < item.originalPrice,
})),
})
// Transform facet values
refinementList({
container: '#brands',
attribute: 'brand',
transformItems: (items) =>
items
.map((item) => ({
...item,
// Customize labels
label: item.label.toUpperCase(),
// Add custom data
highlighted: item.count > 100,
}))
// Sort alphabetically
.sort((a, b) => a.label.localeCompare(b.label)),
})
Advanced Transformations
Combine transformations with external data:const brandLogos = {
'Apple': '/logos/apple.png',
'Samsung': '/logos/samsung.png',
};
refinementList({
container: '#brands',
attribute: 'brand',
transformItems: (items) =>
items.map((item) => ({
...item,
logo: brandLogos[item.label] || '/logos/default.png',
})),
templates: {
item: ({ label, count, logo, html }) => html`
<label class="brand-item">
<input type="checkbox" />
<img src="${logo}" alt="${label}" class="brand-logo" />
<span>${label} (${count})</span>
</label>
`,
},
})
Custom CSS Classes
All widgets acceptcssClasses for custom styling:
import { searchBox, hits } from 'instantsearch.js/es/widgets';
searchBox({
container: '#searchbox',
cssClasses: {
root: 'custom-searchbox',
form: 'custom-searchbox__form',
input: 'custom-searchbox__input',
submit: 'custom-searchbox__submit',
reset: 'custom-searchbox__reset',
loadingIndicator: 'custom-searchbox__loading',
},
})
hits({
container: '#hits',
cssClasses: {
root: 'custom-hits',
emptyRoot: 'custom-hits--empty',
list: 'custom-hits__list',
item: 'custom-hits__item',
},
})
Combining Default and Custom Classes
Custom classes are added alongside default BEM classes:refinementList({
container: '#brands',
attribute: 'brand',
cssClasses: {
root: 'my-facet', // Added to .ais-RefinementList
list: 'my-facet__list', // Added to .ais-RefinementList-list
item: 'my-facet__item', // Added to .ais-RefinementList-item
},
})
Custom Widgets with Connectors
Connectors provide the logic without UI, letting you build completely custom widgets:import { connectSearchBox } from 'instantsearch.js/es/connectors';
const customSearchBox = connectSearchBox((renderOptions, isFirstRender) => {
const { query, refine, clear, widgetParams } = renderOptions;
const { container } = widgetParams;
if (isFirstRender) {
// Create HTML on first render
const input = document.createElement('input');
const button = document.createElement('button');
input.placeholder = 'Search...';
button.textContent = 'Clear';
input.addEventListener('input', (event) => {
refine(event.target.value);
});
button.addEventListener('click', () => {
clear();
input.value = '';
});
container.appendChild(input);
container.appendChild(button);
}
// Update on subsequent renders
const input = container.querySelector('input');
input.value = query;
});
// Use the custom widget
search.addWidgets([
customSearchBox({
container: document.querySelector('#custom-searchbox'),
}),
]);
React-like Custom Widget
Build widgets with a more declarative approach:import { connectHits } from 'instantsearch.js/es/connectors';
const customHits = connectHits((renderOptions, isFirstRender) => {
const { hits, results, widgetParams } = renderOptions;
const { container } = widgetParams;
container.innerHTML = `
<div class="custom-hits">
<p class="custom-hits__count">
${results.nbHits} results found
</p>
<ul class="custom-hits__list">
${hits
.map(
(hit) => `
<li class="custom-hits__item">
<img src="${hit.image}" alt="${hit.name}" />
<h3>${hit._highlightResult.name.value}</h3>
<p>$${hit.price}</p>
</li>
`
)
.join('')}
</ul>
</div>
`;
});
search.addWidgets([
customHits({
container: document.querySelector('#custom-hits'),
}),
]);
Available Connectors
InstantSearch.js provides connectors for all widgets:import {
connectSearchBox,
connectHits,
connectRefinementList,
connectPagination,
connectStats,
connectSortBy,
connectHierarchicalMenu,
connectMenu,
connectRangeInput,
connectRangeSlider,
connectToggleRefinement,
connectNumericMenu,
connectRatingMenu,
connectClearRefinements,
connectCurrentRefinements,
connectInfiniteHits,
connectHitsPerPage,
connectBreadcrumb,
connectGeoSearch,
connectPoweredBy,
} from 'instantsearch.js/es/connectors';
Widget Composition
Panel Wrapper
Wrap widgets with headers, footers, and collapsing:import { panel, refinementList } from 'instantsearch.js/es/widgets';
// Basic panel
panel({
templates: {
header: ({ html }) => html`<h3>Filter by Brand</h3>`,
footer: ({ html }) => html`<p>Select one or more brands</p>`,
},
})(refinementList)({
container: '#brands',
attribute: 'brand',
})
// Conditional panel with collapsing
panel({
templates: {
header: ({ items, html }) => html`
<h3>Brands (${items.length})</h3>
`,
},
// Hide when no results
hidden: ({ results }) => results.nbHits === 0,
// Collapse when no query
collapsed: ({ state }) => !state.query,
cssClasses: {
root: 'my-panel',
header: 'my-panel__header',
body: 'my-panel__body',
footer: 'my-panel__footer',
collapseButton: 'my-panel__collapse',
collapseIcon: 'my-panel__collapse-icon',
},
})(refinementList)({
container: '#brands',
attribute: 'brand',
})
Multiple Panels
Create reusable panel configurations:import { panel, refinementList, menu } from 'instantsearch.js/es/widgets';
const facetPanel = (title) =>
panel({
templates: {
header: ({ html }) => html`<h3>${title}</h3>`,
},
hidden: ({ results }) => results.nbHits === 0,
});
search.addWidgets([
facetPanel('Brands')(refinementList)({
container: '#brands',
attribute: 'brand',
}),
facetPanel('Categories')(menu)({
container: '#categories',
attribute: 'category',
}),
facetPanel('Colors')(refinementList)({
container: '#colors',
attribute: 'color',
}),
]);
Query Hooks
Modify search queries before they’re sent:import { searchBox } from 'instantsearch.js/es/widgets';
// Debounce search queries
function debounce(fn, delay) {
let timeoutId;
return function (...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay);
};
}
searchBox({
container: '#searchbox',
queryHook: (query, search) => {
const debouncedSearch = debounce(search, 300);
debouncedSearch(query);
},
})
// Transform query
searchBox({
container: '#searchbox',
queryHook: (query, search) => {
// Remove special characters
const cleanedQuery = query.replace(/[^a-zA-Z0-9 ]/g, '');
search(cleanedQuery);
},
})
// Add query suggestions
searchBox({
container: '#searchbox',
queryHook: (query, search) => {
// Log query for analytics
console.log('User searched for:', query);
// Perform the search
search(query);
},
})
Event Handlers
State Change Handler
React to search state changes:const search = instantsearch({
indexName: 'products',
searchClient,
onStateChange: ({ uiState, setUiState }) => {
// Log state changes
console.log('Search state changed:', uiState);
// Modify state before applying
const modifiedState = {
...uiState,
products: {
...uiState.products,
// Always limit to 20 results
configure: {
...uiState.products?.configure,
hitsPerPage: 20,
},
},
};
setUiState(modifiedState);
},
});
Insights Events
Track user interactions:import { hits } from 'instantsearch.js/es/widgets';
hits({
container: '#hits',
templates: {
item: (hit, { html, sendEvent }) => html`
<article>
<h3>${hit.name}</h3>
<button
onClick="${() => {
sendEvent('click', hit, 'Product Clicked');
}}"
>
View Details
</button>
<button
onClick="${() => {
sendEvent('conversion', hit, 'Product Added to Cart');
// Add to cart logic
}}"
>
Add to Cart
</button>
</article>
`,
},
})
Middleware
Extend InstantSearch.js functionality with middleware:const analyticsMiddleware = () => {
return {
onStateChange({ uiState }) {
console.log('Analytics: State changed', uiState);
},
subscribe() {},
unsubscribe() {},
};
};
const search = instantsearch({
indexName: 'products',
searchClient,
});
search.use(analyticsMiddleware());
Insights Middleware
The insights middleware enables event tracking:import { createInsightsMiddleware } from 'instantsearch.js/es/middlewares';
import aa from 'search-insights';
aa('init', {
appId: 'YourApplicationID',
apiKey: 'YourSearchOnlyAPIKey',
});
const insightsMiddleware = createInsightsMiddleware({
insightsClient: aa,
insightsInitParams: {
useCookie: true,
userToken: 'user-123',
},
});
const search = instantsearch({
indexName: 'products',
searchClient,
});
search.use(insightsMiddleware);
Next Steps
Styling
Style your search interface with CSS
Custom Connectors
Build advanced custom widgets
Routing
Synchronize search state with URLs
Insights
Track and analyze user behavior