2021
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.
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.
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.
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:
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.
I came up with a list of initial requirements to serve that vision:
There were also some requirements specific to my cocktail site that I wanted to achieve:
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.
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:
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.
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.
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:
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:
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.
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:
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.
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.
(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.
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:
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:
With this scoring system, let's see what the computed scores are for the above example:
This system seemed to work reasonably well at achieving a rough "next best" order.
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 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.
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.
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.
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.
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.