Skip to content
UI Kit
Accent color

AI search

AI search in four forms. Results with an AI overview on top whose citations jump to their result, a research run that turns into a report, an answer as a comparison table with a recommendation and caveats, and a thread of follow-up questions.

One of 4 components in AI answers

4 variants

Variants

Results with AI overview

A short AI summary above classic results, folded to a few lines, with a clear note on how much the sources agree. A citation scrolls to its result and marks it.

Open in Studio

Research report

When it scrolls into view the research plays out. Searches are planned, sources counted and listed, then the report appears with contents, sections and sources.

Open in Studio

Answer as a table

A comparison question answered as a table with a recommended column, citations in the cells that open their source below, and caveats. Phones switch between options.

Open in Studio

Follow-up thread

Questions and answers as a thread, earlier answers fold to their question, related questions add a new turn and the ask bar stays at hand.

Open in Studio

Details

What AI search brings along, read from the registry and the source.

Category
AI answers
Area
App
Variants
4
Version
1.0.1 · since 1.1.0
File
components/ui/ai-search.tsx
Brings along
utilskiticonrevealmotion
npm packages
None
Languages
English and German, 29 texts of its own

Props

The AiSearchProps interface from components/ui/ai-search.tsx, with the comments from the code.

  • variantoptional
    AiSearchVariant

    overviewresearch-reportstructured-answerfollow-up-thread

  • titleoptional
    string
  • queryoptional
    string
  • summaryoptional
    string

    overview and follow-up-thread: the AI summary, [n] cites a result

  • resultsoptional
    SearchResult[]
  • confidenceoptional
    'high' | 'medium' | 'low'

    overview, research-report and structured-answer: how sure the answer is

  • queriesoptional
    ResearchQuery[]

    research-report

  • sectionsoptional
    ReportSection[]
  • readoptional
    number

    research-report: sources read in total

  • tableoptional
    AnswerTable

    structured-answer

  • caveatsoptional
    string[]

    structured-answer: things to keep in mind, shown under the table

  • turnsoptional
    ThreadTurn[]

    follow-up-thread

  • relatedoptional
    string[]
  • onSearchoptional
    (query: string) => void

    overview and structured-answer: called with the query when the search is submitted

Built-in texts

AI search writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 29 texts
searchEnglish SearchGerman Suchen
overviewEnglish AI overviewGerman KI-Übersicht
moreEnglish Show moreGerman Mehr zeigen
lessEnglish Show lessGerman Weniger zeigen
resultsEnglish ResultsGerman Ergebnisse
sourceEnglish Source {n}German Quelle {n}
confidence_highEnglish Sources agreeGerman Quellen sind sich einig
confidence_mediumEnglish Sources partly disagreeGerman Quellen sind sich teils uneinig
confidence_lowEnglish Few sources, check carefullyGerman Wenige Quellen, genau prüfen
aiNoteEnglish Generated from the results below. It can be wrong.German Aus den Ergebnissen unten erstellt. Kann falsch sein.
helpfulEnglish HelpfulGerman Hilfreich
notHelpfulEnglish Not helpfulGerman Nicht hilfreich
planningEnglish Planning searchesGerman Plant die Suchen
readingEnglish Reading sourcesGerman Liest Quellen
writingEnglish Writing the reportGerman Schreibt den Bericht
readLabelEnglish sources readGerman Quellen gelesen
contentsEnglish ContentsGerman Inhalt
reportEnglish ReportGerman Bericht
recommendedEnglish Fits bestGerman Passt am besten
caveatsEnglish Keep in mindGerman Gut zu wissen
sourcesEnglish SourcesGerman Quellen
sourceCountEnglish {n} sourcesGerman {n} Quellen
followEnglish Ask a follow-upGerman Nachfrage stellen
askEnglish AskGerman Fragen
relatedEnglish People also askGerman Andere fragen auch
thinkingEnglish SearchingGerman Sucht
collapseEnglish Collapse answerGerman Antwort einklappen
expandEnglish Expand answerGerman Antwort aufklappen
fallbackEnglish I looked through the same sources again. The short answer is that it depends on your tariff and how much hot water you use. Open the sources for the details.German Ich habe dieselben Quellen noch einmal durchgesehen. Kurz gesagt hängt es von deinem Tarif und deinem Warmwasser ab. Die Details stehen in den Quellen.

Accessibility

Read from the source of the component.

  • AI search handles the keys Arrow left, Arrow right and Space itself.
  • Sets aria-busy, aria-expanded, aria-label, aria-live, aria-pressed and aria-selected, so screen readers announce purpose and state.
  • Uses the roles search, status, tab, tablist and tabpanel.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Rises in briefly the first time it comes into view (kit-rise, useInView).
  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 750 lines
components/ui/ai-search.tsx
'use client';

import { Fragment, useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useInView, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger } from '@/components/ui/motion';

export type AiSearchVariant = 'overview' | 'research-report' | 'structured-answer' | 'follow-up-thread';

export interface SearchResult {
  id: number;
  title: string;
  site: string;
  /** Shown as the visible address, e.g. "stadtwerke-nordheim.de › tarife" */
  path?: string;
  snippet: string;
  href?: string;
  date?: string;
}

export interface ResearchQuery {
  id: string;
  query: string;
  /** Sources found for it */
  found: number;
}

export interface ReportSection {
  id: string;
  heading: string;
  /** Text with [n] citations */
  text: string;
}

export interface AnswerTable {
  columns: string[];
  /** Each row: label and one cell per column, cells may end with [n] */
  rows: { label: string; cells: string[] }[];
  /** Index of the column that is the recommendation */
  pick?: number;
}

export interface ThreadTurn {
  id: string;
  question: string;
  /** Text with [n] citations, **bold** works */
  answer: string;
  sources?: number[];
}

export interface AiSearchProps {
  variant?: AiSearchVariant;
  title?: string;
  query?: string;
  /** overview and follow-up-thread: the AI summary, [n] cites a result */
  summary?: string;
  results?: SearchResult[];
  /** overview, research-report and structured-answer: how sure the answer is */
  confidence?: 'high' | 'medium' | 'low';
  /** research-report */
  queries?: ResearchQuery[];
  sections?: ReportSection[];
  /** research-report: sources read in total */
  read?: number;
  /** structured-answer */
  table?: AnswerTable;
  /** structured-answer: things to keep in mind, shown under the table */
  caveats?: string[];
  /** follow-up-thread */
  turns?: ThreadTurn[];
  related?: string[];
  /** overview and structured-answer: called with the query when the search is submitted */
  onSearch?: (query: string) => void;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const TEXTS = {
  en: {
    search: 'Search',
    overview: 'AI overview',
    more: 'Show more',
    less: 'Show less',
    results: 'Results',
    source: 'Source {n}',
    confidence_high: 'Sources agree',
    confidence_medium: 'Sources partly disagree',
    confidence_low: 'Few sources, check carefully',
    aiNote: 'Generated from the results below. It can be wrong.',
    helpful: 'Helpful',
    notHelpful: 'Not helpful',
    planning: 'Planning searches',
    reading: 'Reading sources',
    writing: 'Writing the report',
    
    readLabel: 'sources read',
    
    contents: 'Contents',
    
    report: 'Report',
    
    recommended: 'Fits best',
    caveats: 'Keep in mind',
    sources: 'Sources',
    sourceCount: '{n} sources',
    follow: 'Ask a follow-up',
    ask: 'Ask',
    related: 'People also ask',
    thinking: 'Searching',
    collapse: 'Collapse answer',
    expand: 'Expand answer',
    fallback: 'I looked through the same sources again. The short answer is that it depends on your tariff and how much hot water you use. Open the sources for the details.',
  },
  de: {
    search: 'Suchen',
    overview: 'KI-Übersicht',
    more: 'Mehr zeigen',
    less: 'Weniger zeigen',
    results: 'Ergebnisse',
    source: 'Quelle {n}',
    confidence_high: 'Quellen sind sich einig',
    confidence_medium: 'Quellen sind sich teils uneinig',
    confidence_low: 'Wenige Quellen, genau prüfen',
    aiNote: 'Aus den Ergebnissen unten erstellt. Kann falsch sein.',
    helpful: 'Hilfreich',
    notHelpful: 'Nicht hilfreich',
    planning: 'Plant die Suchen',
    reading: 'Liest Quellen',
    writing: 'Schreibt den Bericht',
    
    readLabel: 'Quellen gelesen',
    
    contents: 'Inhalt',
    
    report: 'Bericht',
    
    recommended: 'Passt am besten',
    caveats: 'Gut zu wissen',
    sources: 'Quellen',
    sourceCount: '{n} Quellen',
    follow: 'Nachfrage stellen',
    ask: 'Fragen',
    related: 'Andere fragen auch',
    thinking: 'Sucht',
    collapse: 'Antwort einklappen',
    expand: 'Antwort aufklappen',
    fallback: 'Ich habe dieselben Quellen noch einmal durchgesehen. Kurz gesagt hängt es von deinem Tarif und deinem Warmwasser ab. Die Details stehen in den Quellen.',
  },
};

type Texts = (typeof TEXTS)['en'];
type P = AiSearchProps & { t: Texts };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));

const CSS =
  '@keyframes asr-up{from{opacity:0;transform:translateY(8px)}}@keyframes asr-pop{from{opacity:0;transform:scale(.9)}}@keyframes asr-dot{0%,80%,100%{opacity:.25}40%{opacity:1}}' +
  '@media (prefers-reduced-motion:no-preference){.asr-up{animation:asr-up .45s var(--ease-kit) var(--asr-d,0ms) both}.asr-pop{animation:asr-pop .35s var(--ease-kit) var(--asr-d,0ms) both}.asr-dot{animation:asr-dot 1.2s ease-in-out infinite}}';

function Style() {
  return (
    <style href="kit-ai-search" precedence="default">
      {CSS}
    </style>
  );
}

/* ------------------------------------------------------------------ */
/* Shared parts                                                        */
/* ------------------------------------------------------------------ */

function Stage({ title, children, className, wide }: { title?: string; children: React.ReactNode; className?: string; wide?: boolean }) {
  return (
    <section className={cn('kit-glow relative overflow-clip bg-surface py-12 sm:py-16 lg:py-20', className)}>
      <div className="kit-container">
        <div className={cn('mx-auto w-full', wide ? 'max-w-[68rem]' : 'max-w-[46rem]')}>
          {title && (
            <Reveal className="mb-5 sm:mb-6">
              <h2 className="text-balance text-[22px] font-semibold leading-tight tracking-[-0.02em] text-foreground sm:text-[26px]">{title}</h2>
            </Reveal>
          )}
          {children}
        </div>
      </div>
    </section>
  );
}

function SearchBar({ value, t, onSubmit }: { value?: string; t: Texts; onSubmit?: (q: string) => void }) {
  const [q, setQ] = useState(value ?? '');
  return (
    <form
      role="search"
      onSubmit={(e) => {
        e.preventDefault();
        onSubmit?.(q);
      }}
      className="flex items-center gap-2 rounded-2xl border border-border bg-card p-1.5 pl-4 shadow-[var(--highlight),var(--shadow-md)] focus-within:border-[color-mix(in_oklab,var(--primary)_50%,var(--border))] focus-within:shadow-[var(--highlight),var(--shadow-md),0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)]"
    >
      <Icon name="search" size={18} className="shrink-0 text-muted-foreground" />
      <label className="min-w-0 flex-1">
        <span className="sr-only">{t.search}</span>
        <input type="search" value={q} onChange={(e) => setQ(e.target.value)} className="h-11 w-full bg-transparent text-[15px] text-foreground outline-none placeholder:text-subtle-foreground focus-visible:outline-none" />
      </label>
      <button type="submit" aria-label={t.search} className="grid size-11 shrink-0 place-items-center rounded-xl bg-primary text-primary-foreground hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))]">
        <Icon name="arrow-right" size={18} />
      </button>
    </form>
  );
}

const CONF: Record<'high' | 'medium' | 'low', { icon: IconName; cls: string }> = {
  high: { icon: 'check-circle', cls: 'text-success-foreground' },
  medium: { icon: 'alert-circle', cls: 'text-warning-foreground' },
  low: { icon: 'help-circle', cls: 'text-destructive' },
};

function Confidence({ level, t }: { level: 'high' | 'medium' | 'low'; t: Texts }) {
  const c = CONF[level];
  return (
    <span className={cn('inline-flex items-center gap-1.5 text-[12.5px] font-medium', c.cls)}>
      <Icon name={c.icon} size={14} />
      {t[`confidence_${level}`]}
    </span>
  );
}

const cite = 'inline-flex h-[1.3em] min-w-[1.3em] items-center justify-center rounded-[5px] px-1 align-[0.1em] text-[11px] font-semibold tabular-nums leading-none transition-colors';

/** Text with **bold** and [n] markers, the marker stays with the word before it */
function Cited({ text, onCite, active, t }: { text: string; onCite?: (n: number) => void; active?: number | null; t: Texts }) {
  const out: React.ReactNode[] = [];
  const re = /(\S*)\s?\[(\d+(?:\s*,\s*\d+)*)\]([.,;:!?]?)/g;
  let last = 0;
  let k = 0;
  const bold = (s: string) => s.split(/(\*\*[^*]+\*\*)/g).map((x, i) => (x.startsWith('**') ? <strong key={i} className="font-semibold text-foreground">{x.slice(2, -2)}</strong> : <Fragment key={i}>{x}</Fragment>));
  for (const m of text.matchAll(re)) {
    out.push(<Fragment key={k++}>{bold(text.slice(last, m.index))}</Fragment>);
    out.push(
      <span key={k++} className="whitespace-nowrap">
        {bold(m[1])}
        {m[3]}
        {m[2].split(',').map((n) => {
          const id = Number(n.trim());
          if (!onCite)
            return (
              <span key={id} aria-label={fill(t.source, { n: id })} className={cn(cite, 'ml-0.5 bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]')}>
                {id}
              </span>
            );
          return (
            <button key={id} type="button" aria-label={fill(t.source, { n: id })} onClick={() => onCite?.(id)} className={cn(cite, "relative ml-0.5 after:absolute after:-inset-x-1 after:-inset-y-4 after:content-['']", active === id ? 'bg-primary text-primary-foreground' : 'bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))] hover:bg-[color-mix(in_oklab,var(--primary)_24%,var(--card))]')}>
              {id}
            </button>
          );
        })}
      </span>,
    );
    last = m.index! + m[0].length;
  }
  out.push(<Fragment key={k++}>{bold(text.slice(last))}</Fragment>);
  return <>{out}</>;
}

function Favicon({ site, id }: { site: string; id: number }) {
  const tone = ['--primary', '--info', '--success', '--warning', '--destructive'][(id - 1) % 5];
  return (
    <span aria-hidden className="grid size-6 shrink-0 place-items-center rounded-full text-[11px] font-semibold uppercase" style={{ background: `color-mix(in oklab, var(${tone}) 16%, var(--card))`, color: `color-mix(in oklab, var(${tone}) 72%, var(--foreground))` }}>
      {site.charAt(0)}
    </span>
  );
}

/* ------------------------------------------------------------------ */
/* overview                                                            */
/* ------------------------------------------------------------------ */

function Overview({ t, title, query, summary = '', results = [], confidence = 'high', onSearch, className }: P) {
  const [open, setOpen] = useState(false);
  const [active, setActive] = useState<number | null>(null);
  const [vote, setVote] = useState<'up' | 'down' | null>(null);
  // A search plays the overview in again, the results come from onSearch
  const [run, setRun] = useState(0);
  const go = (n: number) => {
    setActive(n);
    document.getElementById(`asr-r-${n}`)?.scrollIntoView({ behavior: prefersReducedMotion() ? 'auto' : 'smooth', block: 'center' });
  };
  const paras = summary.split(/\n{2,}/);
  return (
    <Stage title={title} className={className}>
      <Reveal>
        <SearchBar value={query} t={t} onSubmit={(q) => (onSearch?.(q), setRun((r) => r + 1), setVote(null))} />
      </Reveal>
      <Reveal delay={100}>
        <section key={run} aria-label={t.overview} className="asr-up relative mt-5 overflow-hidden rounded-2xl border border-[color-mix(in_oklab,var(--primary)_30%,var(--border))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--primary)_7%,var(--card)),var(--card)_40%)] shadow-[var(--highlight),var(--shadow-sm)]">
          <div className="flex flex-wrap items-center justify-between gap-2 px-4 pt-4 sm:px-5">
            <p className="flex items-center gap-1.5 text-[13px] font-semibold text-foreground">
              <Icon name="sparkle" size={15} className="text-primary" />
              {t.overview}
            </p>
            <Confidence level={confidence} t={t} />
          </div>
          <div className={cn('relative px-4 pt-3 sm:px-5', !open && 'max-h-[9.5rem] overflow-hidden')}>
            <div className="space-y-3 text-pretty text-[15px] leading-relaxed text-muted-foreground">
              {paras.map((p, i) => (
                <p key={i}>
                  <Cited text={p} t={t} onCite={go} active={active} />
                </p>
              ))}
            </div>
            {!open && <div aria-hidden className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-[linear-gradient(to_top,var(--card),transparent)]" />}
          </div>
          <div className="flex flex-wrap items-center justify-between gap-2 px-4 pb-3 pt-2 sm:px-5">
            <button type="button" aria-expanded={open} onClick={() => setOpen((o) => !o)} className="inline-flex h-11 items-center gap-1 rounded-lg px-2 text-[13px] font-medium text-foreground hover:bg-accent sm:h-8">
              {open ? t.less : t.more}
              <Icon name="chevron-down" size={15} className={cn('transition-transform duration-300', open && 'rotate-180')} />
            </button>
            <span className="flex items-center gap-1">
              <span className="mr-1 text-[12px] text-subtle-foreground max-sm:hidden">{t.aiNote}</span>
              {(['up', 'down'] as const).map((v) => (
                <button key={v} type="button" aria-pressed={vote === v} aria-label={v === 'up' ? t.helpful : t.notHelpful} onClick={() => setVote(vote === v ? null : v)} className={cn('grid size-11 place-items-center rounded-lg transition-colors sm:size-8', vote === v ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground')}>
                  <Icon name={v === 'up' ? 'thumbs-up' : 'thumbs-down'} size={15} />
                </button>
              ))}
            </span>
          </div>
          <p className="border-t border-border px-4 py-2 text-[12px] text-subtle-foreground sm:hidden">{t.aiNote}</p>
        </section>
      </Reveal>
      <h3 className="mb-2 mt-7 px-1 text-[12.5px] font-medium text-subtle-foreground">{t.results}</h3>
      <Stagger as="ol" step={60} className="space-y-1">
        {results.map((r) => (
          <li key={r.id} id={`asr-r-${r.id}`} className={cn('scroll-mt-24 rounded-xl px-3 py-3 transition-colors duration-500 sm:px-4', active === r.id ? 'bg-[color-mix(in_oklab,var(--primary)_8%,var(--card))] shadow-[0_0_0_1px_color-mix(in_oklab,var(--primary)_35%,transparent)]' : 'hover:bg-card/70')}>
            <div className="flex items-center gap-2">
              <Favicon site={r.site} id={r.id} />
              <span className="min-w-0">
                <span className="block truncate text-[13px] text-foreground">{r.site}</span>
                {r.path && <span className="block truncate text-[12px] text-muted-foreground">{r.path}</span>}
              </span>
              <span className={cn(cite, 'ml-auto shrink-0', active === r.id ? 'bg-primary text-primary-foreground' : 'bg-[color-mix(in_oklab,var(--foreground)_8%,transparent)] text-muted-foreground')}>{r.id}</span>
            </div>
            <a href={r.href ?? '#'} className="-mb-3 -mt-1.5 block py-3 text-[16.5px] font-medium leading-snug text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))] underline-offset-4 hover:underline">
              {r.title}
            </a>
            <p className="mt-1 text-pretty text-[14px] leading-relaxed text-muted-foreground">
              {r.date && <span className="text-subtle-foreground">{r.date} · </span>}
              {r.snippet}
            </p>
          </li>
        ))}
      </Stagger>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* research-report                                                     */
/* ------------------------------------------------------------------ */

function ResearchReport({ t, title, query, queries = [], sections = [], results = [], read = 0, confidence = 'medium', className }: P) {
  // phase 0 planning, 1 reading, 2 writing, 3 done. The server renders the finished report.
  const [phase, setPhase] = useState(3);
  const [count, setCount] = useState(read);
  const [ref, inView] = useInView<HTMLDivElement>('0px 0px -20% 0px');
  const started = useRef(false);
  const timers = useRef<number[]>([]);
  // Once started the run plays to the end, even when it scrolls out of view, and is only cleared on unmount
  useEffect(() => () => timers.current.forEach((x) => (window.clearTimeout(x), window.clearInterval(x))), []);
  useEffect(() => {
    if (!inView || started.current || prefersReducedMotion()) return;
    // The lock is set when the timer fires: strict mode clears the first timer and starts a second one
    const go = window.setTimeout(() => {
      started.current = true;
      setPhase(0);
      setCount(0);
      const id = window.setInterval(() => setCount((c) => (c >= read ? (window.clearInterval(id), c) : c + Math.max(1, Math.round(read / 12)))), 100);
      timers.current.push(window.setTimeout(() => setPhase(1), 500), window.setTimeout(() => setPhase(2), 1600), window.setTimeout(() => setPhase(3), 2200), id);
    }, 0);
    return () => window.clearTimeout(go);
  }, [inView, read]);
  const steps = [t.planning, t.reading, t.writing];
  const [src, setSrc] = useState<number | null>(null);
  const uid = useId();
  const toSource = (n: number) => {
    setSrc(n);
    document.getElementById(`${uid}-src-${n}`)?.scrollIntoView({ behavior: prefersReducedMotion() ? 'auto' : 'smooth', block: 'center' });
  };
  return (
    <Stage title={title} className={className} wide>
      <div ref={ref}>
        <Reveal>
          <p className="mb-4 flex items-start gap-2 rounded-xl border border-border bg-card px-4 py-3 text-[15px] text-foreground shadow-[var(--shadow-sm)]">
            <Icon name="search" size={17} className="mt-0.5 shrink-0 text-muted-foreground" />
            {query}
          </p>
        </Reveal>
        <div className="grid grid-cols-1 gap-4 lg:grid-cols-[19rem_minmax(0,1fr)] lg:items-start lg:gap-6">
          {/* The process: steps, sources counted and the searches it ran */}
          <Reveal delay={80} className="lg:sticky lg:top-6">
            <div className="rounded-2xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)]">
              <ol className="space-y-2.5" aria-live="polite">
                {steps.map((s, i) => {
                  const st = phase > i ? 'done' : phase === i ? 'now' : 'next';
                  return (
                    <li key={s} className="flex items-center gap-2.5 text-[13.5px]">
                      <span className={cn('grid size-6 shrink-0 place-items-center rounded-full', st === 'done' ? 'bg-[color-mix(in_oklab,var(--success)_16%,var(--card))] text-success-foreground' : st === 'now' ? 'bg-[color-mix(in_oklab,var(--primary)_16%,var(--card))] text-primary' : 'border border-border text-subtle-foreground')}>
                        <Icon name={st === 'done' ? 'check' : st === 'now' ? 'loader' : 'circle'} size={13} className={cn(st === 'now' && 'animate-spin', st === 'next' && 'scale-50')} />
                      </span>
                      <span className={st === 'next' ? 'text-muted-foreground' : 'text-foreground'}>{s}</span>
                    </li>
                  );
                })}
              </ol>
              <p className="mt-4 border-t border-border pt-3 text-[13px] tabular-nums text-muted-foreground">
                <span className="text-[20px] font-semibold text-foreground">{Math.min(count, read)}</span> {t.readLabel}
              </p>
              <ul className="mt-3 space-y-1.5">
                {queries.map((q, i) => (
                  <li key={q.id} className="flex items-center gap-2 text-[12.5px]">
                    <Icon name="search" size={12} className="shrink-0 text-subtle-foreground" />
                    <span className="min-w-0 flex-1 truncate text-muted-foreground">{q.query}</span>
                    <span className={cn('shrink-0 tabular-nums text-subtle-foreground transition-opacity duration-300', phase < 1 && 'opacity-0')} style={{ transitionDelay: `${i * 250}ms` }}>
                      {q.found}
                    </span>
                  </li>
                ))}
              </ul>
            </div>
          </Reveal>
          <Reveal delay={160}>
            <article className="rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-sm)] sm:p-7" aria-busy={phase < 3}>
              <div className="mb-5 flex flex-wrap items-center justify-between gap-2 border-b border-border pb-4">
                <p className="flex items-center gap-1.5 text-[13px] font-semibold text-foreground">
                  <Icon name="file-check" size={15} className="text-primary" />
                  {t.report}
                </p>
                {phase >= 3 ? (
                  <span className="asr-up">
                    <Confidence level={confidence} t={t} />
                  </span>
                ) : (
                  <span className="text-[12.5px] text-muted-foreground">{steps[Math.min(phase, 2)]}</span>
                )}
              </div>
              <nav aria-label={t.contents} className="mb-6 flex flex-wrap gap-1.5">
                {sections.map((s) => (
                  <a key={s.id} href={`#asr-s-${s.id}`} className="inline-flex min-h-9 items-center rounded-lg border border-border px-2.5 text-[12.5px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground max-sm:min-h-11">
                    {s.heading}
                  </a>
                ))}
              </nav>
              <div className="space-y-7">
                {sections.map((s, i) => (
                  <section key={s.id} id={`asr-s-${s.id}`} className={cn('scroll-mt-24 transition-[opacity,filter] duration-700 ease-[var(--ease-kit)]', phase < 3 && 'opacity-25 blur-[2px]')} style={{ transitionDelay: phase >= 3 ? `${i * 140}ms` : '0ms' }}>
                    <h3 className="text-[17px] font-semibold tracking-[-0.01em] text-foreground">{s.heading}</h3>
                    <p className="mt-2 text-pretty text-[15px] leading-relaxed text-muted-foreground">
                      <Cited text={s.text} t={t} onCite={toSource} active={src} />
                    </p>
                  </section>
                ))}
              </div>
              <div className="mt-7 border-t border-border pt-4">
                <p className="mb-2 text-[12.5px] font-medium text-subtle-foreground">{t.sources}</p>
                <ol className="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
                  {results.map((r) => (
                    <li key={r.id} id={`${uid}-src-${r.id}`} className="scroll-mt-24">
                      <a href={r.href ?? '#'} className={cn('flex min-h-11 items-center gap-2 rounded-lg px-2 py-1.5 text-[13px] transition-colors duration-500 hover:bg-accent', src === r.id && 'bg-[color-mix(in_oklab,var(--primary)_8%,var(--card))]')}>
                        <span className={cn(cite, src === r.id ? 'bg-primary text-primary-foreground' : 'bg-[color-mix(in_oklab,var(--foreground)_8%,transparent)] text-muted-foreground')}>{r.id}</span>
                        <span className="min-w-0 flex-1 text-pretty leading-snug text-foreground line-clamp-2">{r.title}</span>
                      </a>
                    </li>
                  ))}
                </ol>
              </div>
            </article>
          </Reveal>
        </div>
      </div>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */

function StructuredAnswer({ t, title, query, summary, table, caveats = [], results = [], confidence = 'medium', onSearch, className }: P) {
  const [active, setActive] = useState<number | null>(null);
  const [phone, setPhone] = useState(table?.pick ?? 0);
  const [run, setRun] = useState(0);
  const cols = table?.columns ?? [];
  const src = results.find((r) => r.id === active);
  return (
    <Stage title={title} className={className} wide>
      <Reveal>
        <SearchBar value={query} t={t} onSubmit={(q) => (onSearch?.(q), setRun((r) => r + 1))} />
      </Reveal>
      <Reveal delay={100}>
        <div key={run} className="asr-up mt-5 rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
          <div className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-3 sm:px-5">
            <p className="flex items-center gap-1.5 text-[13px] font-semibold text-foreground">
              <Icon name="sparkle" size={15} className="text-primary" />
              {t.overview}
            </p>
            <Confidence level={confidence} t={t} />
          </div>
          {summary && (
            <p className="px-4 pt-4 text-pretty text-[15px] leading-relaxed text-muted-foreground sm:px-5">
              <Cited text={summary} t={t} onCite={setActive} active={active} />
            </p>
          )}
          {/* Phones: one option at a time */}
          <div className="p-4 md:hidden">
            <div role="tablist" aria-label={t.overview} className="flex gap-1 rounded-xl bg-[color-mix(in_oklab,var(--foreground)_5%,transparent)] p-1">
              {cols.map((c, i) => (
                <button
                  key={c}
                  type="button"
                  role="tab"
                  aria-selected={phone === i}
                  tabIndex={phone === i ? 0 : -1}
                  onClick={() => setPhone(i)}
                  onKeyDown={(e) => {
                    const d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
                    if (!d) return;
                    e.preventDefault();
                    const n = (i + d + cols.length) % cols.length;
                    setPhone(n);
                    (e.currentTarget.parentElement?.children[n] as HTMLElement | undefined)?.focus();
                  }} className={cn('min-h-11 flex-1 truncate rounded-lg px-1.5 text-[12.5px] font-medium', phone === i ? 'bg-card text-foreground shadow-[var(--highlight),var(--shadow-sm)]' : 'text-muted-foreground')}>
                  {c}
                </button>
              ))}
            </div>
            <dl key={phone} role="tabpanel" className="asr-up mt-3 divide-y divide-border rounded-xl border border-border">
              {table?.pick === phone && (
                <div className="flex items-center gap-1.5 bg-[color-mix(in_oklab,var(--primary)_8%,var(--card))] px-3.5 py-2 text-[12.5px] font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]">
                  <Icon name="star" size={13} />
                  {t.recommended}
                </div>
              )}
              {table?.rows.map((r) => (
                <div key={r.label} className="flex items-start justify-between gap-3 px-3.5 py-2.5 text-[13.5px]">
                  <dt className="shrink-0 text-muted-foreground">{r.label}</dt>
                  <dd className="text-right text-foreground">
                    <Cited text={r.cells[phone] ?? ''} t={t} onCite={setActive} active={active} />
                  </dd>
                </div>
              ))}
            </dl>
          </div>
          <div className="hidden p-5 md:block">
            <table className="w-full table-fixed border-separate border-spacing-0 text-left text-[14px]">
              <thead>
                <tr>
                  <th className="w-[24%] pb-2" />
                  {cols.map((c, i) => (
                    <th key={c} scope="col" className={cn('rounded-t-xl px-4 pb-2 pt-3 align-bottom font-semibold text-foreground', table?.pick === i && 'bg-[color-mix(in_oklab,var(--primary)_8%,var(--card))]')}>
                      {table?.pick === i && (
                        <span className="mb-1 flex items-center gap-1 text-[11.5px] font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]">
                          <Icon name="star" size={12} />
                          {t.recommended}
                        </span>
                      )}
                      {c}
                    </th>
                  ))}
                </tr>
              </thead>
              <tbody>
                {table?.rows.map((r, ri) => (
                  <tr key={r.label} className="asr-up" style={{ '--asr-d': `${200 + ri * 70}ms` } as React.CSSProperties}>
                    <th scope="row" className="border-t border-border py-3 pr-3 align-top text-[13px] font-normal text-muted-foreground">
                      {r.label}
                    </th>
                    {r.cells.map((cell, ci) => (
                      <td key={ci} className={cn('border-t border-border px-4 py-3 align-top text-foreground', table?.pick === ci && 'bg-[color-mix(in_oklab,var(--primary)_8%,var(--card))]', table?.pick === ci && ri === (table?.rows.length ?? 0) - 1 && 'rounded-b-xl')}>
                        <Cited text={cell} t={t} onCite={setActive} active={active} />
                      </td>
                    ))}
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
          {src && (
            <div key={src.id} className="asr-up mx-4 mb-4 flex items-start gap-3 rounded-xl border border-border bg-background/60 p-3.5 sm:mx-5" aria-live="polite">
              <Favicon site={src.site} id={src.id} />
              <div className="min-w-0">
                <a href={src.href ?? '#'} className="text-[13.5px] font-medium text-foreground underline-offset-4 hover:underline">
                  {src.title}
                </a>
                <p className="mt-0.5 text-[12.5px] leading-relaxed text-muted-foreground">
                  {src.site} · {src.snippet}
                </p>
              </div>
            </div>
          )}
          {caveats.length > 0 && (
            <div className="border-t border-border px-4 py-4 sm:px-5">
              <p className="mb-2 flex items-center gap-1.5 text-[13px] font-medium text-foreground">
                <Icon name="info" size={14} className="text-warning-foreground" />
                {t.caveats}
              </p>
              <ul className="space-y-1.5">
                {caveats.map((c) => (
                  <li key={c} className="relative pl-4 text-pretty text-[13.5px] leading-relaxed text-muted-foreground before:absolute before:left-1 before:top-[0.6em] before:size-1.5 before:rounded-full before:bg-warning">
                    {c}
                  </li>
                ))}
              </ul>
            </div>
          )}
        </div>
      </Reveal>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* follow-up-thread                                                    */
/* ------------------------------------------------------------------ */

function FollowUpThread({ t, title, turns: initial = [], results = [], related = [], className }: P) {
  const [turns, setTurns] = useState(initial);
  const [collapsed, setCollapsed] = useState<string[]>(initial.slice(0, -1).map((x) => x.id));
  const [q, setQ] = useState('');
  const [busy, setBusy] = useState(false);
  const seq = useRef(0);
  const ask = (question: string) => {
    if (!question.trim() || busy) return;
    setBusy(true);
    setQ('');
    const id = `f${++seq.current}`;
    setCollapsed(turns.map((x) => x.id));
    window.setTimeout(
      () => {
        setTurns((s) => [...s, { id, question, answer: t.fallback, sources: results.slice(0, 2).map((r) => r.id) }]);
        setBusy(false);
      },
      prefersReducedMotion() ? 0 : 1200,
    );
  };
  return (
    <Stage title={title} className={className}>
      <ol className="space-y-3">
        {turns.map((x, i) => {
          const shut = collapsed.includes(x.id);
          return (
            <li key={x.id} className={cn('rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]', i === turns.length - 1 && 'asr-up')}>
              <button type="button" aria-expanded={!shut} aria-label={shut ? t.expand : t.collapse} onClick={() => setCollapsed((c) => (shut ? c.filter((y) => y !== x.id) : [...c, x.id]))} className="flex w-full items-start gap-3 px-4 py-3.5 text-left sm:px-5">
                <h3 className="min-w-0 flex-1 text-pretty text-[16px] font-semibold leading-snug text-foreground">{x.question}</h3>
                {shut && !!x.sources?.length && <span className="mt-0.5 shrink-0 text-[12px] tabular-nums text-muted-foreground">{fill(t.sourceCount, { n: x.sources.length })}</span>}
                <Icon name="chevron-down" size={17} className={cn('mt-0.5 shrink-0 text-muted-foreground transition-transform duration-300', !shut && 'rotate-180')} />
              </button>
              <div className={cn('grid transition-[grid-template-rows] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', shut ? 'grid-rows-[0fr]' : 'grid-rows-[1fr]')}>
                <div className="overflow-hidden" inert={shut || undefined}>
                  <div className="px-4 pb-4 sm:px-5">
                    <p className="text-pretty text-[15px] leading-relaxed text-muted-foreground">
                      <Cited text={x.answer} t={t} />
                    </p>
                    {!!x.sources?.length && (
                      <div className="mt-3 flex flex-wrap gap-1.5">
                        {x.sources.map((n) => {
                          const r = results.find((y) => y.id === n);
                          return r ? (
                            <a key={n} href={r.href ?? '#'} className="inline-flex h-8 max-w-full items-center gap-1.5 rounded-full border border-border bg-background pl-0.5 pr-2.5 text-[12px] text-muted-foreground hover:text-foreground max-sm:h-11">
                              <Favicon site={r.site} id={r.id} />
                              <span className="truncate">{r.site}</span>
                            </a>
                          ) : null;
                        })}
                      </div>
                    )}
                  </div>
                </div>
              </div>
            </li>
          );
        })}
        {busy && (
          <li className="asr-up flex items-center gap-2 rounded-2xl border border-dashed border-border-strong px-5 py-4 text-[13.5px] text-muted-foreground" role="status">
            <Icon name="loader" size={15} className="animate-spin" />
            {t.thinking}
          </li>
        )}
      </ol>
      {related.length > 0 && (
        <Reveal delay={100}>
          <p className="mb-2 mt-6 px-1 text-[12.5px] font-medium text-subtle-foreground">{t.related}</p>
          <ul className="divide-y divide-border rounded-2xl border border-border bg-card">
            {related.map((r) => (
              <li key={r}>
                <button type="button" disabled={busy} onClick={() => ask(r)} className="flex min-h-12 w-full items-center gap-3 px-4 py-2.5 text-left text-[14px] text-foreground transition-colors hover:bg-accent/60 disabled:opacity-50 sm:px-5">
                  <Icon name="corner-down-left" size={15} className="shrink-0 rotate-180 text-muted-foreground" />
                  <span className="flex-1">{r}</span>
                  <Icon name="plus" size={15} className="shrink-0 text-muted-foreground" />
                </button>
              </li>
            ))}
          </ul>
        </Reveal>
      )}
      <form
        onSubmit={(e) => {
          e.preventDefault();
          ask(q);
        }}
        className="sticky bottom-4 mt-5 flex items-center gap-2 rounded-2xl border border-border bg-card/95 p-1.5 pl-4 shadow-[var(--highlight),var(--shadow-lg)] backdrop-blur focus-within:border-[color-mix(in_oklab,var(--primary)_50%,var(--border))] focus-within:shadow-[var(--highlight),var(--shadow-lg),0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)]"
      >
        <label className="min-w-0 flex-1">
          <span className="sr-only">{t.follow}</span>
          <input value={q} onChange={(e) => setQ(e.target.value)} placeholder={t.follow} className="h-11 w-full bg-transparent text-[15px] text-foreground outline-none placeholder:text-subtle-foreground focus-visible:outline-none" />
        </label>
        <button type="submit" disabled={!q.trim() || busy} className="inline-flex h-11 shrink-0 items-center gap-1.5 rounded-xl bg-primary px-4 text-[13.5px] font-medium text-primary-foreground disabled:bg-[color-mix(in_oklab,var(--foreground)_8%,var(--card))] disabled:text-subtle-foreground">
          {t.ask}
        </button>
      </form>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */

/**
 * AI search: results with an AI overview on top and citations that jump to
 * their result, a research run that turns into a report, an answer as a
 * comparison table with a recommendation and caveats, and a thread of
 * follow-up questions with related ones to tap.
 */
export function AiSearch({ variant = 'overview', labels, ...props }: AiSearchProps) {
  const t = useTexts(TEXTS, labels);
  const p: P = { ...props, t };
  const body = (() => {
    switch (variant) {
      case 'research-report':
        return <ResearchReport {...p} />;
      case 'structured-answer':
        return <StructuredAnswer {...p} />;
      case 'follow-up-thread':
        return <FollowUpThread key={JSON.stringify(props.turns)} {...p} />;
      default:
        return <Overview {...p} />;
    }
  })();
  return (
    <>
      <Style />
      {body}
    </>
  );
}

Frequent questions

How do I install AI search?

With aiw add @ai-website/ai-search. The command puts components/ui/ai-search.tsx into your project, the code is yours from then on. It brings along icon, reveal and motion. It needs no further npm packages.

Which variants does AI search have?

4 variants, Results with AI overview, Research report, Answer as a table and Follow-up thread. You pick one with the variant prop, for example variant="research-report".

Can I translate the texts of AI search?

Yes. AI search carries 29 texts of its own in English and German, such as “Search”, “AI overview” and “Show more”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with AI search?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find AI search with aiw_components and install it with aiw_add @ai-website/ai-search. Name the variant in your prompt, for example variant="overview", and the assistant writes your content straight into the props.

Can I adjust AI search without code?

Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching AiSearch code to copy.

Often searched

Jump to

Copied to the clipboard