Creating a slide-out search bar with Rails 7 and Stimulus

After fiddling with a few header layouts, I built one that I thought was simple and effective for both mobile and desk screen sizes. The header includes a search bar and navigation menu. To prevent needlessly occupying important screen space, the search bar slides out of view when you scroll down the page, and slides back in if you scroll up.

Remark is built on Rails 7, which comes with Stimulus out-of-the-box. TailwindCSS is used for styling, but this solution can be adapted to work with whatever styling method you choose.

We're going to use arbitrary values for some of the Tailwind classes we apply, so read up on that if you're unfamiliar with Tailwind—it will help understand some of the peculiar classes applied to our elements.

Note: We'll stub out code in some spots to make the incremental changes a little more clear. The full code is available in the last section.

The header

We'll start with a pretty simple sticky header that includes the search bar (which we want to slide out of view when not in use), and some nav items that we'll just stub out.

<!-- app/views/application/_header.html.erb -->
<div class="sticky top-0">
  <div class="h-[4rem]">
    <%= render "application/search" %>
  </div>
  <nav>
    <!-- nav items -->
  </nav>
</div>

Notice the h-[4rem] class on search container. We want to specify the height so we can slide the entire search bar just out of view by applying top-[-4rem] to the header.

The controller

Let's create the Stimulus controller to attach to our header element:

rails g stimulus header

This should generate a new Stimulus controller that looks something like this:

// app/javascript/controllers/header_controller.js
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="header"
export default class extends Controller {
  connect() {
  }
}

Connecting the controller

We'll tweak our header partial so that our new controller gets connected when we render it by adding the data-controller="header" attribute.

<!-- app/views/application/_header.html.erb -->
<div class="sticky top-0">
<div data-controller="header" class="sticky top-0">
  <!-- header contents -->
</div>

Responding to scroll events

Now that our controller is connected to our header, we need to respond to scroll events: when scrolling down the page, we want to tuck away the search bar. When you scroll up, the search bar should reveal itself again.

// app/javascript/controllers/header_controller.js
export default class extends Controller {
  connect() {
    this.listener = () => {
      if (window.scrollY < this.scrollY) {
        // show the search bar
      } else if (window.scrollY > this.scrollY) {
        // hide the search bar
      }
      this.scrollY = window.scrollY
    }
    window.addEventListener("scroll")
  }
  disconnect() {
    window.removeEventListener("scroll", this.listener)
  }
}

Making it work

So what do we want our controller to actually do to hide and show our search bar? Quite simply, it must add and remove CSS classes to the connected element. Since our header is a sticky element, we can use the top property to move it partially above the viewport, effectively hiding the search bar.

And to keep our code flexible and resilient to change, we'll use two important Stimulus features: values and targets.

Target

This is a gimme. All Stimulus controllers are connected to a DOM element, accessible via this.element. Additional targets can be provided, but in our case, we only need the one.

Value

Values speak for themselves. You add a value attribute to your connected DOM element, and that value can be retrieved (or changed) via the controller. The only value we really need is the class to add or remove from our header in order to hide or show it. You have to define your controller's values as follows:

// app/javascript/controllers/header_controller.js
export default class extends Controller {
  static values = {
    class: String
  }
  // connect()
  // disconnect()
  // ...
}

Note: it's a little gauche to use a protected keyword like class, but 🤷‍♂️

Now we provide the value in our header partial:

<!-- app/views/application/_header.html.erb -->
<div data-controller="header" class="sticky top-0">
<div
  data-controller="header"
  data-header-class-value="top-[-4rem]"
  class="sticky top-0"
>
  <!-- header contents -->
</div>

Note: if you're using Tailwind, you might have to whitelist your class value.

And our controller's event listener now simply needs to add the value to its connected element's class list:

this.listener = () => {
  if (window.scrollY < this.scrollY) {
    // show the search bar
    this.element.classList.remove(this.classValue)
  } else if (window.scrollY > this.scrollY) {
    // hide the search bar
    this.element.classList.add(this.classValue)
  }
  this.scrollY = window.scrollY
}

Making it smooth

Back in our header partial, we'll add a few Tailwind classes so we get a smooth sliding animation.

<!-- app/views/application/_header.html.erb -->
<div
  data-controller="header"
  data-header-class-value="top-[-4rem]"
  class="sticky top-0"
  class="sticky top-0 transition-[top] ease-in-out duration-300"
>
  <!-- header contents -->
</div>

Putting it all together

The full code for the header and controller are below:

<!-- app/views/application/_header.html.erb -->
<div
  data-controller="header"
  data-header-class-value="top-[-4rem]"
  class="sticky top-0 transition-[top] ease-in-out duration-300"
>
  <div class="h-[4rem]">
    <%= render "application/search" %>
  </div>
  <nav>
    <!-- nav items -->
  </nav>
</div>
// app/javascript/controllers/header_controller.js
export default class extends Controller {
  static values = {
    class: String
  }
  connect() {
    this.listener = () => {
      if (window.scrollY < this.scrollY) {
        this.element.classList.remove(this.classValue)
      } else if (window.scrollY > this.scrollY) {
        this.element.classList.add(this.classValue)
      }
      this.scrollY = window.scrollY
    }
    window.addEventListener("scroll", this.listener)
  }
  disconnect() {
    window.removeEventListener("scroll", this.listener)
  }
}

Afterthoughts

  • This implementation will show the search bar as soon the page is scrolled up. Personally, I don't like this, since one might accidentally scroll slightly upward when stopping an inertial scroll. You can prevent this by only showing the search bar if the window has scrolled a minimum distance above the stored position, but the scroll listener has to be updated to not store window.scrollY on each execution:

    this.scrollDeadZone = 50 // example value
    this.listener = () => {
      if (window.scrollY < this.scrollY) {
      if (window.scrollY < this.scrollY - this.scrollDeadZone) {
        this.element.classList.remove(this.classValue)
        this.scrollY = window.scrollY
      } else if (window.scrollY > this.scrollY) {
        this.element.classList.add(this.classValue)
        this.scrollY = window.scrollY
      }
      this.scrollY = window.scrollY
    }
  • Remark's implementation of this has a few other bells and whistles like enabling/disabling the sliding search bar for pages that don't need it, but the basic concept is the same.

Updates

  • Aug 29: Added code diffs for clear incremental changes, a note about code stubbing, and improved some wording.

Updated August 29, 2022 at 09:17 pm CDT