niceui

AI Sidebar

A collapsible copilot panel beside a document, with suggestions that apply directly to the text.

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-ai-sidebar.json
02

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

Source

components/niceui/ai/AISidebar.tsx
import { useState } from 'react';
import { ArrowUp, Bold, Check, Italic, List, PanelRight, Sparkles, Wand2, X } from 'lucide-react';
import { cn } from '@/lib/cn';

type Block = { id: string; text: string; kind?: 'p' | 'tldr' | 'list' };
type Suggestion = { id: string; title: string; target: string; before?: string; after: string; apply: (b: Block[]) => Block[] };

const DOC: Block[] = [
  { id: 'intro', text: 'This quarter was a quarter in which the team worked on a lot of different things across many different areas of the product, and we made progress on most of them.' },
  { id: 'growth', text: 'Weekly active teams grew 38% to 1,640, driven mostly by the new onboarding checklist and the Slack integration launch in August.' },
  { id: 'perf', text: 'Mistakes were made in the September billing migration, and some customers were double-charged for about four hours.' },
];

const SUGGESTIONS: Suggestion[] = [
  {
    id: 's1', title: 'Tighten the intro', target: 'intro',
    before: 'This quarter was a quarter in which the team worked on a lot…',
    after: 'Q3 was about focus: we shipped onboarding, Slack and faster billing.',
    apply: (b) => b.map((x) => (x.id === 'intro' ? { ...x, text: 'Q3 was about focus: we shipped a new onboarding flow, the Slack integration and a faster billing system.' } : x)),
  },
  {
    id: 's2', title: 'Own the billing incident', target: 'perf',
    before: 'Mistakes were made in the September billing migration…',
    after: 'We double-charged some customers for four hours. All were refunded within a day.',
    apply: (b) => b.map((x) => (x.id === 'perf' ? { ...x, text: 'Our September billing migration double-charged 212 customers for about four hours. Every affected customer was refunded within 24 hours, and we added a dry-run step to future migrations.' } : x)),
  },
  {
    id: 's3', title: 'Add a TL;DR', target: 'top',
    after: 'Teams +38%, Slack launched, billing incident resolved.',
    apply: (b) => [{ id: 'tldr', kind: 'tldr', text: 'TL;DR — Weekly active teams up 38%, Slack integration launched, billing incident fully refunded.' }, ...b],
  },
  {
    id: 's4', title: 'Add next steps', target: 'end',
    after: 'Q4 priorities: SSO, usage-based pricing, mobile beta.',
    apply: (b) => [...b, { id: 'next', kind: 'list', text: 'Ship SSO for Enterprise|Launch usage-based pricing|Open the mobile beta to 500 teams' }],
  },
];

export type AISidebarProps = { title?: string; defaultOpen?: boolean; onApply?: (suggestionId: string) => void; className?: string };

export function AISidebar({ title = 'Q3 Product Update', defaultOpen = true, onApply, className }: AISidebarProps) {
  const [blocks, setBlocks] = useState(DOC);
  const [open, setOpen] = useState(defaultOpen);
  const [applied, setApplied] = useState<string[]>([]);
  const [dismissed, setDismissed] = useState<string[]>([]);
  const [flash, setFlash] = useState<string | null>(null);
  const [hover, setHover] = useState<string | null>(null);

  const apply = (s: Suggestion) => {
    setBlocks((b) => s.apply(b));
    setApplied((a) => [...a, s.id]);
    onApply?.(s.id);
    const id = s.target === 'top' ? 'tldr' : s.target === 'end' ? 'next' : s.target;
    setFlash(id);
    window.setTimeout(() => setFlash((f) => (f === id ? null : f)), 1400);
  };
  const reset = () => {
    setBlocks(DOC);
    setApplied([]);
    setDismissed([]);
  };
  const remaining = SUGGESTIONS.filter((s) => !applied.includes(s.id) && !dismissed.includes(s.id)).length;

  return (
    <div className={cn('relative flex h-[480px] w-full overflow-hidden rounded-2xl bg-ink-900 shadow-card', className)}>
      <div className="flex min-w-0 flex-1 flex-col">
        <div className="flex items-center gap-1 border-b border-white/5 px-3 py-2">
          {[Bold, Italic, List].map((Icon, i) => (
            <button key={i} className="focus-ring flex h-7 w-7 items-center justify-center rounded-md text-ink-400 transition-colors hover:bg-white/5 hover:text-ink-100" aria-label="Format">
              <Icon className="h-3.5 w-3.5" />
            </button>
          ))}
          <span className="ml-2 text-xs text-ink-500">Saved</span>
          <button
            onClick={() => setOpen((o) => !o)}
            className={cn('focus-ring ml-auto flex h-7 items-center gap-1.5 rounded-md px-2 text-xs font-medium transition-colors', open ? 'bg-brand-500/15 text-brand-200' : 'text-ink-300 hover:bg-white/5')}
          >
            <PanelRight className="h-3.5 w-3.5" /> Copilot
            {remaining > 0 && !open && <span className="flex h-4 min-w-[16px] animate-scale-in items-center justify-center rounded-full bg-brand-500 px-1 text-[10px] text-white">{remaining}</span>}
          </button>
        </div>
        <article className="flex-1 overflow-y-auto px-6 py-6 sm:px-10">
          <p className="text-xs text-ink-500">Product · Updated just now</p>
          <h2 className="mt-1 text-2xl font-medium text-ink-50">{title}</h2>
          <div className="mt-5 space-y-3">
            {blocks.map((b) => {
              const target = !!hover && SUGGESTIONS.find((s) => s.id === hover)?.target === b.id;
              const cls = cn(
                '-mx-2 rounded-lg px-2 py-1 text-sm leading-relaxed transition-all duration-700',
                flash === b.id ? 'bg-brand-500/15 text-ink-50' : 'text-ink-300',
                target && 'bg-amber-500/10 shadow-[inset_2px_0_0_theme(colors.amber.400)]',
              );
              if (b.kind === 'list')
                return (
                  <ul key={b.id} className={cn(cls, 'animate-fade-up space-y-1')}>
                    {b.text.split('|').map((t) => <li key={t} className="flex gap-2"><span className="mt-2 h-1 w-1 shrink-0 rounded-full bg-brand-400" />{t}</li>)}
                  </ul>
                );
              return (
                <p key={b.id + b.text.length} className={cn(cls, 'animate-fade-in', b.kind === 'tldr' && 'animate-fade-up border-l-2 border-brand-400 bg-brand-500/[0.06] font-medium')}>
                  {b.text}
                </p>
              );
            })}
          </div>
        </article>
      </div>
      <aside className={cn('absolute inset-y-0 right-0 z-10 flex flex-col overflow-hidden border-l border-white/5 bg-ink-950/95 backdrop-blur transition-[width] duration-500 ease-smooth sm:relative', open ? 'w-72' : 'w-0')}>
        <div className="flex w-72 items-center justify-between px-4 py-3">
          <span className="flex items-center gap-2 text-sm font-medium text-ink-50"><Sparkles className="h-4 w-4 text-brand-300" /> Suggestions</span>
          <button onClick={reset} className="focus-ring rounded-md px-1.5 py-0.5 text-[11px] text-ink-500 transition-colors hover:text-ink-200">Reset</button>
        </div>
        <div className="w-72 flex-1 space-y-2 overflow-y-auto px-3 pb-3">
          {SUGGESTIONS.filter((s) => !dismissed.includes(s.id)).map((s, i) => {
            const done = applied.includes(s.id);
            return (
              <div
                key={s.id}
                onPointerEnter={() => !done && setHover(s.id)}
                onPointerLeave={() => setHover(null)}
                className={cn('animate-fade-up rounded-xl bg-ink-900 p-3 shadow-hairline transition-all duration-300', done ? 'opacity-60' : 'hover:bg-ink-850')}
                style={{ animationDelay: `${i * 80}ms` }}
              >
                <p className="flex items-center gap-1.5 text-xs font-medium text-ink-100"><Wand2 className="h-3.5 w-3.5 text-brand-300" />{s.title}</p>
                <div className={cn('grid transition-all duration-300 ease-smooth', done ? 'grid-rows-[0fr] opacity-0' : 'grid-rows-[1fr]')}>
                  <div className="overflow-hidden">
                    {s.before && <p className="mt-2 text-xs text-rose-300/70 line-through decoration-rose-400/50">{s.before}</p>}
                    <p className="mt-1.5 text-xs text-emerald-300">{s.after}</p>
                    <div className="mt-2.5 flex gap-1.5">
                      <button onClick={() => { apply(s); setHover(null); }} className="focus-ring flex h-7 items-center gap-1 rounded-md bg-ink-50 px-2.5 text-xs font-medium text-ink-950 transition-all hover:bg-white active:scale-95"><Check className="h-3 w-3" /> Apply</button>
                      <button onClick={() => setDismissed((d) => [...d, s.id])} className="focus-ring flex h-7 items-center gap-1 rounded-md px-2 text-xs text-ink-400 transition-colors hover:bg-white/5 hover:text-ink-100"><X className="h-3 w-3" /> Dismiss</button>
                    </div>
                  </div>
                </div>
                {done && <p className="mt-1 flex animate-fade-in items-center gap-1 text-[11px] text-emerald-400"><Check className="h-3 w-3" /> Applied to document</p>}
              </div>
            );
          })}
          {remaining === 0 && <p className="animate-fade-in px-2 py-4 text-center text-xs text-ink-500">All caught up — your update reads great.</p>}
        </div>
        <div className="w-72 p-3">
          <div className="flex items-center gap-2 rounded-xl bg-ink-900 py-1.5 pl-3 pr-1.5 shadow-hairline focus-within:shadow-glow">
            <input placeholder="Ask Copilot to edit…" className="min-w-0 flex-1 bg-transparent text-xs text-ink-100 outline-none placeholder:text-ink-500" />
            <button className="focus-ring flex h-6 w-6 items-center justify-center rounded-md bg-brand-500 text-white transition-transform active:scale-90" aria-label="Send"><ArrowUp className="h-3.5 w-3.5" /></button>
          </div>
        </div>
      </aside>
    </div>
  );
}

export function AISidebarDemo() {
  return <AISidebar />;
}