Concepts / Building Search UI / Customize an existing widget
Jul. 18, 2019

Customize an Existing Widget

You are currently reading the documentation for InstantSearch.js V3. Read our migration guide to learn how to upgrade from V2 to V3. You can still find the V2 documentation here.

Highlight and snippet your search results

Search is all about helping users understand the results. This is especially true when using text based search. When a user types a query in the search box, the results must show why the results are matching the query. That’s why Algolia implements a powerful highlighting that lets you display the matching parts of text attributes in the results. On top of that, Algolia implements snippeting to get only the meaningful part of a text, when attributes have a lot of content.

This feature is already packaged for you in InstantSearch.js through two functions highlight and snippet.

Usage with templates

You have a direct access to the highlight and snippet functions through the template system.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const search = instantsearch({
  indexName: 'instant_search'
  searchClient,
});

search.addWidget(
  instantsearch.widgets.hits({
    container: '#hits',
    templates: {
      item: `
        <article>
          <p>Name: {{#helpers.highlight}}{ "attribute": "name", "highlightedTagName": "mark" }{{/helpers.highlight}}</p>
          <p>Description: {{#helpers.snippet}}{ "attribute": "description", "highlightedTagName": "mark" }{{/helpers.snippet}}</p>
        </article>
      `
    }
  })
);

Usage with render functions

You can also use the highlight and snippet functions through render functions.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const search = instantsearch({
  indexName: 'instant_search'
  searchClient,
});

search.addWidget(
  instantsearch.widgets.hits({
    container: '#hits',
    templates: {
      item(hit) {
        return `
          <article>
            <p>Name: ${instantsearch.highlight({ attribute: 'name', highlightedTagName: 'mark', hit })}</p>
            <p>Name: ${instantsearch.snippet({ attribute: 'name', highlightedTagName: 'mark', hit })}</p>
          </article>
        `;
      }
    }
  })
);

Style your widgets

All widgets in InstantSearch.js namespace are shipped with CSS class names that can be overriden.

The format for those class names is ais-NameOfWidget-element--modifier. We are following the naming convention defined by SUIT CSS.

The different class names used by each widget are described on their respective documentation pages. You can also inspect the underlying DOM and style accordingly.

Loading the theme

We do not load any CSS into your page automatically but we provide two themes that you can load manually:

  • reset.css
  • algolia.css

We strongly recommend that you use at least reset.css in order to avoid visual side effects caused by the new HTML semantics.

The reset theme CSS is included within the algolia CSS, so there is no need to import it separately when you are using the algolia theme.

Via CDN

The themes are available on jsDelivr:

unminified:

minified:

You can either copy paste the content into your own app or use a direct link to jsDelivr:

1
2
3
4
<!-- Include only the reset -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/instantsearch.css@7.3.1/themes/reset-min.css" integrity="sha256-t2ATOGCtAIZNnzER679jwcFcKYfLlw01gli6F6oszk8=" crossorigin="anonymous">
<!-- or include the full Algolia theme -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/instantsearch.css@7.3.1/themes/algolia-min.css" integrity="sha256-HB49n/BZjuqiCtQQf49OdZn63XuKFaxcIHWf0HNKte8=" crossorigin="anonymous">

Via npm & Webpack

1
2
npm install instantsearch.css
npm install --save-dev style-loader css-loader
1
2
3
4
// Include only the reset
import 'instantsearch.css/themes/reset.css';
// or include the full Algolia theme
import 'instantsearch.css/themes/algolia.css';
1
2
3
4
5
6
7
8
9
10
module.exports = {
  module: {
    loaders: [
      {
        test: /\.css$/,
        loaders: ['style?insertAt=top', 'css'],
      },
    ],
  },
};

Other bundlers

Any other module bundler like Browserify or Parcel can be used to load our CSS. InstantSearch.js does not rely on any specific module bundler or module loader.

CSS class override

You can override the class names of every widgets with the cssClasses option. The different key provided by each widget to override the class are described on their respective documentation pages. Here is an example with the hits widget:

1
2
3
4
5
6
7
8
search.addWidget(
  instantsearch.widgets.hits({
    container: '#hits',
    cssClasses: {
      item: 'item-custom-css-class',
    },
  })
);

Styling icons

You can style the icon colors using the widget class names:

1
2
3
4
.ais-SearchBox-submitIcon path
.ais-SearchBox-resetIcon path {
  fill: red,
}

Translate your widgets

Most elements in InstantSearch.js widgets can be customized by means of templates. Those templates can be simple text labels or complete piece of HTML. Underneath, InstantSearch.js is using Hogan, which is an implementation of mustache, the logic-less templating.

Example: translating the “show more” label

Here is an example of a menu widget with a show more label translated in french:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const search = instantsearch({
  // provide the search client here
});

search.addWidget(
  instantsearch.widgets.menu({
    attribute: '...',
    templates: {
      showMoreText: `
        {{#isShowingMore}}
          Voir moins…
        {{/isShowingMore}}
        {{^isShowingMore}}
          Voir plus…
        {{/isShowingMore}}
        `,
    }
  })
);

Templating your UI

InstantSearch.js widgets provide templates so that you can customize pieces of the widgets UI. For customizing widgets you can either use:

  • string based templates using mustache
  • or provide a function that should output a string with the rendering

The string-based templates are based on mustache and the implementation is using Hogan. For more reference on those, check out their websites. It might happen that you want to be able to customize a part of the widget that the template system does not cover. For these use cases take a look at the section extending widgets.

Example using mustache

1
2
3
4
5
6
7
8
9
10
const search = instantsearch({
  /* InstantSearch options */
});

search.addWidget(instantsearch.refinementList({
  container: '#hits',
  templates: {
    item: '👉 {{value}}',
  }
}));

Example using a function

1
2
3
4
5
6
7
8
9
10
11
12
const search = instantsearch({
  /* InstantSearch options */
});

search.addWidget(instantsearch.refinementList({
  container: '#hits',
  templates: {
    item(item) {
      return `👉 ${item.value}`;
    }
  }
}));

Modify the list of items in widgets

Every widget and connector that handles a list of items exposes a transformItems option. This option is a function that takes the items as a parameter and expect to return the items back. This option can be used to sort, filter and add manual values.

Sorting

In this example we use the transformItems option to order the items by label in a ascending mode:

1
2
3
4
5
6
7
8
9
10
search.addWidget(
  instantsearch.widgets.refinementList({
    container: '#brands',
    attribute: 'brand',
    transformItems(items) {
      // Assume that LoDash is available
      return _.orderBy(items, 'label', 'asc');
    }
  })
);

Some of the widget and connector also provide a sortBy option. It accepts either an array of strings, like so:

1
2
3
4
5
6
7
search.addWidget(
  instantsearch.widgets.refinementList({
    container: '#brands',
    attribute: 'brand',
    sortBy: ['isRefined', 'count:desc', 'name:asc']
  })
);

Or use a comparison function:

1
2
3
4
5
6
7
8
9
10
11
search.addWidget(
  instantsearch.widgets.refinementList({
    container: '#brands',
    attribute: 'brand',
    sortBy: (a, b) {
      // if a should be before b return -1
      // if b should be before a return 1
      // otherwise return 0
    },
  })
);

Filtering

In this example we use the transformItems option to filter out items when the count is lower than 150

1
2
3
4
5
6
7
8
9
search.addWidget(
  instantsearch.widgets.refinementList({
    container: '#brands',
    attribute: 'brand',
    transformItems(items) {
      return items.filter(item => item.count >= 150);
    }
  })
);

Add manual values

By default, the values in a RefinementList or a Menu are dynamic. This means that the values are updated with the context of the search. Most of the time this is the expected behavior, but in some cases you may want to have a static list of values that never change. To achieve this we can use the connectors.

In this example we are using the refinementList connector to display a static list of values. This RefinementList will always display and only display the items “Apple” and “Microsoft”.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
const staticRefinementList = instantsearch.connectors.connectRefinementList(
  ({ items, refine, widgetParams }, isFirstRender) => {
    const container = document.getElementById('brand-list');

    if (isFirstRender) {
      container.addEventListener('click', ({target}) => {
        const input = target.closest('input');

        if (input) {
          refine(input.value);
        }
      });

      return;
    }

    const list = widgetParams.items.map(({label: staticLabel, value}) => {
      const { isRefined } = items.find(
        ({label}) => label === staticLabel
      ) || {
        isRefined: false,
      };

      return `
        <li>
          <label>
            <input
              type="checkbox"
              value="${value}"
              ${isRefined ? 'checked' : ''}
            />
            ${staticLabel}
          </label>
        </li>
      `;
    });

    container.innerHTML = `
      <ul>
        ${list.join('')}
      </ul>
    `;
  }
);

search.addWidget(
  staticRefinementList({
    attribute: 'brand',
    items: [
      { label: 'Apple', value: 'Apple' },
      { label: 'Microsoft', value: 'Microsoft' },
    ],
  })
);

Searching long lists

For some cases, you want to be able to directly search into a list of facet values. This can be achieved using the searchable prop on widgets like refinementList or refinementList connector. To enable this feature, you’ll need to make the attribute searchable using the API or the Dashboard.

With widgets

Use the searchable prop to add a search box to supported widgets:

1
2
3
4
5
6
7
search.addWidget(
  instantsearch.widgets.refinementList({
    container: '#brand-list',
    attribute: 'brand',
    searchable: true,
  })
);

With connectors

You can implement your own search box for searching for items in lists when using supported connectors by using those provided params:

  • searchForItems(query): call this function with a search query to trigger a new search for items
  • isFromSearch: true when you are in search mode and the provided items are search items results
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
const customRefinementListWithSearchBox = instantsearch.connectors.connectRefinementList(
  ({ items, refine, searchForItems, isFromSearch }, isFirstRender) => {
    const container = document.getElementById('brand-list');

    if (isFirstRender) {
      container.innerHTML = `
        <div>
          <input type="search" />
          <ul></ul>
        </div>
      `;

      container.addEventListener('click', ({target}) => {
        const input = target.closest('input[type="checkbox"]');

        if (input) {
          refine(input.value);
        }
      });

      container.addEventListener('input', ({target}) => {
        const isSearchInput =
          target.nodeName === 'INPUT' && target.type === 'search';

        if (isSearchInput) {
          searchForItems(target.value);
        }
      });

      return;
    }

    if (!isFromSearch) {
      container.querySelector('input[type="search"]').value = '';
    }

    container.querySelector('ul').innerHTML = items
      .map(
        ({value, isRefined, highlighted, count}) => `
          <li>
            <label>
              <input
                type="checkbox"
                value="${value}"
                ${isRefined ? 'checked' : ''}
              />
              ${highlighted} (${count})
            </label>
          </li>
        `
      )
      .join('');
  }
);

search.addWidget(
  customRefinementListWithSearchBox({
    attribute: 'brand',
  })
);

Apply default value to widgets

A question that comes up frequently is “how do I instantiate a refinementList widget with a pre-selected item?”. For this use case, you can use the configure widget.

The following example instantiates a search page with a default query of “apple” and will show a category menu where the item “Cell Phones” is already selected:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const search = instantsearch({
  indexName: 'instant_search',
  searchClient,
});

search.addWidget(
  instantsearch.widgets.configure({
    query: 'apple',
    disjunctiveFacetsRefinements: {
      categories: ['Cell Phones'],
    },
  })
);

search.addWidget(
  instantsearch.widgets.refinementList({
    container: '#category',
    attribute: 'categories',
  })
);

search.addWidget(
  instantsearch.widgets.hits({
    container: '#hits',
  })
);

How to provide search parameters

Algolia has a wide range of parameters. If one of the parameters you want to use is not covered by any widget or connector, then you can use the configure widget.

Here’s an example configuring the distinct parameter:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const search = instantsearch({
  indexName: 'instant_search',
  searchClient,
});

search.addWidget(
  instantsearch.widgets.configure({
    distinct: 1,
  })
);

search.addWidget(
  instantsearch.widgets.hits({
    container: '#hits',
  })
);

Dynamic update of search parameters

In order to dynamically update the search parameters, you should create a new custom widget.

Filters your results in a way that is not covered by any widget

Our widgets already provides a lot of different ways to filter your results but sometimes you might have more complicated use cases that require the usage of the filters search parameter.

Don’t use filters on a attribute already used with a widget, it will conflict.

1
2
3
4
5
search.addWidget(
  instantsearch.widgets.configure({
    filters: 'NOT categories:"Cell Phones"',
  })
);

Customize the complete UI of the widgets

InstantSearch.js comes with widgets that have a standardized rendering. If you feel limited by the options provided, you can go further using connectors.

Connectors are the render-less counterparts of the widgets. They encapsulate all the logic needed for making search widgets. Each one of them is specialized to make a certain type of widget.

If you want to create a type of widget that is not available, you should then create a custom widget.

Introduction to connectors

Anatomy of a connector

A connector is a function that will create a widget factory, which is a function that can create widget instances.

They follow the pattern:

1
(rendering, unmount?) => (widgetParameters) => Widget

In practice, creating a new custom widget based on a connector would look like that:

1
2
3
4
5
6
7
8
9
10
11
const makeHits = instantsearch.connectors.connectHits(
  function renderHits({ hits }, isFirstRendering) {
    hits.forEach(hit => {
      console.log(hit);
    });
  }
);

const search = instantsearch(/* options */);

search.addWidget(makeHits());

Reusability of connectors

Connectors are meant to be reusable, it is important to be able to pass options to the rendering of each single widget instance when instantiating them. That’s why all the options passed to the newly created widget factory will be forwarded to the rendering.

Let’s take an example where we want to be able to configure the DOM element that will host the widget:

1
2
3
4
5
6
7
8
9
10
11
12
13
const makeHits = instantsearch.connectors.connectHits(
  function renderHits({ hits, widgetParams }, isFirstRendering) {
    // widgetParams contains all the option used to call the widget factory
    const container = widgetParams.container;

    $(container).html(hits.map(hit => JSON.stringify(hit)));
  }
);

const search = instantsearch(/* options */);

search.addWidget(makeHits({container: $('#hits-1')}));
search.addWidget(makeHits({container: $('#hits-2')}));

When is the rendering function called?

The rendering function is called before the first search (init lifecycle step) and each time results come back from Algolia (render lifecycle step).

Depending on the method you are relying on to render your widget, you might want to use the first call to create the basic DOM structure (like when using vanilla JS or jQuery).

To be able to identify at which point of the lifecycle the rendering function is called, a second argument isFirstRendering is provided to the rendering function.

This parameter is there to be able to only do some operations once, like creating the basic structure of the new widget once. The latter calls can then be used to only update the DOM.

1
2
3
4
5
6
7
8
9
10
11
12
13
const makeHits = instantsearch.connectors.connectHits(
  function renderHits({ hits }, isFirstRendering) {
    if (isFirstRendering) {
      // Do some initial rendering
    }

    // Do the normal rendering
  }
);

const search = instantsearch(/* options */);

search.addWidget(makeHits());

When is the unmount function called?

The unmount function is called when you remove a widget.

When search.removeWidget(widget) is called, InstantSearch.js cleans up the internal data of the widget, and calls the unmount function to clean up the DOM.

1
2
3
4
5
6
7
8
9
const makeHits = instantsearch.connectors.connectHits(
  function renderHits({ hits, widgetParams }, isFirstRendering) {
    const container = widgetParams.container;
    $(container).html(hits.map(hit => JSON.stringify(hit)));
  },
  function unmount() {
    $("#hits").remove();
  }
);

Customize widgets hands-on

InstantSearch.js comes bundled with a set of 15+ UI components. Each of them has options to manipulate CSS classes or even modifying part of the HTML output (templates).

To go a step further in terms of customization, InstantSearch.js offers connectors that contain the logic of the widgets without their rendering.

A custom menu with jQuery

In this example we will create a new custom widget using menu connector. We will cover step by step how to write a render function used by the connector.

For simplicity, we will write custom widgets with jQuery to manipulate the DOM.

In the first three steps we focus on implementing the rendering function and then will connect it to InstantSearch.

1. Set up the DOM

Since we use jQuery in these examples, we want to update only the changing parts of the markup at every render.

To help you to do that, the connectors API provides isFirstRendering a boolean as second argument of the render function. We can leverage this to insert the initial markup of your custom widget.

1
2
3
4
5
6
7
8
9
10
const customMenuRenderFn = (renderParams, isFirstRendering) => {
  if (isFirstRendering) {
    // insert needed markup in the DOM
    // here we want a `<select></select>` element and the title
    $(document.body).append(`
      <h1>My first custom menu widget</h1>
      <select></select>
    `);
  }
}

If you use a rendering library such as React, you can omit this part because React will compute this for you.

2. Display the available dropdown options

Then, on every render we want to update and insert the available menu items as <option> DOM nodes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const customMenuRenderFn = (renderParams, isFirstRendering) => {
  if (isFirstRendering) {
    $(document.body).append(`
      <h1>My first custom menu widget</h1>
      <select></select>
    `);
  }

  // `renderParams` is an object containing all the information
  // you need to create a custom widget.
  const items = renderParams.items;

  // Transform `items[]` to HTML markup:

  // each item comes with a `value` and `label`, it will also have a boolean to true
  // called `isRefined` when the current menu item is selected by the user.
  const optionsHTML = items.map(({value, isRefined, label, count}) => `
    <option value="${value}" ${isRefined ? ' selected' : ''}>
      ${label} (${count})
    </option>`
  );

  // then replace the content of `<select></select>` node with the new menu items markup.
  $(document.body).find('select').html(optionsHTML);
}

Now we have all the menu options displayed on the page but nothing is updating when the user selects a new option. Let’s connect the dropdown to the search.

3. Make it interact with the search results

The menu connector comes with a refine() function in the first argument renderParams object. You need to call this refine() function every time a user select another option to refine the search results:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const customMenuRenderFn = (renderParams, isFirstRendering) => {
  if (isFirstRendering) {
    $(document.body).append(`
      <h1>My first custom menu widget</h1>
      <select></select>
    `);

    // We will bind the `<select>` change event on first render
    // because we don't want to create new listeners on every render
    // for potential performance issues:
    const refine = renderParams.refine;

    // we will use `event.target.value` to identify
    // which option is selected and then refine it:
    $(document.body).find('select').on('change', ({target}) => {
      refine(target.value);
    });
  }

  const items = renderParams.items;
  const optionsHTML = items.map(({value, isRefined, label, count}) => `
    <option value="${value}" ${isRefined ? 'selected' : ''}>
      ${label} (${count})
    </option>
  `);

  $(document.body).find('select').html(optionsHTML);
}

Now every time a user selects a new option in the dropdown menu, it triggers a new search to refine the search results!

4. Mount the custom menu dropdown widget on your page

We’ve just written the render function, we can now use it with the menu connector. This will create a new widget factory for our custom dropdown widget.

Let’s use this factory in your search:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
const customMenuRenderFn = (renderParams, isFirstRendering) => {
  if (isFirstRendering) {
    $(document.body).append(`
      <h1>My first custom menu widget</h1>
      <select></select>
    `);

    const refine = renderParams.refine;
    $(document.body).find('select').on('change', ({target}) => {
      refine(target.value);
    });
  }

  const items = renderParams.items;
  const optionsHTML = items.map(({value, isRefined, label, count}) => `
    <option value="${value}" ${isRefined ? 'selected' : ''}>
      ${label} (${count})
    </option>
  `);

  $(document.body).find('select').html(optionsHTML);
}

// Create a new factory of the custom menu select widget:
const dropdownMenu = instantsearch.connectors.connectMenu(customMenuRenderFn);

// Instantiate custom widget and display it on the page.

// Custom widgets that are created with connectors accepts
// the same options as a built-in widget, for instance
// the menu widget takes a mandatory `attribute` option
// so we have to do the same:
search.addWidget(
  dropdownMenu({
    attribute: 'categories'
  })
);

This example works on a single DOM element, which means that you won’t be able to re-use it for another attribute.

5. Make it reusable!

Connectors are meant to be reusable, it is important to be able to pass options to the rendering of each single widget instance when instantiating them.

That’s why all the options passed to the newly created widget factory will be forwarded to the rendering.

Let’s update our custom render function to be able to configure the DOM element where the widget is mounted and also the title:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
const customMenuRenderFn = (renderParams, isFirstRendering) => {
  // widgetParams contains all the original options used to instantiate the widget on the page.
  const container = renderParams.widgetParams.containerNode;
  const title = renderParams.widgetParams.title || 'My first custom menu widget';

  if (isFirstRendering) {
    // replace `document.body` with the container provided by the user
    // and also the new title
    $(container).append(`
      <h1>${title}</h1>
      <select></select>
    `);

    const refine = renderParams.refine;
    $(container).find('select').on('change', ({target}) => {
      refine(target.value);
    });
  }

  const items = renderParams.items;
  const optionsHTML = items.map(({value, isRefined, label, count}) => `
    <option value="${value}" ${isRefined ? 'selected' : ''}>
      ${label} (${count})
    </option>
  `);

  $(container).find('select').html(optionsHTML);
}

const dropdownMenu = instantsearch.connectors.connectMenu(customMenuRenderFn);

// Now you can use the dropdownMenu at two different places in the DOM:
// (since they use the same `attribute` they will display the same options)
search.addWidget(
  dropdownMenu({
    attribute: 'categories',
    containerNode: '#first-dropdown',
  })
);

search.addWidget(
  dropdownMenu({
    attribute: 'categories',
    containerNode: '#second-dropdown',
  })
);

With these steps we introduced a way to provide custom parameters:

  • a DOM container
  • a title

And voilà, we have covered how to write a simple custom widget using connectors 🎉 !

Did you find this page helpful?