Crafting a Responsive Search Bar with Intelligent Autocomplete

2021

Try it live here!

The search function on Eric's Guide - my craft cocktail recipe site - allows the user to search for recipes by name or ingredients to add to the set of active search filters.

When the search input is given focus, the zero state of the suggestion list recommends the top base spirits by popularity and a few core recipes. When one or more ingredient filters are active, the zero state of the suggestion list recommends the "next best" ingredients to buy in order to maximize the number of recipes the user can make.

An active search with prioritized search results, as seen on the mobile site

Background

I enjoy making craft cocktails at home. I used to keep a hand-written drink journal of my favorite recipes, but eventually it became tedious enough to find specific recipes in there that I decided to build a React site with full search and filtering capability.

Screenshot of the initial, one-page cocktail site
The first phase of the cocktail site: a one-page list of cocktails, with recipes popping in a modal upon selection

Version one of my search was a simple input that auto-completed the top result into the input as you type, with the remainder of the word/phrase living in an active text selection, just like a Google Chrome address bar.

Screenshot of search input version 1
Search bar v1, demonstrating the as-you-type autocomplete

You could hit Enter or submit to choose that result, press Backspace to clear the suggested selection, or keep typing to continue searching for your desired result. You could search for recipe names or ingredients, but it was limited by the "starts with" nature of displaying the search result within the input as you're typing. For example, to find a drink named "The Andy Special", typing "andy" wouldn't match it, only typing "the andy" would.

The list of recipes grew. I added URL support with react-router to enable Home, Explore, and Recipe pages, as well as a nav bar at the top.

With the site now having multiple pages and the search still living only on the Explore page, it was time to reexamine its function on the site. I set out to make a version two of the search, with two main motivations:

  1. Solve the "starts with" problem by having multiple results appear in a suggestion pane rather than one result within the input
  2. Make the search ever-present across the site by putting it in the nav bar, so one could use it as the primary navigation method, especially on mobile

I first searched around for some decent autocomplete libraries that might fit my use case. Algolia's autocomplete seemed like one of the top ones, so I tried it out, but had trouble finding clear documentation on the data structure you feed to it, and it didn't seem like it could easily handle two types of search results (recipes and ingredients). I also had a unique, fully-responsive vision for the search, and quickly realized it was unlikely any library would support that vision.

It was time to do what I love most: crafting a bespoke component from scratch that provides a superior UX for the situation than other alternatives.

Requirements

I came up with a list of initial requirements to serve that vision:

  • Lives in fixed floating nav bar
  • Provides instant results as you type
  • Shows context-sensitive suggestions upon initial focus of search input
  • Suggestion pane takes over full screen on mobile, just like iOS Safari
  • Suggestion pane pops in small pane on desktop, just below the input
  • Suggestion list is keyboard-navigable with arrow keys, just like desktop Chrome address bar
  • Top result is always auto-selected so hitting Enter takes you there
  • Display a limited list of top suggestions, not all possible suggestions

There were also some requirements specific to my cocktail site that I wanted to achieve:

  • Suggestion pane always shows both ingredient and recipe name results (if available) to train the user that both are possible to search for
  • All ingredient result listings give an indication of what selecting one actually does for you (e.g. explore 33 recipes, completes 2 recipes, contributes to 11 recipes)
  • When ingredient filters are active, suggestion pane must indicate to the user that it's sensitive to that context, is excluding those filters from the results, and is adding on top of them
  • Sort algorithm for ingredient results subtly tells you the next-best ingredients to buy/use in order to access the most recipes
  • Whichever type of result has the most matches (ingredient names or recipe names), ensure that the limited suggestion list shows more of that category, but still some of the other - to continue to train the user that both are possible to search for

The "next-best ingredient" feature was something I'd had in mind for a while, and was delighted when I realized the new search gave me an opportunity to include it without adding complexity to the site.

Building the Search Index

First I needed a way of providing instant results as you type, in a format that could be quickly accessed and could support both ingredient names and recipe names as results.

I decided to break all multi-word names into single-word tokens, any of which could be matched against. The lookup index itself would be a JavaScript object, with individual letters as keys and nested objects as values, each having either more letters as keys to go deeper into a word, or a "results" array with a list of search results once we got to the last letter in a token.

Below is an example of a search index containing just two tokens: "amaro" and "amaretto". You can see the chain of letters going down into the nested structure:

{
  a: {
    m: {
      a: {
        r: {
          e: {
            t: {
              t: {
                o: {
                  results: [
                    {
                      token: 'amaretto',
                      type: SEARCH_RESULT_TYPE.RECIPE,
                      recipeId: 'amaretto-sour'
                    },
                    {
                      token: 'amaretto',
                      type: SEARCH_RESULT_TYPE.INGREDIENT,
                      ingredient: 'amaretto'
                    }
                  ]
                }
              }
            }
          },
          o: {
            results: [
              {
                token: 'amaro',
                type: SEARCH_RESULT_TYPE.RECIPE,
                recipeId: 'amaro-spritz'
              },
              {
                token: 'amaro',
                type: SEARCH_RESULT_TYPE.INGREDIENT,
                ingredient: 'amaro'
              },
              {
                token: 'amaro',
                type: SEARCH_RESULT_TYPE.INGREDIENT,
                ingredient: 'vermouth amaro'
              }
            ]
          }
        }
      }
    }
  }
}

My createSearchIndex function has three main steps:

  1. Break down all ingredient names into single word tokens and generate an object that maps from token keys to an array of search result objects matched by those tokens.
  2. Do the same for recipe names, adding in those tokens and results into the same token map.
  3. Loop through each key in that token map and invoke a recursive function against the token. The recursive function goes through the token, letter by letter, and builds each level of the nested search index object right up to the final level that contains the search result(s).

The resulting search index object makes it easy to take the few letters the user types in, for example "ama", recursing through it letter by letter, and retrieving the nested object at position searchIndex['a']['m']['a']. From there, it’s a simple matter to search through that object and compile all the "results" arrays into one single array of search results.

Sorting the Results

Once you have an array of the search results, the next step is to sort them so they meet the requirements I outlined above: the ingredient results must be sorted roughly in "next best" order, and if any recipes match, at least some must also be shown, even if the vast majority of results are ingredients.

Search suggestions displayed during a search for "ama". The ingredient that matches the most recipes is listed first.

The one quality I allowed to take precedence over "next best" was whether or not the ingredient started with the characters in the search term. This was to provide more relevant matches immediately, as I figured a user searching for a multi-word ingredient or recipe was likely to start at the beginning first.

Here are the priorities of the ingredient sort algorithm, in order:

  1. Ingredients that start with the search term
  2. Ingredients that complete the most recipes (if filters are active)
  3. Ingredients used in the most matching recipes (if filters are active)
  4. Ingredients used in the most recipes overall
  5. Alphabetical by name

This algorithm is implemented with a simple array sort function:

ingredients.sort((a, b) => {
  if (a.ingredient.startsWith(searchTerm) && !b.ingredient.startsWith(searchTerm)) {
    return -1;
  } else if (!a.ingredient.startsWith(searchTerm) && b.ingredient.startsWith(searchTerm)) {
    return 1;
  } else if (areFiltersActive && a.numFilteredRecipesThisCompletes > b.numFilteredRecipesThisCompletes) {
    return -1;
  } else if (areFiltersActive && a.numFilteredRecipesThisCompletes < b.numFilteredRecipesThisCompletes) {
    return 1;
  } else if (areFiltersActive && a.numFilteredRecipesThisIn > b.numFilteredRecipesThisIn) {
    return -1;
  } else if (areFiltersActive && a.numFilteredRecipesThisIn < b.numFilteredRecipesThisIn) {
    return 1;
  } else if (ingredientToRecipeMap[a.ingredient].length > ingredientToRecipeMap[b.ingredient].length) {
    return -1;
  } else if (ingredientToRecipeMap[b.ingredient].length > ingredientToRecipeMap[a.ingredient].length) {
    return 1;
  } else {
    return a.ingredient.localeCompare(b.ingredient);
  }
});

The recipe sort algorithm has similar priorities, though without the recipe-matching logic:

  1. Recipes whose name starts with the search term
  2. Alphabetical by name

This same sort algorithm is applied when one or more ingredient filters are already active. In the example below, "gin" and "lemon juice" are active filters, and a search for "d" prioritizes "dry vermouth", since adding that will complete the recipe for a martini.

A search for "d" when "gin" and "lemon juice" have already been selected as active filters.

To be true to the "give an indication of what selecting one actually does for you" requirement, I make sure to give an indication in the UI what's going on behind the scenes, calling out "completes 1 recipe" next to "dry vermouth". The available display options here match #2-4 in the priorities of the ingredient sort algorithm above, and are as follows:

  1. "completes N recipe(s)" - adding this ingredient to the active filters will enable you to make N recipes without needing additional ingredients
  2. "contributes to N recipe(s)" - adding this ingredient to the active filters will get you closer to the full list of ingredients needed for N of the recipe(s) already displayed based on the existing filters
  3. "enables N recipe(s)" - adding this ingredient to the active filters will add in N new recipe(s) beyond the ones that already match the existing filters

Calculating the "Next Best" Ingredients

The examples above show the search suggestions when a search term has been entered. That sort algorithm gives the user a decent sense of what the "next best" ingredients are, limited by the results that match the search term, and skewed a bit by which result names start with the search term.

But what about when there is no search term yet? This is the place I realized was perfect to insert the real "next best" ingredients in a subtle way, without complicating the UI.

When there are no active filters, the "next best" ingredients are pretty simple: the ones that will showcase the most recipes.

The zero state of the search suggestions from the Home page, when no ingredient filters have been selected.

When there are active filters, we need a slightly different sort algorithm than the one for results matching a search term. "Ingredients that start with the search term" no longer applies, and prioritizing all "ingredients that complete the most recipes" over all "ingredients used in the most matching recipes" seemed a little too simplistic, for reasons I'll explain in a bit.

Let's take the same simple case as before with two active filters, "gin" and "lemon juice". Prioritizing all "completes" over all "contributes to" in this case seems to work nicely. Within the "completes", "simple syrup" matches more recipes than "dry vermouth", and is moved up accordingly.

The zero state of the search suggestions when "gin" and "lemon juice" are the two active filters.

(Side Note: Because of the intent the user has already demonstrated here to filter by ingredients, as well as the usefulness of showing a longer list of "next best" ingredients, this is the one time I break my requirement of always showing both ingredients and recipes in the search suggestions.)

But what about a more complex case, with eleven filters selected? In this case, all six top results complete one or more recipes, but would it be appropriate for an ingredient's prevalence among many recipes to bump it up above an ingredient who completes more recipes? I thought it would.

The zero state of the search suggestions with eleven active filters.

Have a look above at how "rye" and "maraschino liqueur" (which both complete one recipe) appear above "campari" and "aperol" (which complete two recipes). Does this seem appropriate? Let’s look at the numbers:

  1. simple syrup: completes 4, contributes to 28
  2. rye: completes 1, contributes to 14
  3. maraschino liqueur: completes 1, contributes to 9
  4. campari: completes 2, contributes to 5
  5. aperol: completes 2, contributes to 5
  6. benedictine: completes 1, contributes to 6

If a user is using these suggestions to determine which bottle would be most valuable to add to their liquor collection next, wouldn't a bottle of rye give them more options than a bottle of campari, since it'd help them with fourteen recipes rather than five? I'd say yes. To take these multiple numbers into account simultaneously, computing a weighted score for each ingredient and sorting based on the score rather than individual metrics seemed to make sense.

I first decided to only utilize ingredients from recipes that match the active filters when computing the "next best" ingredients and their scores. It's possible that I could miss an ingredient this way that happens to enable a large swath of recipes not contained in the current list, but I reasoned that if someone's entering in their liquor collection, they'd rather know what would help them make a cocktail with what they already have, rather than what would help them make a bunch of recipes that don't use any of the bottles they own.

In calculating the score, I mirrored the way I was categorizing display of recipe results on the Explore page, which shows recipes that only have one missing ingredient ahead of ones that have two missing ingredients, ahead of all the others. One could weight these in all sorts of ways, but I decided to start with something simple and potentially tweak it later:

  • 10 points if the ingredient would complete a recipe that’s only missing one ingredient
  • 5 points if the ingredient would help complete a recipe that’s missing two ingredients
  • 1 point otherwise

With this scoring system, let's see what the computed scores are for the above example:

  1. simple syrup: completes 4, contributes to 28 = score of 92
  2. rye: completes 1, contributes to 14 = score of 39
  3. maraschino liqueur: completes 1, contributes to 9 = score of 38
  4. campari: completes 2, contributes to 5 = score of 31
  5. aperol: completes 2, contributes to 5 = score of 23
  6. benedictine: completes 1, contributes to 6 = score of 23

This system seemed to work reasonably well at achieving a rough "next best" order.

Making It Responsive

The two requirements around appearance - (a) suggestion pane takes over full screen on mobile, just like iOS Safari, and (b) suggestion pane pops in small pane on desktop, just below the input - meant that there would be two main breakpoints: mobile and desktop.

The mobile and desktop breakpoints of the search suggestion pane.

The fact that the search lives within a fixed floating nav bar made this both easier and more complicated. In order to position the floating suggestion pane nearby and the same width as the search input on the desktop breakpoint, I decided to have the suggestion pane live within the same container as the search and nav bar.

<div class="NavBar NavBar--scrolled">
  <div class="NavBar-content">
    <div class="NavBar-searchContainer">
      <div class="Search Search--open">
        <form class="Search-form">
          <input class="Search-input" value="" ... />
        </form>
        <div class="SearchSuggestions SearchSuggestions--open">
          <ol class="SearchSuggestions-list">
            ...
          </ol>
        </div>
      </div>
    </div>
  </div>
</div>
A simplified version of the HTML structure of the search within the nav bar.

For positioning, there are three key elements within the HTML above: NavBar, NavBar-searchContainer, and SearchSuggestions.

On mobile, NavBar is position fixed so it can remain visible even when scrolling, NavBar-searchContainer is position absolute so the search can animate to the condensed state of the nav bar when the page is scrolled, and SearchSuggestions is position fixed so it can take over the entire rest of the viewport's vertical space (via setting "bottom" to zero).

On desktop, NavBar remains position fixed, NavBar-searchContainer remains position absolute, but SearchSuggestions changes to position absolute, so it can now be positioned in a small pane relative to the NavBar-searchContainer, rather than relative to the viewport.

.NavBar {
  left: 0;
  position: fixed;  // always visible even when scrolled down
  right: 0;
  top: 0;
  z-index: 30;
}
.NavBar-searchContainer {
  position: absolute;
  left: 0;
  right: 0;
  top: 50px;        // leaves room for the logo above
  transition: left 250ms, top 250ms;
}
.NavBar--scrolled .NavBar-searchContainer {
  left: 30px;       // moves search over so logo icon is visible
  top: 10px;        // moves search up on the same line as the logo
}
.SearchSuggestions {
  bottom: 0;        // all the way to the bottom of the viewport
  display: none;
  left: 0;
  position: fixed;
  top: 90px;        // leaves room above for the nav bar
  width: 100%;
}
.NavBar--scrolled .SearchSuggestions {
  top: 50px;        // moves pane up to account for condensed nav bar
}
.SearchSuggestions--open {
  display: block;
}

@media (min-width: 1024px) {
  .NavBar-searchContainer {
    left: auto;          // overrides the mobile style
    top: 10px;
    transition: none;
    width: 340px;
  }
  .NavBar--scrolled .NavBar-searchContainer {
    left: auto;          // repeats the property again to override mobile style
    top: 10px;           // repeats the property again to override mobile style
  }
  .SearchSuggestions {
    bottom: auto;        // overrides the mobile style
    left: 0;             // aligns with the left of the search input
    position: absolute;  // is now relative to the searchContainer
    right: 0;            // aligns with the right of the search input
    top: 40px;           // positioned just below the search input
    width: auto;
  }
  .NavBar--scrolled .SearchSuggestions {
    top: 40px;           // repeats the property again to override mobile style
  }
}
A simplified version of the positioning CSS used for the three important elements in play.

The final piece needed was to prevent the page from scrolling on mobile when the search pane is up. This is a common need in front-end development when modals are up, and usually accomplished through adding a CSS class to the body and/or html elements that sets overflow to hidden, which you then remove when the modal is closed.

However, iOS Safari doesn't necessarily listen to that the way other browsers do (adding the class didn't fully prevent scrolling), so landing on a complete solution required more doing. I tried the popular body-scroll-lock library but it didn't work.

It took a bit of playing and a bit of research, but adding an overlayOpen class to the body and preventing default on document.ontouchmove proved to be the winning combination that made iOS Safari behave the way I wanted.

const isSafari = navigator.userAgent.indexOf("Safari") !== -1;
const isIphone = navigator.userAgent.indexOf("iPhone") !== -1;
const isMobileIosSafari = isSafari && isIphone;
const handleTouchMoveOnIosSafari = e => {
  e.preventDefault();
};

function SearchSuggestions({
  isSearchOpen,
  ...
}) {
  const breakpoint = useBreakpoint();

  useEffect(() => {
    const body = document.getElementsByTagName('body')[0];

    if (isSearchOpen && breakpoint !== BREAKPOINTS.DESKTOP) {
      body.className = 'overlayOpen';

      if (isMobileIosSafari) {
        document.ontouchmove = handleTouchMoveOnIosSafari;
      }
    } else {
      body.className = '';

      if (isMobileIosSafari) {
        document.ontouchmove = e => true;
      }
    }
  }, [isSearchOpen]);

  return ( ... );
};
A simplified version of the scroll lock code in the SearchSuggestions component.
body.overlayOpen {
  overflow-y: hidden;
  height: 100vh;
}
The overlayOpen CSS that works to prevent the body from scrolling when the search pane is up.

My initial goal was to allow the search suggestions to be scrollable within the pane, but this proved problematic to enable while still disabling all other forms of scroll, so I ended up limiting the suggestion list to six items and preventing scrolling entirely when the search pane is up.

Success!

A principle I believe in strongly, especially for hobby projects, is to build first and optimize later. Get an idea out in the universe first, test it against the real world, see how it does, tweak it here and there to solidify the experience, and then worry about performance and scale. Keeping things simple is often key to maintaining motivation and making progress (and delivering quickly at low cost), especially as a front-end engineer who doesn't particularly enjoy dealing with the configuration headaches that come with plugging together various back-end technologies.

Does the search index get built each time a user loads up the app? Yes. Will it start to become slow once I add a whole bunch more recipes? Probably. Can I figure out a way later to build the search index ahead of time? Absolutely. But for now, it's quite snappy, and I'm fairly proud of the overall user experience design.