Skip to main content

Search Form

to run in your site's root directory
View template source
gem "importmap-rails" unless File.read("Gemfile").include?("importmap")
gem "perron" unless File.read("Gemfile").include?("perron")

after_bundle do
  unless File.exist?("config/importmap.rb")
    rails_command "importmap:install"
  end

  unless File.exist?("config/initializers/perron.rb")
    rails_command "perron:install"
  end

  route "resource :search, module: :perron, path: \"search.json\", only: %%w[show]"

  insert_into_file "config/initializers/perron.rb", "\n  config.search_scope = []\n", after: "Perron.configure do |config|"

  create_file "app/javascript/components/index.js", <<~JS, skip: true
  import "components/search_form"
  JS

  application_js_path = "app/javascript/application.js"
  if File.exist?(application_js_path)
    insert_into_file application_js_path, "\nimport \"components\"\n", after: /\A(?:import .+\n)*/
  else
    create_file application_js_path, <<~JS
  import "components"
  JS
  end

  unless File.exist?("config/importmap.rb")
    say "Warning: importmap.rb not found!", :yellow
    say "Please set up importmap-rails first by running:"
    say "  rails importmap:install"
    say "Or use your preferred JavaScript set up."

    return
  end

  run "bin/importmap pin minisearch"
  run "bin/importmap pin tinykeys"

  insert_into_file "config/importmap.rb", after: /\A.*pin .+\n+/m do
    "\npin_all_from \"app/javascript/components\", under: \"components\"\npin_all_from \"app/javascript/helpers\", under: \"helpers\"\n"
  end

create_file "app/javascript/components/search_form.js", <<~'TEXT', force: true
import MiniSearch from "minisearch"
import { excerpt, highlight } from "helpers/search"

export default class SearchForm extends HTMLElement {
  #clickOutside = null
  #unsubscribe = null

  connectedCallback() {
    this.debounceTimeout = null
    this.selectedIndex = -1

    this.#input.addEventListener("focus", () => {
      this.setAttribute("data-focused", "")

      this.#initialize()
    })

    this.#input.addEventListener("blur", () => {
      this.removeAttribute("data-focused")
    })

    this.#setupClickOutside()

    if (!this.#submitButton) {
      this.#input.addEventListener("input", (event) => this.#debounce(event.target.value))
    } else {
      this.#submitButton.addEventListener("click", (event) => {
        event.preventDefault()

        this.#search(this.#input.value)
      })
    }

    this.#input.addEventListener("keydown", (event) => this.#navigate(event))

    if (this.hasAttribute("data-shortcut")) this.#setupShortcut()
  }

  disconnectedCallback() {
    if (this.debounceTimeout) clearTimeout(this.debounceTimeout)
    if (this.#unsubscribe) this.#unsubscribe()

    document.removeEventListener("mousedown", this.#clickOutside)
  }

  // private

  #setupClickOutside() {
    this.#clickOutside = (event) => {
      if (!this.contains(event.target)) {
        this.#close()
      }
    }

    document.addEventListener("mousedown", this.#clickOutside)
  }

  async #setupShortcut() {
    try {
      const imported = await import("tinykeys")
      const tinykeys = imported.default || imported.tinykeys || imported

      this.#unsubscribe = tinykeys(window, {
        [this.getAttribute("data-shortcut")]: (event) => {
          event.preventDefault()

          this.#input.focus()
        }
      })
    } catch {}
  }

  #navigate(event) {
    if (event.key === "Escape") {
      event.preventDefault()

      this.#close()

      return
    }

    if (!this.hasAttribute("data-open")) return

    const results = this.#resultsContainer.querySelectorAll("li a")
    if (results.length === 0) return

    switch (event.key) {
      case "ArrowDown":
        event.preventDefault()

        this.selectedIndex = Math.min(this.selectedIndex + 1, results.length - 1)
        this.#updateSelection(results)

        break
      case "ArrowUp":
        event.preventDefault()

        this.selectedIndex = Math.max(this.selectedIndex - 1, -1)
        this.#updateSelection(results)

        break
      case "Enter":
        event.preventDefault()

        if (this.selectedIndex >= 0) results[this.selectedIndex].click()

        break
    }
  }

  #debounce(query) {
    if (this.debounceTimeout) clearTimeout(this.debounceTimeout)

    this.debounceTimeout = setTimeout(() => this.#search(query), 150)
  }

  async #initialize() {
    if (this.index) return

    this.setAttribute("aria-busy", "true")

    try {
      const response = await fetch(this.#searchEndpoint)

      this.index = await response.json()
      this.miniSearch = new MiniSearch(this.#config)

      this.miniSearch.addAll(this.index)
    } catch (error) {
      console.error("Failed to load search index:", error)
    } finally {
      this.removeAttribute("aria-busy")
    }
  }

  #search(query) {
    if (query.trim().length < 2) {
      this.#close()

      return
    }

    const results = this.miniSearch.search(query)
    this.#render(results, query)
  }

  #close() {
    this.removeAttribute("data-open")
    this.removeAttribute("data-focused")
    this.removeAttribute("data-results")
    this.removeAttribute("data-empty")
    this.removeAttribute("data-results-count")

    this.#resultsContainer.innerHTML = ""
  }

  #render(results, query = "") {
    this.selectedIndex = -1

    if (results.length === 0) {
      this.#renderEmpty()

      return
    }

    const groupBy = this.getAttribute("data-group-by")
    const html = groupBy ? this.#renderGrouped(results, groupBy, query) : results.map(result => this.#renderItem(result, query)).join("")

    this.#resultsContainer.innerHTML = html

    this.#resultsContainer.querySelectorAll("li a").forEach(link => {
      link.addEventListener("mouseenter", () => {
        this.selectedIndex = -1

        this.#resultsContainer.querySelectorAll("li[data-selected]").forEach(item => item.removeAttribute("data-selected"))
      })
    })

    this.removeAttribute("data-empty")

    this.setAttribute("data-results-count", results.length)
    this.setAttribute("data-results", "")
    this.setAttribute("data-open", "")
  }

  #renderEmpty() {
    const emptyContainer = this.querySelector("[data-slot='empty']")

    if (emptyContainer) {
      emptyContainer.innerHTML = this.#emptyMessage
      emptyContainer.removeAttribute("hidden")
    }

    this.setAttribute("data-results-count", "0")
    this.setAttribute("data-empty", "")
    this.setAttribute("data-open", "")
  }

  #renderGrouped(results, groupBy, query) {
    const grouped = results.reduce((group, result) => {
      const key = result[groupBy] || "Other"

      if (!group[key]) {
        group[key] = []
      }

      group[key].push(result)

      return group
    }, {})

    return Object.entries(grouped).map(([group, items]) => `
      <li data-slot="group">
        <h6 data-group-label>${group}</h6>

        <ul data-slot="items">
          ${items.map(result => this.#renderItem(result, query)).join("")}
        </ul>
      </li>
    `).join("")
  }

  #renderItem(result, query) {
    return `
      <li data-slot="item">
        <a href="${result.href}" data-result-link>
          <h5 data-result-title>${highlight(result.title, query)}</h5>

          <p data-result-excerpt>${highlight(excerpt(result.body, query), query)}</p>
        </a>
      </li>
    `
  }

  #updateSelection(results) {
    results.forEach((result, index) => {
      const listItem = result.closest("li")

      if (index === this.selectedIndex) {
        listItem.setAttribute("data-selected", "")
        listItem.scrollIntoView({ block: "nearest" })
      } else {
        listItem.removeAttribute("data-selected")
      }
    })
  }

  get #config() {
    const defaults = {
      fields: ["title", "headings", "body"],
      idField: "slug",
      storeFields: ["title", "body", "href", "slug"],
      searchOptions: {
        prefix: true,
        boost: { title: 30, headings: 20 },
        combineWith: "and",
        fuzzy: true
      }
    }

    const customConfig = this.getAttribute("data-config")
    if (!customConfig) return defaults

    const parsed = JSON.parse(customConfig)

    return {
      ...defaults,
      ...parsed,
      fields: parsed.fields || defaults.fields,
      storeFields: [...new Set([...defaults.storeFields, ...(parsed.storeFields || [])])],
      searchOptions: {
        ...defaults.searchOptions,
        ...parsed.searchOptions,
        boost: {
          ...defaults.searchOptions.boost,
          ...parsed.searchOptions?.boost
        }
      }
    }
  }

  get #input() {
    return this.querySelector("input[type='search']")
  }

  get #submitButton() {
    return this.querySelector("button[type='submit']")
  }

  get #resultsContainer() {
    return this.querySelector("[data-slot='results']")
  }

  get #searchEndpoint() {
    return this.getAttribute("data-endpoint")
  }

  get #emptyMessage() {
    return this.getAttribute("data-empty") || "No results found"
  }
}

customElements.define("search-form", SearchForm)
TEXT

create_file "app/javascript/helpers/search.js", <<~'TEXT', force: true
export function excerpt(text, query, maxLength = 150) {
  const terms = query.trim().split(/\s+/).filter(Boolean)
  if (!terms.length) return truncate(text, maxLength)

  const pattern = terms.map(escapeRegex).join("|")
  const match = text.match(new RegExp(pattern, "i"))
  if (!match) return truncate(text, maxLength)

  const halfWindow = maxLength / 2
  const start = Math.max(0, match.index - halfWindow)
  const end = Math.min(text.length, start + maxLength)
  const snippet = text.slice(start, end)

  return `${start > 0 ? "…" : ""}${snippet}${end < text.length ? "…" : ""}`
}

export function highlight(text, query) {
  const terms = query.trim().split(/\s+/).filter(Boolean)
  if (!terms.length) return text

  const pattern = terms.map(escapeRegex).join("|")

  return text.replace(new RegExp(`(${pattern})`, "gi"), "<mark>$1</mark>")
}

function truncate(text, length) {
  return text.length > length ? `${text.slice(0, length)}…` : text
}

function escapeRegex(value) {
  return value.replace(/[.*+?^${}()|[\]]\]/g, "\\$&")
}

TEXT
end

This web component provides instant full-text search powered by MiniSearch. It features fuzzy matching, field boosting, grouped results and keyboard navigation. This template configures most for you, but there are a few manual steps to go through.

Features

  • Instant search with 150ms debounce
  • Fuzzy matching handles typos automatically
  • Prefix search finds “doc” when typing “documentation”
  • Keyboard navigation with Arrow keys, Enter, and Escape
  • ARIA attributes for accessibility (aria-busy)
  • Grouped results by collection, category or any field
  • Highlighted matches in titles and excerpts

Configure which collections are indexed in your initializer:

# config/initializers/perron.rb
Perron.configure do |config|
  config.search_scope = %w[posts pages]
end

This includes all posts and pages in the search index.

Add fields to search per content type using search_fields:

class Content::Post < Perron::Resource
  search_fields :description, :collection_name
end

These fields are added to the default search fields: title, headings, and body.

Usage

<search-form data-endpoint="<%= search_path(trailing_slash: false) %>">
  <input type="search" placeholder="Search…" />

  <ul data-slot="results"></ul>
</search-form>

With empty state

<search-form
  data-endpoint="<%= search_path(trailing_slash: false) %>"
  data-empty="No results"
>
  <input type="search" placeholder="Search…" />

  <ul data-slot="results"></ul>

  <div data-slot="empty"></div>
</search-form>

With grouped results (you can group by any defined key):

<search-form
  data-endpoint="<%= search_path(trailing_slash: false) %>"
  data-group-by="collection_name"
>
  <input type="search" placeholder="Search…" />

  <ul data-slot="results"></ul>
</search-form>

With keyboard shortcut

<search-form
  data-endpoint="<%= search_path(trailing_slash: false) %>"
  data-shortcut="$mod+k"
>
  <input type="search" placeholder="Search…" />

  <ul data-slot="results"></ul>
</search-form>

Configuration

Attributes

Attribute Description
data-endpoint URL to the search index JSON endpoint
data-config JSON string with MiniSearch configuration
data-empty Message to display when no results found
data-group-by Field name to group results by
data-shortcut Keyboard shortcut to focus the input

Data Config

Pass a JSON configuration object to customize search behavior:

<search-form
  data-endpoint="<%= search_path(trailing_slash: false) %>"
  data-config='{
    "fields": ["title", "headings", "body", "description"],
    "storeFields": ["title", "body", "href", "slug", "category"],
    "searchOptions": {
      "boost": {"title": 30, "category": 25, "description": 20},
      "prefix": true,
      "fuzzy": 0.2
    }
  }'
>

Fields (required)

Array of field names to search. Each field gets weighted equally by default.

Store Fields (required)

Array of field names to include in search results for rendering. Must include href for links to work.

Search Options

  • boost - Increase weight for specific fields (higher = more relevant)
  • prefix - Match partial words at the start (default: true)
  • fuzzy - Allow typos (0-1, where 1 is most lenient; default: 0.2)
  • combineWith - How to combine terms ("AND" or "OR"; default: "AND")

Styling

The component uses semantic data attributes for styling:

Selector Description
[data-slot="results"] Results container
[data-slot="empty"] Empty state container
[data-group-label] Group header text
[data-result-link] Individual result link
[data-result-title] Result title
[data-result-excerpt] Result excerpt
[data-selected] Currently selected item

State Attributes

Attribute When Set
data-open Results dropdown is visible
data-focused Input has focus
data-results Results are displayed
data-empty No results found
data-busy Loading search index

Example CSS

search-form {
  position: relative;

  [data-slot="results"],
  [data-slot="empty"] {
    position: absolute;
    top: 100%;
    width: 100%;
    background: white;
    border: 1px solid #e2e8f0;
    border-radius: 0.375rem;
    box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);
  }

  [data-slot="results"] {
    display: grid;
    gap: 0.5rem;
    max-height: 24rem;
    overflow-y: auto;

    &:empty { display: none; }
  }

  [data-result-link] {
    display: block;
    padding: 0.5rem;
    text-decoration: none;

    &:hover,
    [data-selected] & {
      background: #f1f5f9;
    }
  }

  [data-result-title] {
    font-weight: 500;
    color: #0f172a;
  }

  [data-result-excerpt] {
    font-size: 0.875rem;
    color: #64748b;
  }

  mark {
    background: linear-gradient(to bottom right, #fecaca, #fed7aa);
    border-radius: 0.125rem 0.5rem 0.5rem 0.125rem;
  }
}