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
qto 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!
Search posts from the database:
<%= 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
<%= 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
<%= 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
<%= 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 |
Loading Indicator
Show a spinner while the search request is in progress. Provides visual feedback that the search is happening.
Type to see the loading spinner (appears briefly during request):
<%= 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.
<%= rui_live_search url: search_path,
turbo_frame: "results",
placeholder: "Search...",
clear_button: true,
loading_indicator: true %>
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.
Press ⌘K (Mac) or Ctrl+K (Windows) to focus:
<%= 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.
Modal Mode
Enable modal mode to show search in a centered dialog, triggered by button click or keyboard shortcut. This creates a command palette / spotlight search UX similar to Algolia, Stripe, or Vercel.
Click the button or press ⌘M to open modal search:
<%= rui_live_search url: search_path,
turbo_frame: "results",
modal: true,
shortcut: "m",
placeholder: "Search posts..." %>
Custom Trigger Button
Use the trigger slot to customize the trigger button content.
<%= rui_live_search url: search_path,
turbo_frame: "results",
modal: true,
shortcut: "j" do |search| %>
<% search.with_trigger do %>
<div class="flex items-center gap-2">
<%= rui_icon :search, size: :sm %>
<span class="text-zinc-500">Search everything...</span>
<kbd class="text-xs font-mono bg-zinc-100 px-1.5 py-0.5 rounded">⌘J</kbd>
</div>
<% end %>
<% end %>
All Features in Modal
Modal mode supports all LiveSearch features - voice search, scope dropdown, recent searches, loading indicator, and empty state.
<%= rui_live_search url: search_path,
turbo_frame: "results",
modal: true,
shortcut: "k",
placeholder: "Search...",
voice_search: true,
scope: [
{ value: "all", label: "All" },
{ value: "posts", label: "Posts" },
{ value: "users", label: "Users" }
],
recent_searches: true,
loading_indicator: true do |search| %>
<% search.with_empty_state do %>
<p class="text-zinc-500 text-center py-8">No results found</p>
<% end %>
<% end %>
Behavior: A trigger button appears showing "Search..." and the shortcut hint. Click the button or press ⌘K (Mac) / Ctrl+K (Windows) to open the modal. Search input is focused automatically. Results appear inside the modal. Press Escape or click outside to close.
Accessibility: Dialog has aria-modal="true", trigger has aria-haspopup="dialog", results region has aria-live="polite", and focus is trapped within modal while open.
Voice Search
Enable voice-to-text search using the Web Speech API. A microphone button appears that starts voice recognition when clicked.
Click the microphone and speak to search:
<%= rui_live_search url: search_path,
turbo_frame: "results",
voice_search: true %>
Browser Support: Voice search uses the Web Speech API (SpeechRecognition). Supported in Chrome, Edge, Safari. Firefox requires enabling media.webspeech.recognition.enable flag. The button remains visible but logs a warning on unsupported browsers.
Visual Feedback: While listening, the microphone button turns red and pulses. Speech is automatically transcribed to the input and triggers a search when recognition ends.
Scope Dropdown
Add a dropdown filter before the search input to let users select a search scope or category.
Select a scope then search:
<%= 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 %>
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:
<%= 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.
<%= 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.
<%= 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...
<%= 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.
<%= 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 %>
# 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.
<%= 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:
<%= 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.
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
<%= 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.
- User types in the search input
- Stimulus controller debounces the input (waits for typing to stop)
- Form submits as GET request with
data-turbo-frame="your_frame_id" - Turbo intercepts the response
- Server renders full page (including the turbo-frame)
- Turbo extracts only the matching
<turbo-frame>content - Content replaces the existing frame - no full page reload!
<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.
<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.
<%= 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.
<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, modalcss-highlight-nav— Keyboard navigation for results (modal mode)recent-searches— LocalStorage recent searches dropdownvoice-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) |
// 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
rui_live_search(**options)
Live search input with debounced queries and Turbo Frame updates
| Parameter | Type | Default | Description |
|---|---|---|---|
| url* | String | — | Search endpoint URL |
| turbo_frame* | String | — | Target Turbo Frame ID for results |
| placeholder | String | — | Placeholder text for the input |
| debounce | Integer |
300
|
Debounce delay in milliseconds |
| min_length | Integer |
1
|
Minimum characters before search triggers |
| param_name | Symbol |
:q
|
Query parameter name |
| size | Symbol |
:base
|
Size variant (:sm, :base, :lg) |
| shape | Symbol |
:rounded
|
Shape variant (:square, :rounded, :pill) |
| clear_button | Boolean |
false
|
Show clear button when input has value |
| loading_indicator | Boolean |
false
|
Show loading spinner during request |
| autofocus | Boolean |
false
|
Focus input on page load |
| disabled | Boolean |
false
|
Disable the search input |
| search_button | Boolean |
false
|
Show search submit button |
| search_button_text | String |
"Search"
|
Text for search button |
| shortcut | String | — | Global keyboard shortcut key (e.g., 'k' for ⌘K) |
| shortcut_hint | Boolean |
true when shortcut set
|
Show shortcut hint badge |
| voice_search | Boolean |
false
|
Show voice search microphone button |
| scope | Array |
[]
|
Array of {value:, label:} hashes for scope dropdown |
| scope_param | Symbol |
:scope
|
Query parameter name for scope |
| scope_default | String | — | Default selected scope value |
| recent_searches | Boolean |
false
|
Enable recent searches dropdown |
| recent_searches_limit | Integer |
5
|
Max recent searches to store |
| recent_searches_key | String |
"rui_recent_searches"
|
LocalStorage key |
| modal | Boolean |
false
|
Enable modal mode - search opens in dialog |
| modal_size | Symbol |
:lg
|
Modal dialog size (:sm, :md, :lg, :xl) |
| class | String | — | Custom CSS classes |
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. |