niceui

Gradient Blob New

A soft morphing gradient blob that speeds up while thinking and settles when done, from 16 to 160px.

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-gradient-blob.json
02

Or copy the source below. Dependencies: none.

Source

components/niceui/ai/thinking/GradientBlob.tsx
import { useEffect, useState } from 'react';
import { cn } from '@/lib/cn';

export type BlobMode = 'idle' | 'thinking' | 'done';

const css = `
@keyframes th-blob-morph {
  0%, 100% { border-radius: 42% 58% 63% 37% / 41% 44% 56% 59%; transform: rotate(0deg) scale(1) }
  33% { border-radius: 63% 37% 41% 59% / 58% 42% 58% 42%; transform: rotate(120deg) scale(1.04) }
  66% { border-radius: 37% 63% 52% 48% / 46% 61% 39% 54%; transform: rotate(240deg) scale(.97) }
}
@keyframes th-blob-hue { to { filter: hue-rotate(40deg) } }
@media (prefers-reduced-motion: reduce) { .th-blob * { animation: none !important } }
`;

export type GradientBlobProps = {
  mode?: BlobMode;
  size?: number;
  className?: string;
};

const PALETTE: Record<BlobMode, [string, string, string]> = {
  idle: ['#334155', '#475569', '#1e293b'],
  thinking: ['#38bdf8', '#818cf8', '#22d3ee'],
  done: ['#34d399', '#2dd4bf', '#a3e635'],
};

/** A soft morphing blob that speeds up while the agent thinks and settles when it's done. Works from 16 to 160px. */
export function GradientBlob({ mode = 'thinking', size = 96, className }: GradientBlobProps) {
  const [a, b, c] = PALETTE[mode];
  const speed = mode === 'thinking' ? 3.2 : mode === 'done' ? 14 : 9;
  const blur = Math.max(1, size / 22);
  return (
    <span role="img" aria-label={mode === 'thinking' ? 'Thinking' : mode === 'done' ? 'Done' : 'Idle'} className={cn('th-blob relative inline-block shrink-0', className)} style={{ width: size, height: size }}>
      <style>{css}</style>
      <span
        aria-hidden
        className="absolute inset-[8%] transition-[background] duration-700"
        style={{
          background: `radial-gradient(circle at 30% 30%, ${a}, transparent 60%), radial-gradient(circle at 70% 65%, ${b}, transparent 62%), radial-gradient(circle at 50% 50%, ${c}, ${c})`,
          filter: `blur(${blur * 0.4}px)`,
          animation: `th-blob-morph ${speed}s ease-in-out infinite${mode === 'thinking' ? ', th-blob-hue 2.4s ease-in-out infinite alternate' : ''}`,
          boxShadow: mode === 'idle' ? 'none' : `0 0 ${size / 3}px ${a}55`,
        }}
      />
      <span
        aria-hidden
        className="absolute inset-[22%] rounded-full opacity-60 mix-blend-screen"
        style={{ background: 'radial-gradient(circle at 35% 30%, rgba(255,255,255,.55), transparent 55%)', filter: `blur(${blur}px)` }}
      />
    </span>
  );
}

export function GradientBlobDemo() {
  const [mode, setMode] = useState<BlobMode>('thinking');
  const [auto, setAuto] = useState(true);
  useEffect(() => {
    if (!auto || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    const order: BlobMode[] = ['thinking', 'done', 'idle'];
    const id = window.setInterval(() => setMode((m) => order[(order.indexOf(m) + 1) % order.length]), 3200);
    return () => clearInterval(id);
  }, [auto]);
  return (
    <div className="flex w-full flex-col items-center gap-6">
      <GradientBlob mode={mode} size={150} />
      <div className="flex gap-1.5" role="group" aria-label="Mode">
        {(['idle', 'thinking', 'done'] as BlobMode[]).map((m) => (
          <button
            key={m}
            onClick={() => {
              setAuto(false);
              setMode(m);
            }}
            aria-pressed={mode === m}
            className={cn('focus-ring rounded-full px-3 py-1 text-xs capitalize transition-colors', mode === m ? 'bg-white/10 text-ink-50' : 'text-ink-400 hover:bg-white/5 hover:text-ink-200')}
          >
            {m}
          </button>
        ))}
      </div>
      <div className="flex items-center gap-3 rounded-full bg-ink-900 py-1.5 pl-1.5 pr-3 shadow-card">
        <GradientBlob mode={mode} size={22} />
        <span className="text-sm text-ink-200">{mode === 'thinking' ? 'Thinking…' : mode === 'done' ? 'Answer ready' : 'Ask me anything'}</span>
      </div>
      <div className="flex items-end gap-3">
        {[16, 24, 40, 64].map((s) => (
          <GradientBlob key={s} mode={mode} size={s} />
        ))}
      </div>
    </div>
  );
}