# Search results (Caseroom component)

An announced count, then ranked results with the address and the match marked in each snippet. Black and white, dark mode, shadcn-compatible; React, Astro and plain HTML.

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

## 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`
- HTML: https://www.themes.dev/caseroom/r/html/result-list.html (with https://www.themes.dev/caseroom/r/html/caseroom.css)

Page: https://www.themes.dev/caseroom/components/result-list/ · Rules for agents: https://www.themes.dev/caseroom/AGENT.md

## Code

### result-list.astro

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

interface Props {
  query: string;
  results: Result[];
  class?: string;
}
const { query, results, class: className } = Astro.props;
---

<section aria-label="Search results" class={cn(c.root, className)}>
  <p role="status" class={c.count}>{results.length} results for “{query}”</p>
  <ol class={c.list}>
    {
      results.map((r) => (
        <li class={c.item}>
          <span class={c.path}>{r.path}</span>
          <a href={r.href} class={c.title}>{r.title}</a>
          <p class={c.snippet}>{markParts(r.snippet, query).map((p) => (p.mark ? <mark>{p.text}</mark> : p.text))}</p>
        </li>
      ))
    }
  </ol>
</section>
```

### result-list.tsx

```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

```tsx
/** 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() }));
}
```
