shadimage
Systaliko UI
blockai-conversation-loopsource: ok · hit

Ai Conversation Loop

ai conversation timeline, animates infinitely, to showcase your ai product.

npx shadcn add "https://systaliko-ui.vercel.app/r/ai-conversation-loop.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
animationmotion

Registry dependencies

https://systaliko-ui.vercel.app/r/typing-text

Files1 · source cached 10/14/2026, 11:45:12 AM

registry/blocks/ai-conversation-loop/index.tsx
'use client';

import { TypingText, TypingTextProps } from '@/components/systaliko-ui/text/typing-text';
import {
  motion,
  AnimatePresence,
  HTMLMotionProps,
  MotionConfig,
} from 'motion/react';
import React from 'react';

export type PhasesT =
  | 'idle'
  | 'typing-user'
  | 'thinking'
  | 'sources'
  | 'typing-answer'
  | 'complete'
  | 'exiting';

export interface TimingConfig {
  pauseBeforeStart: number;
  thinking: number;
  sourcesStagger: number;
  pauseBeforeAnswer: number;
  holdComplete: number;
}

const DEFAULT_TIMING: TimingConfig = {
  pauseBeforeStart: 400,
  thinking: 1600,
  sourcesStagger: 450,
  pauseBeforeAnswer: 600,
  holdComplete: 3500,
};

interface AiConversationLoopContextType {
  phase: PhasesT;
  setPhase: (phase: PhasesT) => void;
  cycleKey: number;
}

const AiConversationLoopContext = React.createContext<
  AiConversationLoopContextType | undefined
>(undefined);

export function useAiConversationLoopContext() {
  const context = React.useContext(AiConversationLoopContext);
  if (!context) {
    throw new Error(
      'AiConversationLoop components must be used within a <AiConversationLoop>',
    );
  }
  return context;
}

interface AiConversationLoopProps extends HTMLMotionProps<'div'> {
  userTiming?: Partial<TimingConfig>;
}

export function AiConversationLoop({
  userTiming = DEFAULT_TIMING,
  children,
  ...props
}: AiConversationLoopProps) {
  const [cycleKey, setCycleKey] = React.useState(0);
  const [phase, setPhase] = React.useState<PhasesT>('idle');

  React.useEffect(() => {
    let timeoutId: ReturnType<typeof setTimeout>;
    let intervalId: ReturnType<typeof setInterval>;

    if (phase === 'idle') {
      timeoutId = setTimeout(() => {
        setPhase('typing-user');
      }, userTiming.pauseBeforeStart);
    } else if (phase === 'thinking') {
      timeoutId = setTimeout(() => {
        setPhase('sources');
      }, userTiming.thinking);
    } else if (phase === 'sources') {
      intervalId = setInterval(() => {
        clearInterval(intervalId);
        timeoutId = setTimeout(() => {
          setPhase('typing-answer');
        }, userTiming.pauseBeforeAnswer);
      }, userTiming.sourcesStagger);
    } else if (phase === 'complete') {
      timeoutId = setTimeout(() => {
        setPhase('exiting'); // Trigger exit animation
      }, userTiming.holdComplete);
    } else if (phase === 'exiting') {
      timeoutId = setTimeout(() => {
        setCycleKey((k) => k + 1); // Trigger restart
        setPhase('idle'); // Enter idle state to pause before restarting
      }, 500); // Wait for exit animation to complete
    }

    return () => {
      clearTimeout(timeoutId);
      clearInterval(intervalId);
    };
  }, [phase, cycleKey, userTiming]);

  return (
    <AiConversationLoopContext.Provider
      value={{
        phase,
        setPhase,
        cycleKey,
      }}
    >
      <MotionConfig transition={{ type: 'spring', bounce: 0, duration: 0.5 }}>
        <AnimatePresence mode="wait">
          {phase !== 'idle' && phase !== 'exiting' && (
            <motion.div
              key={cycleKey}
              initial={{ opacity: 0, y: 15, filter: 'blur(8px)' }}
              animate={{ opacity: 1, y: 0, filter: 'blur(0px)' }}
              exit={{ opacity: 0, y: -15, filter: 'blur(8px)' }}
              {...props}
            >
              {children}
            </motion.div>
          )}
        </AnimatePresence>
      </MotionConfig>
    </AiConversationLoopContext.Provider>
  );
}

export function UserMessage({ text, speed, ...props }: TypingTextProps) {
  const { setPhase, cycleKey } = useAiConversationLoopContext();
  return (
    <TypingText
      key={`user-${cycleKey}`}
      text={text}
      speed={speed}
      onComplete={() => setPhase('thinking')}
      {...props}
    />
  );
}

export function AiBlock({ ...props }: HTMLMotionProps<'div'>) {
  const { phase } = useAiConversationLoopContext();

  return (
    <motion.div
      initial={false}
      animate={phase !== 'typing-user' ? 'visible' : 'hidden'}
      variants={{
        hidden: { opacity: 0, y: 10, filter: 'blur(8px)' },
        visible: {
          opacity: 1,
          y: 0,
          filter: 'blur(0px)',
          transition: { delay: 0.1 },
        },
      }}
      {...props}
    />
  );
}

export function AiStatus({ ...props }: HTMLMotionProps<'div'>) {
  const { phase } = useAiConversationLoopContext();
  return (
    <AnimatePresence mode="popLayout">
      {phase === 'thinking' && (
        <motion.div
          key="thinking"
          layout
          initial={{ opacity: 0, y: 8, filter: 'blur(4px)' }}
          animate={{ opacity: 1, y: 0, filter: 'blur(0px)' }}
          exit={{ opacity: 0, y: -8, filter: 'blur(4px)' }}
          {...props}
        />
      )}
    </AnimatePresence>
  );
}

export function AiSources({ ...props }: HTMLMotionProps<'div'>) {
  const { phase } = useAiConversationLoopContext();
  const isVisible =
    phase === 'sources' ||
    phase === 'typing-answer' ||
    phase === 'complete' ||
    phase === 'exiting';

  return (
    <AnimatePresence mode="popLayout">
      {isVisible && (
        <motion.div
          key="sources"
          layout
          initial={{ opacity: 0, y: 8, filter: 'blur(4px)' }}
          animate={{ opacity: 1, y: 0, filter: 'blur(0px)' }}
          exit={{ opacity: 0, y: -8, filter: 'blur(4px)' }}
          {...props}
        />
      )}
    </AnimatePresence>
  );
}
export function AiAnswer({
  startTyping = true,
  onTypingComplete,
  text,
  speed = 22,
  children,
  ...props
}: TypingTextProps & {
  startTyping?: boolean;
  onTypingComplete?: () => void;
}) {
  const [isComplete, setIsComplete] = React.useState(false);

  React.useEffect(() => {
    if (startTyping) setIsComplete(false);
  }, [startTyping]);

  const handleComplete = React.useCallback(() => {
    setIsComplete(true);
    onTypingComplete?.();
  }, [onTypingComplete]);

  return (
    <div className="overflow-hidden">
      <div className="rounded-2xl rounded-tl-none border bg-card text-card-foreground px-4 py-3 text-xs shadow-sm">
        <p className="leading-relaxed">
          {startTyping && text && text !== '' ? (
            <TypingText
              text={text}
              speed={speed}
              onComplete={handleComplete}
              {...props}
            />
          ) : (
            <>
              As a Large Language Model, I am unable to provide real-time
              information.
            </>
          )}
        </p>

        <motion.div
          initial={false}
          animate={{ opacity: isComplete ? 1 : 0, y: isComplete ? 0 : 4 }}
          transition={{ type: 'spring', duration: 0.35, bounce: 0 }}
          className="mt-3 flex items-center justify-between border-t pt-3"
        >
          {children}
        </motion.div>
      </div>
    </div>
  );
}

export function AiResult({
  text,
  speed = 22,
  ...props
}: TypingTextProps & HTMLMotionProps<'div'>) {
  const { phase, setPhase } = useAiConversationLoopContext();

  const isVisible =
    phase === 'typing-answer' || phase === 'complete' || phase === 'exiting';
  return (
    <AnimatePresence mode="popLayout">
      {isVisible && (
        <motion.div
          key="answer"
          layout
          initial={{ opacity: 0, y: 10, filter: 'blur(4px)' }}
          animate={{ opacity: 1, y: 0, filter: 'blur(0px)' }}
          exit={{ opacity: 0, y: -10, filter: 'blur(4px)' }}
        >
          <AiAnswer
            startTyping={
              phase === 'typing-answer' ||
              phase === 'complete' ||
              phase === 'exiting'
            }
            text={text}
            speed={speed}
            onTypingComplete={() => {
              if (phase === 'typing-answer') {
                setPhase('complete');
              }
            }}
            {...props}
          />
        </motion.div>
      )}
    </AnimatePresence>
  );
}

More from Systaliko UI

Browse all components in this library →