niceui

Answer Engine New

A search-style answer: source cards first, then a streamed answer with inline citations and related questions.

ReactTailwind CSSTypeScriptAI AgentsMIT · Free
Live preview

Installation

01

Install with the shadcn CLI. The NiceUI theme comes along the first time.

$ npx shadcn@latest add https://niceui.dev/r/ai-answer-engine.json
02

Or copy the source below. Dependencies: lucide-react.

Source

components/niceui/ai/AnswerEngine.tsx
import { Fragment, useEffect, useState } from 'react';
import { CornerDownRight, Layers, RotateCcw, Sparkles } from 'lucide-react';
import { cn } from '@/lib/cn';

export type AnswerSource = { title: string; domain: string; color: string };
export type AnswerData = { query: string; sources: AnswerSource[]; answer: string; related: string[] };

const reduced = () => typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;

/** Renders **bold** and [n] citation chips; hovering a chip highlights its source. */
function Rich({ text, onCite }: { text: string; onCite: (n: number | null) => void }) {
  return (
    <>
      {text.split(/(\*\*[^*]+\*\*|\[\d+\])/g).map((part, i) => {
        const cite = part.match(/^\[(\d+)\]$/);
        if (cite)
          return (
            <button
              key={i}
              onMouseEnter={() => onCite(+cite[1])}
              onMouseLeave={() => onCite(null)}
              onFocus={() => onCite(+cite[1])}
              onBlur={() => onCite(null)}
              aria-label={`Source ${cite[1]}`}
              className="focus-ring mx-0.5 inline-flex h-4 min-w-4 -translate-y-px items-center justify-center rounded bg-brand-500/15 px-1 align-middle font-mono text-[10px] font-medium text-brand-200 transition-colors hover:bg-brand-500/30"
            >
              {cite[1]}
            </button>
          );
        if (part.startsWith('**') && part.endsWith('**') && part.length > 4) return <strong key={i} className="font-medium text-ink-50">{part.slice(2, -2)}</strong>;
        return <Fragment key={i}>{part}</Fragment>;
      })}
    </>
  );
}

export type AnswerEngineProps = { data: AnswerData; onAsk?: (question: string) => void; className?: string };

/** Search-style answer: sources first, then a streamed answer with inline citations and related questions. */
export function AnswerEngine({ data, onAsk, className }: AnswerEngineProps) {
  const [phase, setPhase] = useState<'search' | 'answer' | 'done'>('search');
  const [shown, setShown] = useState(0);
  const [count, setCount] = useState(0);
  const [hover, setHover] = useState<number | null>(null);
  const tokens = data.answer.split(/(\s+)/).filter(Boolean);

  useEffect(() => {
    if (reduced()) {
      setShown(data.sources.length);
      setCount(Infinity);
      setPhase('done');
      return;
    }
    setPhase('search');
    setShown(0);
    setCount(0);
    const ids = data.sources.map((_, i) => window.setTimeout(() => setShown(i + 1), 180 + i * 190));
    ids.push(window.setTimeout(() => setPhase('answer'), 260 + data.sources.length * 190));
    return () => ids.forEach(clearTimeout);
  }, [data]);

  useEffect(() => {
    if (phase !== 'answer') return;
    if (count >= tokens.length) {
      setPhase('done');
      return;
    }
    const id = window.setTimeout(() => setCount((c) => c + 2), 22 + Math.random() * 30);
    return () => clearTimeout(id);
  }, [phase, count, tokens.length]);

  const text = tokens.slice(0, count).join('');

  return (
    <div className={cn('w-full rounded-2xl bg-ink-900 p-5 shadow-card', className)}>
      <h3 className="text-[17px] font-medium leading-snug tracking-[-0.01em] text-ink-50">{data.query}</h3>
      <p className="mt-1.5 flex items-center gap-1.5 text-xs text-ink-500">
        <Layers className="h-3.5 w-3.5" />
        {phase === 'search' ? <span className="shimmer-text">Searching {data.sources.length} sources…</span> : <span>{data.sources.length} sources</span>}
      </p>

      <div className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-4">
        {data.sources.map((s, i) => (
          <a
            key={s.domain}
            href="#"
            onClick={(e) => e.preventDefault()}
            className={cn(
              'focus-ring group min-w-0 rounded-xl p-2.5 ring-1 transition-all duration-300',
              i < shown ? 'translate-y-0 opacity-100' : 'translate-y-1 opacity-0',
              hover === i + 1 ? 'bg-brand-500/10 ring-brand-400/40' : 'bg-white/[0.03] ring-white/5 hover:bg-white/[0.05]',
            )}
          >
            <span className="flex items-center gap-1.5">
              <span className="grid h-4 w-4 shrink-0 place-items-center rounded text-[9px] font-semibold text-ink-950" style={{ background: s.color }}>
                {s.domain[0].toUpperCase()}
              </span>
              <span className="truncate text-[11px] text-ink-500">{s.domain}</span>
              <span className="ml-auto font-mono text-[10px] text-ink-600">{i + 1}</span>
            </span>
            <span className="mt-1.5 line-clamp-2 block text-xs leading-snug text-ink-200">{s.title}</span>
          </a>
        ))}
      </div>

      <div className="mt-5 flex items-center gap-1.5 text-xs font-medium text-ink-300">
        <Sparkles className="h-3.5 w-3.5 text-brand-400" /> Answer
      </div>
      <div className="mt-2 min-h-[96px] text-sm leading-relaxed text-ink-300">
        {phase === 'search' ? (
          <div className="space-y-2 pt-1">
            {[92, 100, 74].map((w) => (
              <div key={w} className="h-2.5 animate-pulse rounded-full bg-white/[0.05]" style={{ width: `${w}%` }} />
            ))}
          </div>
        ) : (
          <p>
            <Rich text={text} onCite={setHover} />
            {phase === 'answer' && <span aria-hidden className="ml-0.5 inline-block h-[1.05em] w-[7px] translate-y-[3px] animate-blink rounded-[2px] bg-brand-400" />}
          </p>
        )}
      </div>

      <div className={cn('grid transition-all duration-500 ease-smooth', phase === 'done' ? 'grid-rows-[1fr] opacity-100' : 'pointer-events-none grid-rows-[0fr] opacity-0')}>
        <div className="overflow-hidden">
        <div className="mt-4 border-t border-white/5 pt-3">
        <p className="mb-1 text-xs font-medium text-ink-400">Related</p>
        {data.related.map((q) => (
          <button key={q} onClick={() => onAsk?.(q)} className="focus-ring group flex w-full items-center gap-2.5 rounded-lg px-2 py-2 text-left text-sm text-ink-300 transition-colors hover:bg-white/[0.04] hover:text-ink-50">
            <CornerDownRight className="h-3.5 w-3.5 shrink-0 text-ink-600 transition-colors group-hover:text-brand-400" />
            <span className="min-w-0 flex-1 truncate">{q}</span>
          </button>
        ))}
        </div>
        </div>
      </div>
    </div>
  );
}

const SOURCES: AnswerSource[][] = [
  [
    { title: 'Why ceramic electrolytes crack at scale', domain: 'voltreview.io', color: '#7dd3fc' },
    { title: 'Inside a pilot line for solid-state cells', domain: 'gridnotes.dev', color: '#fcd34d' },
    { title: 'Dendrites, explained with pictures', domain: 'cellmath.org', color: '#86efac' },
    { title: 'The cost curve nobody talks about', domain: 'kilowatt.news', color: '#f9a8d4' },
  ],
  [
    { title: 'Who ships solid-state cells first?', domain: 'kilowatt.news', color: '#f9a8d4' },
    { title: 'Pilot lines, by the numbers', domain: 'gridnotes.dev', color: '#fcd34d' },
    { title: 'Automaker battery roadmaps compared', domain: 'drivetrain.fyi', color: '#c4b5fd' },
    { title: 'What “sample B” cells really mean', domain: 'voltreview.io', color: '#7dd3fc' },
  ],
];

const DATA: AnswerData[] = [
  {
    query: 'Why are solid-state batteries so hard to manufacture?',
    sources: SOURCES[0],
    answer:
      'Solid-state cells replace the liquid electrolyte with a **ceramic or polymer layer** [1], which is safer and denser but far less forgiving. Every layer has to stay in perfect contact while the cell swells and shrinks, and microscopic gaps let **lithium dendrites** grow [3]. Thin ceramic sheets also crack when stacked at speed [2], so yields on pilot lines stay low and costs stay high [4].',
    related: ['Which companies are closest to mass production?', 'How long do solid-state batteries last?', 'Are they actually safer than lithium-ion?'],
  },
  {
    query: 'Which companies are closest to mass production?',
    sources: SOURCES[1],
    answer:
      'A handful of cell makers now ship **B-sample cells** to automakers for road testing [4], the step before production tooling [2]. Most roadmaps point to limited production runs first, usually in premium models [3], with broad rollout only once yields and costs improve [1].',
    related: ['Why are solid-state batteries so hard to manufacture?', 'What is a B-sample cell?', 'How much would a solid-state car cost?'],
  },
];

export function AnswerEngineDemo() {
  const [i, setI] = useState(0);
  const ask = (q: string) => setI(DATA.findIndex((d) => d.query === q) >= 0 ? DATA.findIndex((d) => d.query === q) : (i + 1) % DATA.length);
  return (
    <div className="mx-auto w-full max-w-2xl space-y-3">
      <AnswerEngine data={DATA[i]} onAsk={ask} />
      <button onClick={() => setI((n) => (n + 1) % DATA.length)} className="focus-ring mx-auto flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs text-ink-400 transition-colors hover:bg-white/5 hover:text-ink-100">
        <RotateCcw className="h-3.5 w-3.5" /> Ask another
      </button>
    </div>
  );
}