For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

Web Search 01

A compact research trail with source links and a chat example.

Installation

Set up the @cobalt registry as described in Installation, then add the block:

$ pnpm dlx shadcn@latest add @cobalt/web-search-01

Usage

Pass steps with a label, optional result count, and optional sources. Each source accepts a title, domain, and optional link. Set dwell in milliseconds to control the delay before a step appears. Pass revealed to control the number of visible rows externally; source rows count separately. Set working={false} to hide the progress indicator, and use onComplete to respond when all rows are visible.

Source

"use client";

import { RiGlobeLine, RiSearchLine, RiSparklingLine } from "@remixicon/react";
import { cn } from "cn";
import * as React from "react";

import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
  Trace,
  TraceDescription,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from "@/components/ui/trace";

export interface WebSearchSource {
  title: string;
  domain: string;
  href?: string;
}

export interface WebSearchStep {
  label: string;
  meta?: string;
  sources?: WebSearchSource[];
  dwell?: number;
}

/** A compact, progressively revealed research trail. */
export function WebSearch({
  steps,
  revealed,
  working = "Searching",
  onComplete,
  className,
}: {
  steps: WebSearchStep[];
  /** Number of visible rows, including source rows. Omit to use the timer. */
  revealed?: number;
  working?: string | false;
  onComplete?: () => void;
  className?: string;
}) {
  const rows = React.useMemo(
    () =>
      steps.flatMap((step, index) => [
        { kind: "step" as const, step, index },
        ...(step.sources?.length
          ? [{ kind: "sources" as const, step, index }]
          : []),
      ]),
    [steps]
  );
  const [count, setCount] = React.useState(0);
  const completed = React.useRef(false);
  React.useEffect(() => {
    if (revealed !== undefined || count >= rows.length) {
      return;
    }
    const row = rows[count];
    const timer = window.setTimeout(
      () => setCount((value) => value + 1),
      row.kind === "step" ? (row.step.dwell ?? 700) : 250
    );
    return () => window.clearTimeout(timer);
  }, [count, revealed, rows]);
  const visible = Math.max(0, Math.min(revealed ?? count, rows.length));
  React.useEffect(() => {
    if (visible >= rows.length && !completed.current) {
      completed.current = true;
      onComplete?.();
    } else if (visible < rows.length) {
      completed.current = false;
    }
  }, [visible, rows.length, onComplete]);

  return (
    <Trace
      className={cn("w-fit max-w-full gap-0", className)}
      aria-live="polite"
    >
      {rows.slice(0, visible).map((row, rowIndex) =>
        row.kind === "step" ? (
          <div
            key={`step-${row.index}`}
            style={{ "--delay": `${rowIndex * 40}ms` } as React.CSSProperties}
            className={cn(
              "web-search-01-row-in relative flex min-h-7 min-w-0 items-start",
              row.index > 0 && "ml-2.5 border-l border-border pl-4"
            )}
          >
            {row.index > 0 && (
              <span
                aria-hidden
                className="absolute top-0 -left-px h-3 w-3 rounded-bl-lg border-b border-l border-border"
              />
            )}
            <TraceLine>
              <TraceIcon>
                <RiSearchLine />
              </TraceIcon>
              <TraceTitle className="whitespace-normal wrap-anywhere">
                {row.step.label}
              </TraceTitle>
              {row.step.meta && (
                <TraceDescription className="ml-2 hidden @[240px]:inline">
                  {row.step.meta}
                </TraceDescription>
              )}
            </TraceLine>
          </div>
        ) : (
          <div
            key={`sources-${row.index}`}
            style={{ "--delay": `${rowIndex * 40}ms` } as React.CSSProperties}
            className="relative ml-2.5 flex min-h-7 min-w-0 items-start border-l border-border pl-4 web-search-01-row-in"
          >
            <span
              aria-hidden
              className="absolute top-0 -left-px h-3 w-3 rounded-bl-lg border-b border-l border-border"
            />
            <div className="flex min-w-0 flex-wrap items-center gap-2 text-sm leading-5 text-muted-foreground">
              <span>Sources</span>
              <span className="flex flex-wrap items-center gap-1">
                {row.step.sources?.slice(0, 6).map((source, sourceIndex) => {
                  const mark = (
                    <Avatar
                      size="xs"
                      title={`${source.title} — ${source.domain}`}
                    >
                      <AvatarFallback>
                        {source.domain.charAt(0).toUpperCase()}
                      </AvatarFallback>
                    </Avatar>
                  );
                  return source.href ? (
                    <a
                      key={`${source.domain}-${sourceIndex}`}
                      href={source.href}
                      aria-label={`${source.title} — ${source.domain}`}
                      className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring"
                    >
                      {mark}
                    </a>
                  ) : (
                    <span
                      key={`${source.domain}-${sourceIndex}`}
                      aria-label={`${source.title} — ${source.domain}`}
                    >
                      {mark}
                    </span>
                  );
                })}
                {(row.step.sources?.length ?? 0) > 6 && (
                  <span className="flex h-5 min-w-5 items-center justify-center rounded-full border border-background bg-muted px-1 text-xs">
                    +{(row.step.sources?.length ?? 0) - 6}
                  </span>
                )}
              </span>
            </div>
          </div>
        )
      )}
      {visible < rows.length && working && (
        <TraceLine status="running" className="mt-1">
          <TraceIcon>
            <RiSparklingLine />
          </TraceIcon>
          <TraceTitle>{working}</TraceTitle>
        </TraceLine>
      )}
      {rows.length === 0 && (
        <TraceLine>
          <TraceIcon>
            <RiGlobeLine />
          </TraceIcon>
          <TraceTitle>No searches yet</TraceTitle>
        </TraceLine>
      )}
    </Trace>
  );
}