AI in User Interfaces
Integrating AI into user interfaces requires thoughtful design beyond raw API calls. The best AI-powered UIs feel instant, transparent, and reliable — masking the latency and complexity of LLM inference behind familiar interaction patterns.
This guide covers practical patterns for embedding AI into web interfaces: search augmentation, autocomplete suggestions, chat layouts, generative form outputs, and the UX principles that make AI feel natural rather than gimmicky.
AI search combines traditional full-text search with semantic understanding. Users get relevant results even when their query doesn't contain exact keywords. The key is debouncing, streaming results, and handling loading states.
| 1 | // AI-powered search with debouncing and streaming |
| 2 | "use client"; |
| 3 | import { useState, useCallback, useRef } from "react"; |
| 4 | |
| 5 | function AISearch() { |
| 6 | const [query, setQuery] = useState(""); |
| 7 | const [results, setResults] = useState<SearchResult[]>([]); |
| 8 | const [isSearching, setIsSearching] = useState(false); |
| 9 | const [aiSummary, setAiSummary] = useState(""); |
| 10 | const abortRef = useRef<AbortController | null>(null); |
| 11 | |
| 12 | const search = useCallback(async (q: string) => { |
| 13 | if (q.length < 2) { setResults([]); setAiSummary(""); return; } |
| 14 | |
| 15 | // Cancel previous request |
| 16 | abortRef.current?.abort(); |
| 17 | abortRef.current = new AbortController(); |
| 18 | setIsSearching(true); |
| 19 | |
| 20 | try { |
| 21 | const res = await fetch("/api/ai-search", { |
| 22 | method: "POST", |
| 23 | headers: { "Content-Type": "application/json" }, |
| 24 | body: JSON.stringify({ query: q }), |
| 25 | signal: abortRef.current.signal, |
| 26 | }); |
| 27 | |
| 28 | const reader = res.body!.getReader(); |
| 29 | const decoder = new TextDecoder(); |
| 30 | let summary = ""; |
| 31 | |
| 32 | while (true) { |
| 33 | const { done, value } = await reader.read(); |
| 34 | if (done) break; |
| 35 | const chunk = decoder.decode(value, { stream: true }); |
| 36 | const lines = chunk.split("\n"); |
| 37 | |
| 38 | for (const line of lines) { |
| 39 | if (line.startsWith("data: ")) { |
| 40 | try { |
| 41 | const event = JSON.parse(line.slice(6)); |
| 42 | if (event.type === "results") setResults(event.data); |
| 43 | if (event.type === "summary") { |
| 44 | summary += event.text; |
| 45 | setAiSummary(summary); |
| 46 | } |
| 47 | } catch {} |
| 48 | } |
| 49 | } |
| 50 | } |
| 51 | } catch (e) { |
| 52 | if ((e as Error).name !== "AbortError") console.error(e); |
| 53 | } finally { |
| 54 | setIsSearching(false); |
| 55 | } |
| 56 | }, []); |
| 57 | |
| 58 | // Debounce search |
| 59 | const timeoutRef = useRef<NodeJS.Timeout>(); |
| 60 | const handleChange = (value: string) => { |
| 61 | setQuery(value); |
| 62 | clearTimeout(timeoutRef.current); |
| 63 | timeoutRef.current = setTimeout(() => search(value), 300); |
| 64 | }; |
| 65 | |
| 66 | return ( |
| 67 | <div> |
| 68 | <input value={query} onChange={(e) => handleChange(e.target.value)} placeholder="Search..." /> |
| 69 | {aiSummary && <div className="ai-summary">{aiSummary}</div>} |
| 70 | {results.map((r) => <SearchResultCard key={r.id} result={r} />)} |
| 71 | </div> |
| 72 | ); |
| 73 | } |
info
AI autocomplete goes beyond prefix matching to understand intent and context. It uses streaming to show suggestions as they're generated, with keyboard navigation for selection.
| 1 | // AI autocomplete with keyboard navigation |
| 2 | "use client"; |
| 3 | import { useState, useRef, useEffect } from "react"; |
| 4 | |
| 5 | function AIAutocomplete() { |
| 6 | const [input, setInput] = useState(""); |
| 7 | const [suggestions, setSuggestions] = useState<string[]>([]); |
| 8 | const [selectedIndex, setSelectedIndex] = useState(-1); |
| 9 | const [isStreaming, setIsStreaming] = useState(false); |
| 10 | const inputRef = useRef<HTMLInputElement>(null); |
| 11 | |
| 12 | useEffect(() => { |
| 13 | if (input.length < 3) { setSuggestions([]); return; } |
| 14 | |
| 15 | const controller = new AbortController(); |
| 16 | const timer = setTimeout(async () => { |
| 17 | setIsStreaming(true); |
| 18 | const res = await fetch("/api/autocomplete", { |
| 19 | method: "POST", |
| 20 | headers: { "Content-Type": "application/json" }, |
| 21 | body: JSON.stringify({ prompt: input }), |
| 22 | signal: controller.signal, |
| 23 | }); |
| 24 | |
| 25 | const reader = res.body!.getReader(); |
| 26 | const decoder = new TextDecoder(); |
| 27 | const items: string[] = []; |
| 28 | |
| 29 | while (true) { |
| 30 | const { done, value } = await reader.read(); |
| 31 | if (done) break; |
| 32 | const chunk = decoder.decode(value, { stream: true }); |
| 33 | const lines = chunk.split("\n"); |
| 34 | |
| 35 | for (const line of lines) { |
| 36 | if (line.startsWith("data: ")) { |
| 37 | try { |
| 38 | const { suggestion } = JSON.parse(line.slice(6)); |
| 39 | items.push(suggestion); |
| 40 | setSuggestions([...items]); |
| 41 | } catch {} |
| 42 | } |
| 43 | } |
| 44 | } |
| 45 | setIsStreaming(false); |
| 46 | }, 250); |
| 47 | |
| 48 | return () => { clearTimeout(timer); controller.abort(); }; |
| 49 | }, [input]); |
| 50 | |
| 51 | const handleKeyDown = (e: React.KeyboardEvent) => { |
| 52 | if (e.key === "ArrowDown") { |
| 53 | e.preventDefault(); |
| 54 | setSelectedIndex((i) => Math.min(i + 1, suggestions.length - 1)); |
| 55 | } else if (e.key === "ArrowUp") { |
| 56 | e.preventDefault(); |
| 57 | setSelectedIndex((i) => Math.max(i - 1, -1)); |
| 58 | } else if (e.key === "Enter" && selectedIndex >= 0) { |
| 59 | setInput(suggestions[selectedIndex]); |
| 60 | setSuggestions([]); |
| 61 | } else if (e.key === "Escape") { |
| 62 | setSuggestions([]); |
| 63 | } |
| 64 | }; |
| 65 | |
| 66 | return ( |
| 67 | <div className="relative"> |
| 68 | <input ref={inputRef} value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={handleKeyDown} /> |
| 69 | {suggestions.length > 0 && ( |
| 70 | <ul className="dropdown"> |
| 71 | {suggestions.map((s, i) => ( |
| 72 | <li key={i} className={i === selectedIndex ? "selected" : ""}>{s}</li> |
| 73 | ))} |
| 74 | </ul> |
| 75 | )} |
| 76 | </div> |
| 77 | ); |
| 78 | } |
best practice
Chat interfaces are the most common AI UI pattern. Key considerations include message layout, streaming rendering, action buttons, copy functionality, and conversation management.
| 1 | // Production chat component with all features |
| 2 | "use client"; |
| 3 | import { useState, useRef, useEffect } from "react"; |
| 4 | |
| 5 | interface Message { |
| 6 | id: string; |
| 7 | role: "user" | "assistant"; |
| 8 | content: string; |
| 9 | timestamp: Date; |
| 10 | } |
| 11 | |
| 12 | function ChatInterface() { |
| 13 | const [messages, setMessages] = useState<Message[]>([]); |
| 14 | const [input, setInput] = useState(""); |
| 15 | const [isStreaming, setIsStreaming] = useState(false); |
| 16 | const messagesEndRef = useRef<HTMLDivElement>(null); |
| 17 | |
| 18 | // Auto-scroll to bottom |
| 19 | useEffect(() => { |
| 20 | messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); |
| 21 | }, [messages]); |
| 22 | |
| 23 | const sendMessage = async () => { |
| 24 | if (!input.trim() || isStreaming) return; |
| 25 | |
| 26 | const userMsg: Message = { |
| 27 | id: crypto.randomUUID(), |
| 28 | role: "user", |
| 29 | content: input.trim(), |
| 30 | timestamp: new Date(), |
| 31 | }; |
| 32 | |
| 33 | setMessages((prev) => [...prev, userMsg]); |
| 34 | setInput(""); |
| 35 | setIsStreaming(true); |
| 36 | |
| 37 | const aiMsg: Message = { |
| 38 | id: crypto.randomUUID(), |
| 39 | role: "assistant", |
| 40 | content: "", |
| 41 | timestamp: new Date(), |
| 42 | }; |
| 43 | setMessages((prev) => [...prev, aiMsg]); |
| 44 | |
| 45 | const res = await fetch("/api/chat", { |
| 46 | method: "POST", |
| 47 | headers: { "Content-Type": "application/json" }, |
| 48 | body: JSON.stringify({ messages: [...messages, userMsg] }), |
| 49 | }); |
| 50 | |
| 51 | const reader = res.body!.getReader(); |
| 52 | const decoder = new TextDecoder(); |
| 53 | let content = ""; |
| 54 | |
| 55 | while (true) { |
| 56 | const { done, value } = await reader.read(); |
| 57 | if (done) break; |
| 58 | content += decoder.decode(value, { stream: true }); |
| 59 | setMessages((prev) => |
| 60 | prev.map((m) => (m.id === aiMsg.id ? { ...m, content } : m)) |
| 61 | ); |
| 62 | } |
| 63 | setIsStreaming(false); |
| 64 | }; |
| 65 | |
| 66 | return ( |
| 67 | <div className="flex flex-col h-full"> |
| 68 | <div className="flex-1 overflow-y-auto p-4 space-y-4"> |
| 69 | {messages.map((msg) => ( |
| 70 | <MessageBubble key={msg.id} message={msg} /> |
| 71 | ))} |
| 72 | <div ref={messagesEndRef} /> |
| 73 | </div> |
| 74 | <div className="border-t p-4 flex gap-2"> |
| 75 | <input value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={(e) => e.key === "Enter" && sendMessage()} className="flex-1" placeholder="Ask anything..." disabled={isStreaming} /> |
| 76 | <button onClick={sendMessage} disabled={isStreaming || !input.trim()}> |
| 77 | {isStreaming ? "Generating..." : "Send"} |
| 78 | </button> |
| 79 | </div> |
| 80 | </div> |
| 81 | ); |
| 82 | } |
info
Generative UI goes beyond text — the AI generates interactive UI components (charts, forms, code blocks, data tables) as part of its response. This transforms chat from Q&A into a rich application interface.
| 1 | // AI generates structured UI components |
| 2 | interface AIToolCall { |
| 3 | type: "chart" | "table" | "code" | "form" | "text"; |
| 4 | data: Record<string, unknown>; |
| 5 | } |
| 6 | |
| 7 | // Server: detect tool calls and return structured data |
| 8 | // Client: render appropriate component based on type |
| 9 | function GenerativeMessage({ content, toolCalls }: { content: string; toolCalls?: AIToolCall[] }) { |
| 10 | return ( |
| 11 | <div className="message space-y-4"> |
| 12 | <ReactMarkdown>{content}</ReactMarkdown> |
| 13 | {toolCalls?.map((call, i) => { |
| 14 | switch (call.type) { |
| 15 | case "chart": |
| 16 | return <AIChart key={i} data={call.data} />; |
| 17 | case "table": |
| 18 | return <DataTable key={i} rows={call.data.rows} columns={call.data.columns} />; |
| 19 | case "code": |
| 20 | return <CodeBlock key={i} language={call.data.language} code={call.data.code} />; |
| 21 | case "form": |
| 22 | return <GeneratedForm key={i} fields={call.data.fields} />; |
| 23 | default: |
| 24 | return null; |
| 25 | } |
| 26 | })} |
| 27 | </div> |
| 28 | ); |
| 29 | } |
| 30 | |
| 31 | // Example: AI generates a data visualization |
| 32 | function AIChart({ data }: { data: { labels: string[]; values: number[] } }) { |
| 33 | return ( |
| 34 | <div className="chart-container p-4 border rounded-lg"> |
| 35 | <ResponsiveContainer width="100%" height={300}> |
| 36 | <BarChart data={data.labels.map((l, i) => ({ name: l, value: data.values[i] }))}> |
| 37 | <XAxis dataKey="name" /> |
| 38 | <YAxis /> |
| 39 | <Bar dataKey="value" fill="#00FF41" /> |
| 40 | </BarChart> |
| 41 | </ResponsiveContainer> |
| 42 | </div> |
| 43 | ); |
| 44 | } |
best practice
Great AI UIs follow specific UX principles that manage user expectations, handle uncertainty, and build trust. These patterns are critical for production adoption.
Loading States
Show clear loading indicators for the full lifecycle: request sent, first token received, streaming complete. Users should never wonder if something is happening.
| 1 | // Loading state management |
| 2 | type LoadingPhase = "idle" | "connecting" | "streaming" | "complete" | "error"; |
| 3 | |
| 4 | function LoadingIndicator({ phase }: { phase: LoadingPhase }) { |
| 5 | switch (phase) { |
| 6 | case "connecting": |
| 7 | return <div className="flex items-center gap-2 text-sm text-gray-400"><Spinner /> Connecting...</div>; |
| 8 | case "streaming": |
| 9 | return <div className="flex items-center gap-2 text-sm text-gray-400"><PulsingDot /> Generating...</div>; |
| 10 | case "error": |
| 11 | return <div className="text-sm text-red-400">Failed. <button onClick={retry}>Retry</button></div>; |
| 12 | default: |
| 13 | return null; |
| 14 | } |
| 15 | } |
Confidence Indicators
Show AI confidence through visual cues. Dim uncertain responses, highlight high-confidence answers, and provide source attribution.
| 1 | // Confidence-based styling |
| 2 | function AIResponse({ text, confidence, sources }: Props) { |
| 3 | const opacity = Math.max(0.5, confidence); |
| 4 | return ( |
| 5 | <div style={{ opacity }} className="p-4 rounded-lg border"> |
| 6 | <p>{text}</p> |
| 7 | {sources.length > 0 && ( |
| 8 | <div className="mt-2 text-xs text-gray-500"> |
| 9 | Sources: {sources.map((s) => ( |
| 10 | <a key={s.url} href={s.url} className="underline">{s.title}</a> |
| 11 | ))} |
| 12 | </div> |
| 13 | )} |
| 14 | </div> |
| 15 | ); |
| 16 | } |
Input Prompts & Suggestions
Guide users with example prompts, category chips, and context-aware suggestions. The empty state is the most important moment for AI UX.
| 1 | // Suggestion chips for empty chat state |
| 2 | const suggestions = [ |
| 3 | "Explain this code", |
| 4 | "Write a unit test", |
| 5 | "Debug this error", |
| 6 | "Optimize performance", |
| 7 | ]; |
| 8 | |
| 9 | function EmptyState({ onSelect }: { onSelect: (s: string) => void }) { |
| 10 | return ( |
| 11 | <div className="flex flex-col items-center justify-center h-full gap-6"> |
| 12 | <h2 className="text-xl font-semibold">How can I help?</h2> |
| 13 | <div className="flex flex-wrap gap-2 justify-center"> |
| 14 | {suggestions.map((s) => ( |
| 15 | <button key={s} onClick={() => onSelect(s)} className="px-4 py-2 border rounded-full text-sm hover:bg-gray-800 transition"> |
| 16 | {s} |
| 17 | </button> |
| 18 | ))} |
| 19 | </div> |
| 20 | </div> |
| 21 | ); |
| 22 | } |
warning