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
Variants
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
- Studio
- All 4 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The AiSearchProps interface from components/ui/ai-search.tsx, with the comments from the code.
variantoptionalAiSearchVariantoverviewresearch-reportstructured-answerfollow-up-thread
titleoptionalstringqueryoptionalstringsummaryoptionalstringoverview and follow-up-thread: the AI summary, [n] cites a result
resultsoptionalSearchResult[]confidenceoptional'high' | 'medium' | 'low'overview, research-report and structured-answer: how sure the answer is
queriesoptionalResearchQuery[]research-report
sectionsoptionalReportSection[]readoptionalnumberresearch-report: sources read in total
tableoptionalAnswerTablestructured-answer
caveatsoptionalstring[]structured-answer: things to keep in mind, shown under the table
turnsoptionalThreadTurn[]follow-up-thread
relatedoptionalstring[]onSearchoptional(query: string) => voidoverview 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
| Key | English | German |
|---|---|---|
| search | English Search | German Suchen |
| overview | English AI overview | German KI-Übersicht |
| more | English Show more | German Mehr zeigen |
| less | English Show less | German Weniger zeigen |
| results | English Results | German Ergebnisse |
| source | English Source {n} | German Quelle {n} |
| confidence_high | English Sources agree | German Quellen sind sich einig |
| confidence_medium | English Sources partly disagree | German Quellen sind sich teils uneinig |
| confidence_low | English Few sources, check carefully | German Wenige Quellen, genau prüfen |
| aiNote | English Generated from the results below. It can be wrong. | German Aus den Ergebnissen unten erstellt. Kann falsch sein. |
| helpful | English Helpful | German Hilfreich |
| notHelpful | English Not helpful | German Nicht hilfreich |
| planning | English Planning searches | German Plant die Suchen |
| reading | English Reading sources | German Liest Quellen |
| writing | English Writing the report | German Schreibt den Bericht |
| readLabel | English sources read | German Quellen gelesen |
| contents | English Contents | German Inhalt |
| report | English Report | German Bericht |
| recommended | English Fits best | German Passt am besten |
| caveats | English Keep in mind | German Gut zu wissen |
| sources | English Sources | German Quellen |
| sourceCount | English {n} sources | German {n} Quellen |
| follow | English Ask a follow-up | German Nachfrage stellen |
| ask | English Ask | German Fragen |
| related | English People also ask | German Andere fragen auch |
| thinking | English Searching | German Sucht |
| collapse | English Collapse answer | German Antwort einklappen |
| expand | English Expand answer | German Antwort aufklappen |
| fallback | English 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
'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.