Components

Search Results

A grouped search result list with match highlighting, icons, trailing meta, and an empty state.

navigation

Installation

npx cano-ui add search-results

Source

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

/** Wraps case-insensitive matches of `query` in a highlight mark. */
export function SearchHighlight({
  text,
  query,
}: {
  text: string
  query: string
}) {
  const q = query.trim()
  if (!q) return <>{text}</>
  const parts = text.split(
    new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "ig")
  )
  return (
    <>
      {parts.map((part, i) =>
        part.toLowerCase() === q.toLowerCase() ? (
          <mark
            key={i}
            className="rounded-xs bg-amber-200/70 text-inherit dark:bg-amber-500/30"
          >
            {part}
          </mark>
        ) : (
          <React.Fragment key={i}>{part}</React.Fragment>
        )
      )}
    </>
  )
}

export interface SearchResultItem {
  id: string
  title: string
  description?: string
  /** Icon or thumbnail slot rendered before the text. */
  icon?: React.ReactNode
  /** Small trailing hint, e.g. a category or shortcut. */
  meta?: React.ReactNode
}

export interface SearchResultGroup {
  label: string
  items: SearchResultItem[]
}

export interface SearchResultsProps {
  /** The query used for highlighting matches. */
  query?: string
  groups: SearchResultGroup[]
  onSelect?: (item: SearchResultItem) => void
  emptyMessage?: string
  className?: string
}

export function SearchResults({
  query = "",
  groups,
  onSelect,
  emptyMessage = "No results found.",
  className,
}: SearchResultsProps) {
  const nonEmpty = groups.filter((g) => g.items.length > 0)

  return (
    <div
      data-slot="search-results"
      className={cn("w-full overflow-hidden rounded-lg border", className)}
    >
      {nonEmpty.length === 0 ? (
        <p className="px-4 py-10 text-center text-sm text-muted-foreground">
          {emptyMessage}
          {query ? (
            <>
              {" "}
              for <span className="font-medium text-foreground">“{query}”</span>
            </>
          ) : null}
        </p>
      ) : (
        <div className="max-h-96 divide-y overflow-y-auto">
          {nonEmpty.map((group) => (
            <section key={group.label} aria-label={group.label}>
              <h3 className="bg-muted/50 px-4 py-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
                {group.label}
              </h3>
              <ul>
                {group.items.map((item) => (
                  <li key={item.id}>
                    <button
                      type="button"
                      onClick={onSelect ? () => onSelect(item) : undefined}
                      className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-muted/50 focus-visible:bg-muted/50 focus-visible:outline-none"
                    >
                      {item.icon ? (
                        <span
                          aria-hidden="true"
                          className="flex size-8 shrink-0 items-center justify-center rounded-md border bg-muted/50 text-muted-foreground [&>svg]:size-4"
                        >
                          {item.icon}
                        </span>
                      ) : null}
                      <span className="flex min-w-0 flex-1 flex-col">
                        <span className="truncate text-sm font-medium">
                          <SearchHighlight text={item.title} query={query} />
                        </span>
                        {item.description ? (
                          <span className="truncate text-xs text-muted-foreground">
                            <SearchHighlight
                              text={item.description}
                              query={query}
                            />
                          </span>
                        ) : null}
                      </span>
                      {item.meta ? (
                        <span className="shrink-0 text-xs text-muted-foreground">
                          {item.meta}
                        </span>
                      ) : null}
                    </button>
                  </li>
                ))}
              </ul>
            </section>
          ))}
        </div>
      )}
    </div>
  )
}

This exact file lands in your project at components/ui/search-results.tsx. It is yours to edit.