Elements

37 / 122 · Knowledge

Web search

A search query and its results landing one by one as the agent reads.

assistant-ui draft persistence
Searching

Installation

npx shadcn@latest add "@assistant-ui/elements-web-search"

Usage

import { WebSearch } from "@/components/elements/web-search";

<WebSearch
  query="assistant-ui composer"
  results={[{ title: "Composer guide", domain: "docs.example.com" }]}
  visibleResults={1}
  searching={false}
  cycle={0}
/>

Props

query*stringSearch query shown in the query pill.
results*readonly WebSearchResult[]Result cards available to reveal one by one.
visibleResults*numberHow many results from the start are currently shown.
searching*booleanWhen true a shimmering Searching label replaces the result count.
cycle*numberIdentity key that remounts the list so entrance animation can replay.
classNamestringExtra classes merged onto the root.

This element normalizes its counts and shares at the boundary, so a value from outside its range reads as the nearest end rather than reaching the DOM: a negative count shows nothing, one past the end shows everything, and a share stays within 0 to 100 percent. A prop that names a selection is the exception, and means nothing is selected.

Source

web-search.tsx
"use client";

import type { ComponentProps } from "react";
import { SearchIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { field, mono, ShimmerLabel } from "./surfaces";
import { take } from "./range";

export interface WebSearchResult {
  title: string;
  domain: string;
}

export function WebSearch({
  query,
  results,
  visibleResults,
  searching,
  cycle,
  className,
  ...props
}: Omit<
  ComponentProps<"div">,
  "children" | "query" | "results" | "visibleResults" | "searching" | "cycle"
> & {
  query: string;
  results: readonly WebSearchResult[];
  visibleResults: number;
  searching: boolean;
  cycle: number;
}) {
  return (
    <div
      data-slot="web-search"
      className={cn("flex w-full max-w-sm flex-col gap-2.5", className)}

      {...props}
    >
      <span
        className={cn(
          field,
          "text-foreground/70 inline-flex w-fit items-center gap-1.5 rounded-full px-3.5 py-2 text-xs",
        )}
      >
        <SearchIcon className="text-foreground/40 size-3" />
        {query}
      </span>
      <div className="text-foreground/45 text-xs">
        {searching ? (
          <ShimmerLabel className="relative inline-block leading-none">
            Searching
          </ShimmerLabel>
        ) : (
          <span className="fade-in animate-in duration-300">
            Read 3 sources
          </span>
        )}
      </div>
      <div className="flex min-h-[5.75rem] flex-col">
        {take(results, visibleResults).map((result) => (
          <div
            key={`${cycle}-${result.domain}`}
            className="fade-in slide-in-from-bottom-1 animate-in fill-mode-both hover:bg-foreground/[0.03] -mx-2.5 flex items-center gap-2.5 rounded-xl px-2.5 py-1.5 transition-colors duration-300"
          >
            <span className="bg-foreground/[0.06] text-foreground/45 flex size-4 shrink-0 items-center justify-center rounded text-[9px] font-medium">
              {result.domain.charAt(0).toUpperCase()}
            </span>
            <span className="text-foreground/90 min-w-0 flex-1 truncate text-[13.5px]">
              {result.title}
            </span>
            <span className={cn(mono, "text-foreground/35 shrink-0")}>
              {result.domain}
            </span>
          </div>
        ))}
      </div>
    </div>
  );
}