Alexandre Ruban

April 30, 2024

How to build a Cmd+k search modal with Hotwire

Many applications feature cmd+k search modals. This includes documentation websites like Bootstrap or Tailwind CSS, as well as applications I use every day, such as GitHub and Basecamp.

cmd+k search.png


I recently had to build one, and it was so easy thanks to Hotwire that I wanted to share how I built it with you.

Imagine we have a documentation website similar to Bootstrap with articles. When users press cmd+k, we want to open a modal that allows them to search for an article by keyword. This modal should be accessible from any page on the website.

In order to be able to test what we'll build in this article, we'll only need a new Rails application with an Article model and a title attribute:

rails new demo-app
cd demo-app
rails g scaffold article title
rails db:migrate

First, let’s create the search modal and add it to the application layout. To enable opening the modal with the cmd+k keyboard shortcut, we can use Stimulus keyboard events filters:

<!-- app/views/layouts/application.html.erb -->

<!DOCTYPE html>
<html>
  <head>
    <!-- ... -->
  </head>
  <body>
    <!-- ... -->

    <dialog data-controller="dialog" data-action="keydown.meta+k@window->dialog#open">
      <form method="dialog">
        <button>Close</button>
      </form>
      
      <!-- Search input and results will go there -->
    </dialog>
  </body>
</html>

Let's create the corresponding Stimulus controller:

// app/javascript/controllers/dialog_controller.js

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  open() {
    this.element.showModal()
  }
}

Now, pressing cmd+k should open an empty modal. Since we are using the <dialog> HTML element, pressing the escape key on the keyboard will automatically close the modal. This behavior is built into HTML!

Now, let’s consider what we want to add to our modal. We need two things:
  1. A search input within a form that gets automatically submitted after a few milliseconds.
  2. A results area where we can display links to articles that match the keywords from the search input.

Let’s add the search input and the results section to our modal:

<!-- app/views/layouts/application.html.erb -->

<dialog data-controller="dialog" data-action="keydown.meta+k@window->dialog#open">
  <form method="dialog">
    <button>Close</button>
  </form>
   
  <%= form_with url: search_path, method: :get, data: { controller: "autosubmit", autosubmit_delay_value: 300, turbo_frame: :search } do |f| %>
    <%= f.search_field :query, autofocus: true, data: { action: "autosubmit#submit" } %>
  <% end %>

  <%= turbo_frame_tag :search, loading: :lazy, src: search_path %>
</dialog>


There is a lot happening here in just a few lines of code, so let's break it down together.

The form is linked to the autosubmit Stimulus controller and is automatically submitted after a delay of 300 milliseconds. Each time a user types a character into the search input, they must wait another 300 milliseconds before a request is made to the server. This delay helps prevent the search form from making too many requests to the server:

// app/javascript/controllers/autosubmit_controller.js

import { Controller } from "@hotwired/stimulus"
import { debounce } from "helpers/frequency_helpers"

export default class extends Controller {
  static values = {
    delay: {
      type: Number,
      default: 0
    }
  }

  connect() {
    if (this.delayValue) {
      this.submit = debounce(this.submit.bind(this), this.delayValue)
    }
  }

  submit() {
    this.element.requestSubmit()
  }
}

To implement this delay, I used a debounce function. Since I reuse this debounce function in many places, I stored it in a helper:

// app/javascript/helpers/frequency_helpers.js

export function debounce(fn, delay = 1000) {
  let timeoutId = null

  return (...args) => {
    clearTimeout(timeoutId)
    timeoutId = setTimeout(() => fn.apply(this, args), delay)
  }
}

Note that if you are using import maps, you'll need to configure the helpers path:

# config/importmap.rb

# All the previous code...
# Don't forget to restart your Rails server as importmap.rb 
# is a configuration file
pin_all_from "app/javascript/helpers", under: "helpers"

OK, so we now have a search form that gets automatically submitted after 300 milliseconds. Let's analyze what this form does.

First, it targets the /search URL, which will be responsible for returning the HTML with the list of article links that match our search query. We'll create the SearchesController#show later in the article.

Second, it targets the turbo-frame with an ID of search. This means that if the /search endpoint returns a response with a turbo-frame of ID search, Turbo will replace the original search turbo-frame with the one from the response, leaving the rest of the page untouched.

Last but not least, we create an empty search turbo-frame that will be updated with the search results. This turbo-frame has an src attribute targeting the /search url and a loading attribute of lazy. This means that as soon as the turbo-frame becomes visible to the user — so as soon as the user presses cmd+k — a request will be fired to the /search endpoint, showing the user some initial suggestions even before they type anything into the search input.

Wow, there's a ton of information in such a small code snippet! That's what I love about Hotwire: it enables us to create dynamic user interfaces with very little JavaScript and in a very concise way.

Let's now finalize our search modal by adding the backend part of the feature. First, we'll need a /search route:

# config/routes.rb

Rails.application.routes.draw do
  # All the other routes 
  resource :search, only: :show
end

Then, we need the associated SearchesController. In this example, we are going to perform a very basic search, as this is not the focus of this article. In a real-world scenario, we might want to use a more complex text search tool, such as Elasticsearch, for instance. The following example assumes that we have an Article model with a title attribute:

# app/controllers/searches_controller.rb

class SearchesController < ApplicationController
  def show
    if params[:query].present?
      @articles = Article.where("title like ?", "%#{params[:query]}%").limit(5)
    else
      @articles = Article.all.limit(5)
    end
  end
end

Finally, let’s render a response that wraps the search results in the search turbo-frame. The following view assumes you have an ArticlesController with a show action to be able to link to it:

<%# app/views/searches/show.html.erb %>

<%= turbo_frame_tag :search do %>
  <% if @articles.any? %>
    <ul>
      <% @articles.each do |article| %>
        <li>
          <%= link_to article.title, article, data: { turbo_frame: "_top" } %>
        </li>
      <% end %>
    </ul>
  <% else %>
    <p>No results</p>
  <% end %>
<% end %>

If we create a few articles in the database and press cmd+k on any page of our website, we should see the search modal appear and immediately display five articles, thanks to the lazy-loaded search turbo-frame. If we start typing in the search input, we should begin to see only articles with titles that match our query!

I'm constantly amazed by what we can achieve on the frontend with Hotwire and just a few lines of code. I hope you enjoyed reading this article and look forward to seeing you next time!

About Alexandre Ruban

Ruby on Rails developer.
Creator of the turbo-rails tutorial and the rebuilding turbo-rails tutorial.