Live Search

Search input that queries server as you type (debounced). Results update via Turbo Frame - proving Rails can do what React does, but simpler.

Key Features

  • Debounced Input - Configurable delay prevents excessive server requests (default 300ms)
  • Turbo Frame Integration - Seamless result updates without page reload
  • Min Length - Configure minimum characters before search triggers
  • Clear Button - Optional X button to reset search and show all results
  • Loading Indicator - Optional spinner shows during request
  • Search Button - Optional attached submit button for explicit search
  • Keyboard Shortcut - Global ⌘K / Ctrl+K shortcut to focus search
  • Modal Mode - Command palette / spotlight search UX with ⌘K trigger
  • Voice Search - Microphone button using Web Speech API
  • Scope Dropdown - Filter by category before searching
  • Recent Searches - LocalStorage-backed history dropdown
  • Empty State Slot - Custom "no results" content via slot
  • 3 Sizes - sm, base, lg matching Input component
  • 3 Shapes - square, rounded, pill for different visual styles
  • Search Icon - Built-in search icon on the left
  • Custom Parameter - Change query param from q to any name
  • GET Form - Bookmarkable/shareable search URLs
  • Full Dark Mode - Beautiful in light and dark themes
  • Keyboard Accessible - Tab navigation, native search input features

Basic Usage

The live search component submits a GET form to your URL and updates results in a Turbo Frame. This is a real working example - try searching for posts!

Basic Usage
<%= rui_live_search url: search_posts_path,
      turbo_frame: "search_results",
      placeholder: "Search posts..." %>

<turbo-frame id="search_results">
  <%= render @posts %>
</turbo-frame>

How it works: When you type, the Stimulus controller debounces input (300ms default), then submits a GET form with data-turbo-frame. Turbo intercepts the response and only updates the matching <turbo-frame> element.

Sizes

Three sizes available, matching the Input component for visual consistency.

Small (sm) - Compact layouts, inline search

Base (default) - Most use cases

Large (lg) - Hero sections, prominent search

Sizes
<%= rui_live_search url: search_path, turbo_frame: "results", size: :sm %>

<%= rui_live_search url: search_path, turbo_frame: "results", size: :base %>

<%= rui_live_search url: search_path, turbo_frame: "results", size: :lg %>
Size Height Use Case
:sm h-8 (32px) Compact layouts, toolbars, inline search
:base h-10 (40px) Default size, most use cases
:lg h-11 (44px) Hero sections, prominent search

Shapes

Three shape variants for different visual styles.

Square - Sharp corners

Rounded (default) - Subtle rounding

Pill - Fully rounded ends

Shapes
<%= rui_live_search url: search_path, turbo_frame: "results", shape: :square %>

<%= rui_live_search url: search_path, turbo_frame: "results", shape: :rounded %>

<%= rui_live_search url: search_path, turbo_frame: "results", shape: :pill %>

Shapes with Search Button

When using a search button, the shape applies to both input and button with coordinated border radius.

Square with button

Rounded with button

Pill with button

Shapes with Button
<%= rui_live_search url: search_path, turbo_frame: "results", shape: :square, search_button: true %>
<%= rui_live_search url: search_path, turbo_frame: "results", shape: :rounded, search_button: true %>
<%= rui_live_search url: search_path, turbo_frame: "results", shape: :pill, search_button: true %>
Shape Border Radius Use Case
:square rounded-none Forms, tables, structured layouts
:rounded rounded-lg Default, most use cases
:pill rounded-full Hero sections, search bars, modern UI

Clear Button

Enable a clear button that appears when the input has text. Clicking it clears the search and shows all results.

Clear Button
<%= rui_live_search url: search_path,
      turbo_frame: "results",
      placeholder: "Search...",
      clear_button: true %>

Behavior: The clear button is hidden when the input is empty. When clicked, it clears the input, submits an empty search (showing all results), and focuses the input.

Loading Indicator

Show a spinner while the search request is in progress. Provides visual feedback that the search is happening.

Loading Indicator
<%= rui_live_search url: search_path,
      turbo_frame: "results",
      placeholder: "Search...",
      loading_indicator: true %>

Combined: Clear + Loading

Use both features together for the best user experience.

Clear + Loading
<%= rui_live_search url: search_path,
      turbo_frame: "results",
      placeholder: "Search...",
      clear_button: true,
      loading_indicator: true %>

Search Button

Add an optional submit button attached to the search input. Useful when you want users to explicitly trigger search, or for forms that combine instant search with a manual submit option.

Search Button
<%= rui_live_search url: search_path,
      turbo_frame: "results",
      placeholder: "Search...",
      search_button: true %>

Custom Button Text

Customize the button text to match your UI.

Custom Button Text
<%= rui_live_search url: products_path,
      turbo_frame: "results",
      placeholder: "Find products...",
      search_button: true,
      search_button_text: "Find" %>

<%= rui_live_search url: users_path,
      turbo_frame: "results",
      placeholder: "Search users...",
      search_button: true,
      search_button_text: "Go" %>

All Sizes

The search button scales with the input size.

Small (sm)

Base (default)

Large (lg)

Sizes with Button
<%= rui_live_search url: search_path, turbo_frame: "results", size: :sm, search_button: true %>
<%= rui_live_search url: search_path, turbo_frame: "results", size: :base, search_button: true %>
<%= rui_live_search url: search_path, turbo_frame: "results", size: :lg, search_button: true %>

Full Featured

Combine search button with clear button and loading indicator.

Full Featured with Button
<%= rui_live_search url: search_path,
      turbo_frame: "results",
      placeholder: "Search everything...",
      search_button: true,
      clear_button: true,
      loading_indicator: true %>

Behavior: Search still triggers on typing (debounced). The button provides an additional way to trigger search immediately, and is useful for accessibility and users who prefer explicit actions.

Keyboard Shortcut

Enable a global keyboard shortcut to focus the search input from anywhere on the page. Shows a visual hint badge inside the input.

Keyboard Shortcut
<%= rui_live_search url: search_path,
      turbo_frame: "results",
      shortcut: "k" %>

<%= rui_live_search url: search_path,
      turbo_frame: "results",
      shortcut: "k",
      shortcut_hint: false %>

How it works: The shortcut listens for ⌘/Ctrl + [key] globally. The hint badge shows the appropriate modifier based on the user's OS (⌘ for Mac, Ctrl for Windows/Linux). The badge hides when the input has focus or contains text.

Scope Dropdown

Add a dropdown filter before the search input to let users select a search scope or category.

Scope Dropdown
<%= rui_live_search url: search_path,
      turbo_frame: "results",
      scope: [
        { value: "all", label: "All" },
        { value: "posts", label: "Posts" },
        { value: "users", label: "Users" },
        { value: "comments", label: "Comments" }
      ],
      scope_default: "all",
      scope_param: :category %>
Controller Setup
def index
  @results = case params[:category]
             when "posts" then Post.search(params[:q])
             when "users" then User.search(params[:q])
             when "comments" then Comment.search(params[:q])
             else search_all(params[:q])
             end
end

Behavior: Changing the scope dropdown immediately triggers a new search with the current query. The scope value is sent as a query parameter (default: scope, customizable via scope_param).

Recent Searches

Enable a dropdown showing the user's recent search history, stored in LocalStorage. Shows when focusing an empty input.

Search for something, then focus the empty input to see recent searches:

Recent Searches
<%= rui_live_search url: search_path,
      turbo_frame: "results",
      recent_searches: true,
      recent_searches_limit: 10,
      recent_searches_key: "my_app_searches" %>
Parameter Default Description
recent_searches false Enable the feature
recent_searches_limit 5 Max searches to store
recent_searches_key "rui_recent_searches" LocalStorage key

Behavior: Searches are saved to LocalStorage when submitted. The dropdown appears when focusing an empty input. Click a recent search to fill the input and trigger search. Use different recent_searches_key values for multiple search boxes with separate histories.

Empty State Slot

Use a slot to render custom content when no results are found. The visibility is controlled by your server response or JavaScript.

Empty State Slot
<%= rui_live_search url: search_path, turbo_frame: "results" do |search| %>
  <% search.with_empty_state do %>
    <div class="text-center py-8">
      <p class="text-zinc-500">No results found</p>
      <p class="text-sm text-zinc-400">Try a different search term</p>
    </div>
  <% end %>
<% end %>

<turbo-frame id="results">
  <% if @results.any? %>
    <%= render @results %>
  <% else %>
  <% end %>
</turbo-frame>

Note: The empty state is hidden by default (has hidden class). You control visibility via your Turbo Frame response or JavaScript by toggling the hidden class on the element with data-live-search-target="emptyState".

Debounce & Min Length

Fine-tune the search behavior with debounce delay and minimum character requirements.

Debounce

Control how long to wait after the user stops typing before triggering the search. Default is 300ms.

Debounce Options
<%= rui_live_search url: search_path, turbo_frame: "results" %>

<%= rui_live_search url: search_path, turbo_frame: "results", debounce: 150 %>

<%= rui_live_search url: search_path, turbo_frame: "results", debounce: 500 %>

Minimum Length

Require a minimum number of characters before search triggers. Useful for preventing overly broad searches.

This search requires at least 3 characters:

Type at least 3 characters to search...

Minimum Length
<%= rui_live_search url: search_path,
      turbo_frame: "results",
      min_length: 3 %>

<%= rui_live_search url: search_path,
      turbo_frame: "results",
      debounce: 500,
      min_length: 2 %>

Custom Parameter

Change the query parameter name from the default q to match your controller expectations.

Custom Parameter Name
<%= rui_live_search url: search_path, turbo_frame: "results" %>

<%= rui_live_search url: search_path,
      turbo_frame: "results",
      param_name: :search %>

<%= rui_live_search url: search_path,
      turbo_frame: "results",
      param_name: :query %>
Matching Controller
# Controller expecting :search param
def index
  @posts = Post.all

  if params[:search].present?
    @posts = @posts.where("title ILIKE ?", "%#{params[:search]}%")
  end
end

Autofocus

Automatically focus the search input when the page loads. Great for dedicated search pages.

Autofocus
<%= rui_live_search url: search_path,
      turbo_frame: "results",
      autofocus: true %>

Custom Styling

Add custom classes to the wrapper element for layout control.

Centered with max-width:

Custom Styling
<%= rui_live_search url: search_path,
      turbo_frame: "results",
      class: "max-w-md mx-auto" %>

<div class="max-w-2xl">
  <%= rui_live_search url: search_path,
        turbo_frame: "results",
        class: "w-full" %>
</div>

Controller Setup

Your search endpoint should respond to GET requests. Turbo handles the response automatically.

Controller
class PostsController < ApplicationController
  def index
    @posts = Post.order(created_at: :desc)

    # Filter by search query if present
    if params[:q].present?
      search_term = "%#{params[:q]}%"
      @posts = @posts.where("title ILIKE ? OR body ILIKE ?", search_term, search_term)
    end

    # Turbo Frame requests automatically skip layout
    # No special handling needed!
  end
end
View (index.html.erb)
<%= rui_live_search url: posts_path,
      turbo_frame: "posts_list",
      placeholder: "Search posts...",
      clear_button: true,
      loading_indicator: true %>

<turbo-frame id="posts_list">
  <div class="space-y-4">
    <% @posts.each do |post| %>
      <%= render post %>
    <% end %>
  </div>
</turbo-frame>

Turbo Frame Pattern

Understanding how the component works with Turbo Frames.

  1. User types in the search input
  2. Stimulus controller debounces the input (waits for typing to stop)
  3. Form submits as GET request with data-turbo-frame="your_frame_id"
  4. Turbo intercepts the response
  5. Server renders full page (including the turbo-frame)
  6. Turbo extracts only the matching <turbo-frame> content
  7. Content replaces the existing frame - no full page reload!
Complete Pattern
<form action="/posts" method="get" data-turbo-frame="posts_list">
  <input type="search" name="q" ...>
</form>

<turbo-frame id="posts_list">
  <%= render @posts %>
</turbo-frame>

<turbo-frame id="posts_list">
  <%= render @posts %>
</turbo-frame>

No JavaScript required! The Turbo Frame pattern handles everything. Your controller just renders the page normally - Turbo handles the partial update.

Real-World Examples

Blog Search

A complete blog search with all features enabled.

Blog Search
<div class="max-w-xl mx-auto">
  <%= rui_live_search url: posts_path,
        turbo_frame: "blog_results",
        placeholder: "Search articles...",
        size: :lg,
        clear_button: true,
        loading_indicator: true,
        debounce: 300,
        min_length: 2 %>
</div>

<turbo-frame id="blog_results">
  <div class="divide-y">
    <% @posts.each do |post| %>
      <%= render post %>
    <% end %>
  </div>
</turbo-frame>

Command Palette Style

Compact search for toolbars or command palettes.

Command Palette
<%= rui_live_search url: commands_path,
      turbo_frame: "command_results",
      placeholder: "Type a command...",
      size: :sm,
      autofocus: true,
      debounce: 100,
      min_length: 1 %>

Table Filter

Filter a data table as you type.

Table Filter
<div class="flex items-center gap-4 mb-4">
  <div class="flex-1">
    <%= rui_live_search url: users_path,
          turbo_frame: "users_table",
          placeholder: "Filter users...",
          clear_button: true %>
  </div>
  <%= rui_button "Add User", color: :primary %>
</div>

<turbo-frame id="users_table">
  <%= rui_table do |table| %>
    <% table.with_column(key: :name, label: "Name") %>
    <% table.with_column(key: :email, label: "Email") %>
  <% end %>
</turbo-frame>

JavaScript API

The LiveSearch component uses Stimulus controller composition for modularity. Features are split across focused controllers that communicate via events.

Controller Composition

LiveSearch composes multiple specialized controllers based on enabled features:

  • live-search — Core search, debounce, form submission, modal
  • css-highlight-nav — Keyboard navigation for results (modal mode)
  • recent-searches — LocalStorage recent searches dropdown
  • voice-search — Web Speech API voice input

live-search Controller

Core controller — always present:

Target Description
form The form element that wraps the search input
input The search input element
clearButton Optional clear button (when clear_button: true)
loadingIndicator Optional loading spinner (when loading_indicator: true)
searchButton Optional submit button (when search_button: true)
shortcutHint Keyboard shortcut badge (inline mode)
shortcutText Text element inside shortcut hint
emptyState Empty state slot container
dialog Modal dialog element (when modal: true)
modalTrigger Modal trigger button (when modal: true)
resultsContainer Results area in modal mode
modalEmptyState Empty state shown in modal before typing

Stimulus Values

Configuration values for live-search controller:

Value Type Default Description
debounce Number 300 Debounce delay in milliseconds
minLength Number 1 Minimum characters before search triggers
disabled Boolean false Whether search is disabled
shortcut String "" Global keyboard shortcut key (modal mode)
modal Boolean false Enable modal mode

Composed Controllers

These controllers are automatically added when features are enabled:

recent-searches

Added when recent_searches: true

key value LocalStorage key (default: "rui_recent_searches")
limit value Max searches to store (default: 5)

voice-search

Added when voice_search: true

lang value Speech recognition language (auto-detected)

css-highlight-nav

Added in modal mode for keyboard navigation

selector value CSS selector for navigable items
wrap value Wrap around at ends (default: true)

Custom Events

Events dispatched by the composed controllers:

Event Controller When
live-search:before-search live-search Before form submits (cancelable)
live-search:after-search live-search After form submitted
live-search:cleared live-search After input cleared
live-search:modal-opened live-search Modal dialog opened
live-search:modal-closed live-search Modal dialog closed
recent-searches:selected recent-searches Recent search item clicked
recent-searches:cleared recent-searches Recent searches history cleared
voice-search:start voice-search Voice recognition started
voice-search:result voice-search Speech recognized (detail: { transcript })
voice-search:end voice-search Voice recognition ended
css-highlight-nav:changed css-highlight-nav Highlight moved to new item
css-highlight-nav:selected css-highlight-nav Highlighted item selected (Enter key)
Listening to Events
// Track search analytics
document.addEventListener("live-search:after-search", (event) => {
  analytics.track("search", { query: event.detail.query })
})

// Cancel certain searches
document.addEventListener("live-search:before-search", (event) => {
  if (event.detail.query.length > 100) {
    event.preventDefault() // Too long, cancel
  }
})

// Track voice search usage
document.addEventListener("voice-search:result", (event) => {
  analytics.track("voice_search", { transcript: event.detail.transcript })
})

// React to recent search selection
document.addEventListener("recent-searches:selected", (event) => {
  console.log("Selected recent:", event.detail.query)
})

Accessibility

The LiveSearch component follows WAI-ARIA best practices for accessible search widgets.

ARIA Attributes

  • type="search" for native search input behavior
  • aria-label="Clear search" on clear button for screen readers
  • aria-hidden="true" on loading indicator (decorative)

Keyboard Navigation

  • Arrow Up/Down navigate through results (modal) or recent searches
  • Enter selects the highlighted result or recent search
  • Escape clears input or closes modal
  • ⌘K / Ctrl+K opens modal search (when shortcut enabled)
  • Focus stays on input while navigating - keep typing while browsing

URL & Navigation

  • GET form produces bookmarkable/shareable URLs
  • Browser back button works correctly with search history
  • After clearing, focus returns to input for continued typing

API Reference

Slots

Content slots for customizing component parts

Slot Description
empty_state Custom content for "no results" state. Use with_empty_state { } block.
trigger Custom trigger button content for modal mode. Use with_trigger { } block.

Related Components