Skip to content
Caseroom

Components · Data

Search results

An announced count, then ranked results with the address and the match marked in each snippet.

29% of the 2,062 sites in The UI Index have a list (604 sites).

3 results for “keys”

  1. caseroom › components › tabsTabs

    Panels behind a row of tabs. Arrow keys move between tabs, Home and End jump to the ends.

  2. caseroom › components › kbdKeyboard shortcuts

    Show a key or a shortcut, such as ⌘K for the command menu, next to the action it runs.

  3. caseroom › components › toolbarToolbar

    One Tab stop for a row of controls; the arrow keys move between them.

Both previews render the same markup; the HTML tab below is that markup without a framework.

Install

React
npx shadcn@latest add https://www.themes.dev/caseroom/r/result-list.json
Astro
npx shadcn@latest add https://www.themes.dev/caseroom/r/astro/result-list.json

Or copy the files below into your project. They need clsx, tailwind-merge, and the Caseroom theme. The HTML needs only the compiled CSS.

result-list.tsx
import { cn } from '@/lib/utils';
import { markParts, resultList as c, type Result } from './result-list.variants';

export type { Result };

export interface ResultListProps {
  query: string;
  results: Result[];
  className?: string;
}

/** Search results: an announced count, then ranked results with the match marked in each snippet. */
export function ResultList({ query, results, className }: ResultListProps) {
  return (
    <section aria-label="Search results" className={cn(c.root, className)}>
      <p role="status" className={c.count}>{results.length} results for “{query}”</p>
      <ol className={c.list}>
        {results.map((r) => (
          <li key={r.href} className={c.item}>
            <span className={c.path}>{r.path}</span>
            <a href={r.href} className={c.title}>{r.title}</a>
            <p className={c.snippet}>{markParts(r.snippet, query).map((p, k) => (p.mark ? <mark key={k}>{p.text}</mark> : p.text))}</p>
          </li>
        ))}
      </ol>
    </section>
  );
}
result-list.variants.ts
/** Search results: a count line (role=status, so a new search is announced), then results as an ordered list (a
 *  real ranking): the title as the link, the address in small type, a snippet with the match marked (<mark>). The
 *  title link carries the row's target; rows are separated by space, not rules. */
export const resultList = {
  root: 'w-full',
  count: 'text-sm text-muted-foreground',
  list: 'm-0 mt-4 grid list-none gap-6 p-0',
  item: 'grid gap-1',
  path: 'truncate font-mono text-xs text-muted-foreground',
  title: '-mx-1 w-fit rounded-sm px-1 text-base font-medium underline decoration-transparent underline-offset-4 transition-[text-decoration-color] hover:decoration-foreground active:bg-accent',
  snippet: 'max-w-prose text-sm text-pretty text-muted-foreground [&_mark]:rounded-sm [&_mark]:bg-accent [&_mark]:px-0.5 [&_mark]:font-medium [&_mark]:text-foreground',
};
export type Result = { title: string; href: string; path: string; snippet: string };

/** The snippet as parts, with every match of the query marked (case-insensitive). */
export function markParts(text: string, query: string): { text: string; mark: boolean }[] {
  const q = query.trim();
  if (!q) return [{ text, mark: false }];
  const re = new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
  return text.split(re).filter(Boolean).map((t) => ({ text: t, mark: t.toLowerCase() === q.toLowerCase() }));
}