Command palettes are generally cool, at least I think so. Not sure if it's the original, but the first one I recall seeing it in use was Macintosh's search bar feature.
Alfred then became a pretty popular app as to fill the gap, and lately we're seeing more apps also implementing command palettes like Slack, Linear, GitHub and probably thousands more that I'm not aware of.
I'm proud to say that this little website of mine has also implmented one for search purposes. Mainly to help me find things in the future since I generally only remember the reference points, and not the content itself. As long as I know roughly what I want to find, search should be able to narrow the candidates for me.
Enough of the reasonings for my self satisfaction and let's take a look at how it's built.
What are we looking at
Expectations are important so I'm setting it beforehand.
This blog post is merely going to share how to build a command palette UI, but won't be providing any sort of data.
If you're using Hugo as well, there are custom outputs that can generate the site data into formats like JSON, which then can be consumed by the UI code. That's how this website is made. How the search should work is up to you so feel free to customize and tweak it to your liking.
Setup walkover
There's nothing more annoying than to ask someone to read another person's random code out of the blue. So here's a rough walk through of the setup to give you a better idea of what's going on.
Structure of the code
For the sake of my sanity, and also showcasing a working version, the code being referenced will be the one that's currently being used on this website.
There are 2 components in the search file.
- A nice search input like button that opens up the command palette
- The actual command palette itself
You can see the full version on GitHub. The footer is separated as a partial file but since it's rather irrelevant for this post, we can ignore it.
*Latest version might look different
Alpine.js integration
Since this sample is based on Alpine
, make sure to have it installed. I recommend to use the CDN
distributed version initially for simplicity.
This code includes the Focus
plugin for Alpine
and Alpine
itself. I'll explain why we want the plugin
later.
FlexSearch
The search functionality will be supported via FlexSearch. Similar to Alpine
, I recommend to use the CDN
distributed version for simplicity.
Data
When searching something, you obviously need to provide the content to be searched for. Hugo by default
generates HTML
, AMP
and RSS
pages out of the box. However, it also has a way to generate custom
data as well via custom outputs.
Since this website is using FlexSearch
which is a JavaScript library, providing data as JSON
makes it
easier to feed it into FlexSearch
.
This config declares a new output format called SearchIndex
, that's data type is JSON
and the filename
for Hugo to reference is searchindex
. Hugo will expect there to be a template file at
/layouts/_default/list.searchindex.json
which will be used to generate the JSON
data content.
In order for Hugo to actually generate the data file, make sure to add SearchIndex
to the outputs
list.
Once generated, the data can be seen at /searchindex.json
. Feel free to take a look at the JSON data of
this website to get an idea of what I'm talking about.
Here's an example of how the template will look like.
You can see more details regarding Hugo's custom outputs here.
Implementation
Now that the foundation is set, let's take a look at the implmentation itself.
Data initialization
The data being used for searching are initialized at the top DOM object of the file.
searchbar
decides if the palette can be shown or notsearchterm
is the term that's being searchedsearchresults
is a list that contains the results that matchessearchterm
index
is theDocument
object initialized fromFlexSearch
The index
object is a rather unique one compared the other rather self explanatory variables. It basically
holds and indexes the list of contents, and will be the one used during searching as well.
You should see something like the code above at the end of the file. It's basically telling FlexSearch
to,
- Which method to use for indexing – e.g.
forward
, there's a direct memory usage tradeoff here - The capacity of cached entries
- Document structure – which field to use as
ID
, which fields to store and which to use as indices
And then retrieve the generated content data from /searchindex.json
and insert each of them into the
index.
You can see more details of FlexSearch
's Document
object here – v0.7.31
.
Activating the command palette
There are a 2 ways to show the command palette.
- Clicking on the search input like button
- Type
Ctrl
k
orCmd
k
if you're on macOS or Windows
The hidden
HTML class is used as the way to hide the command palette, and the searchbar
variable is
the one determining if the hidden
class will be assigned or not.
So when the button is clicked, searchbar
is set to true
.
The other way using Ctrl
k
or Cmd
k
is done like this.
Which is basically the same as
document.addEventListener("keydown", (e) => {
e.preventDefault()
if ((e.ctrlKey && e.key === "k") || (e.metaKey && e.key === "k"))
this.searchbar = true;
})
Trapping focus
When the command palette appears, it's best to keep all interactions against the command palette until
you close it. This is where the Focus
plugin comes in.
x-trap
can be used for restraining the focus within the specified DOM object based on a JS
expression.
In this base, searchbar
will be the expression for activating x-trap
.
.inert
, .noscroll
, .noreturn
are all modifiers of x-trap
. You can learn more about them in the
documentation.
The main reason for using x-trap
here is to utilize $focus
. It makes traversing UI components easier
to implement.
Which is equivalent to something like the following
Closing the command palette
If you can open something, you should be able to close it as well. Assuming you have been following, the closing of the command palette should be rather straightforward.
Yes, it's just assigning searchbar
to false
, and the hidden
HTML class will be added back to the DOM
object, which will result in hiding it completely.
Search
That was a lot of thing to go through but we're finally at the search step. There'll be a couple of things to look into at this step.
- Executing the search
- Show the results of the search
- Show
No Results
when nothing matches the search term
Executing the search
Search is done using the index
object that was initialized at the beginning. Here's the function for
running a search.
The result of this function should return a list of objects like the following.
{
"id": "<UUID>",
"title": "some title",
"type": "blog | note | project",
"href": "link to the content",
"summary": "short summary of the content",
"content": "full text of the content"
}
With that, let's take a look at how the searchItems
function is called.
3 things are happening here.
- Add debounce so functions are not triggered on every single key stroke
- Assign the value of the
input
field tosearchterm
- Assign the results of
searchItems
function tosearchresults
#1 is generally a nice to have. If the search computation is not light, you don't want it to run on every key stroke
and debounce
will delay the execution. By default, Alpine
's debounce will delay the execution for 250 ms.
The reasoning for #2 is more apparent in the following sections so I'll be skipping the explanation for now.
What you're seeing here is a rough equivalent to this JS
code.
const debounce = (fn, timeout = 2500) => {
let timer
return (..args) => {
clearTimeout(timer)
timer = setTimeout(() => fn.apply(this, args), timeout)
}
}
document.addEventListener("keydown", (e) => {
debounce(() => {
searchterm = e.target.value // string input
searchresults = searchItems(searchterm, index)
})
})
Show the results of the search
Now that the searchterm
and searchresults
stores the values we care about, it's time to show it to the users.
A section of this code probably looks familiar. Similar to some elements shared previously, the visibility of the
ul
DOM element is based on the hidden
HTML class. How it's determined to be shown or not depends on both searchterm
and searchresults
not being empty.
The reason for also using searchterm
here is to make sure that the list UI is not shown on initial rendering where
there're no inputs.
template
is a way for Alpine
to use for modifying the DOM and replacing similar elements, and is common when used
with some kind of condition logic.
No results available
Similar to determining when the results are shown, there should also be a way to show that there is nothing found for the specified search term.
The logic is mostly similar with a slight twist, searchresults
should be an empty list. This will result in the
following UI.
Food for thought
That was a brief run through of how to implement a command palette with Alpine
and FlexSearch
. The functioning version
of the code is also accessible here on GitHub with the TailwindCSS
classes to make it look like the screenshots.
A couple things worth thinking of if you're considering going with this approach.
How data is provided
This example is based on having a static JSON
data that can be read by FlexSearch
when the page loads. This might
not be the case for you, depending on where your data is coming from.
Obviously if you have some kind of remote data source based on the kind of search infrastructure like Elasticsearch or Typesense, retrieving that data will need to be more involved.
The provided solution in this article will work a lot better for static content than dynamic content. For example, company blogs, or documentation websites where the content is generally static.
Scalability and resource consumption
However, even with just static contents, it's unclear how performant this solution can be. The cache
option for
FlexSearch
initialization can be pretty influential when it comes to resource consumption of the user's laptop.
Also, since I've not done any real load testing, it's unclear how much resources FlexSearch
might require if there's a
somewhat large data set.
Having said all that, it's very unlikely that you'll have millions of contents for company blogs or documentation websites so my rough educated guess is that this solution should work mostly fine.