{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"chat-bento-card","type":"registry:block","title":"Chat bento card","description":"A Bento chat interface card that mimics live chat interactions, great for embedding conversational UI demos in a Bento grid.","dependencies":["motion"],"registryDependencies":["avatar"],"files":[{"path":"registry/100xui/blocks/chat-bento-card/components/chat-bento-card.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport {\n  AnimatePresence,\n  motion,\n  stagger,\n  type HTMLMotionProps,\n} from \"motion/react\";\nimport { CheckCheckIcon } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { Avatar, AvatarImage, AvatarFallback } from \"./ui/avatar\";\n\ninterface OutgoingMessageType {\n  type: \"outgoing\";\n  content: React.ReactNode;\n}\n\ninterface IncomingMessageType {\n  type: \"incoming\";\n  content: React.ReactNode;\n  from: string;\n  avatarProps: { src?: string | Blob; fallback: string };\n}\n\nexport type ChatMessageType = OutgoingMessageType | IncomingMessageType;\n\nconst MotionAvatar = motion.create(Avatar);\n\ninterface ChatBentoCardProps extends React.ComponentPropsWithoutRef<\"div\"> {\n  messages: ChatMessageType[];\n  staggerMessagesInSec?: number;\n  typingDurationInSec?: number;\n  initialMessagesCount?: number;\n  timestamp?: React.ReactNode;\n  viewOptions?: IntersectionObserverInit;\n  startChatOn?: \"hover\" | \"view\";\n}\n\nconst POP_LAYOUT_SYNC_DURATION = 0.2;\n\nexport function ChatBentoCard({\n  messages,\n  className,\n  startChatOn = \"view\",\n  initialMessagesCount = 1,\n  staggerMessagesInSec = 2,\n  typingDurationInSec = 0.75,\n  timestamp = \"Today\",\n  onMouseEnter,\n  onMouseLeave,\n  viewOptions = { threshold: 1 },\n  ...rest\n}: ChatBentoCardProps) {\n  const id = React.useId();\n\n  const [totalMessagesAnimated, setTotalMessagesAnimated] = React.useState(0);\n  const [typing, setTyping] = React.useState(true);\n  const intervalRef = React.useRef<NodeJS.Timeout>(undefined);\n\n  const chatContainerRef = React.useRef<HTMLDivElement>(null);\n  const upcomingMessage = React.useRef<number>(initialMessagesCount);\n\n  const startAnimation = React.useCallback(() => {\n    const advanceMessage = () => {\n      if (messages[upcomingMessage.current]) {\n        setTotalMessagesAnimated((prev) => prev + 1);\n        upcomingMessage.current += 1;\n        setTyping(true);\n      } else {\n        clearInterval(intervalRef.current);\n      }\n    };\n    advanceMessage();\n    intervalRef.current = setInterval(\n      advanceMessage,\n      staggerMessagesInSec * 1000,\n    );\n  }, [messages, staggerMessagesInSec]);\n\n  const resetAnimation = () => {\n    setTotalMessagesAnimated(0);\n    setTyping(true);\n    upcomingMessage.current = 0;\n    clearInterval(intervalRef.current);\n  };\n\n  React.useEffect(() => {\n    if (startChatOn !== \"view\") return;\n\n    const observer = new IntersectionObserver(([entry]) => {\n      if (entry.isIntersecting) startAnimation();\n      else resetAnimation();\n    }, viewOptions);\n\n    observer.observe(chatContainerRef.current!);\n\n    return () => {\n      clearInterval(intervalRef.current);\n      observer.disconnect();\n    };\n  }, [startAnimation, startChatOn, viewOptions]);\n\n  return (\n    <div\n      ref={chatContainerRef}\n      onMouseEnter={(e) => {\n        if (startChatOn === \"hover\") startAnimation();\n        onMouseEnter?.(e);\n      }}\n      onMouseLeave={(e) => {\n        if (startChatOn === \"hover\") resetAnimation();\n        onMouseLeave?.(e);\n      }}\n      className={cn(\n        \"bg-card relative flex w-full flex-col items-center justify-end gap-y-2 overflow-hidden p-2 text-sm\",\n        className,\n      )}\n      aria-hidden\n      {...rest}\n    >\n      <motion.div\n        className=\"text-muted-foreground pt-2 text-xs\"\n        layoutId={`${id}-timestamp`}\n        {...(totalMessagesAnimated === 0 && {\n          transition: { layout: { delay: (messages.length - 1) * 0.1 } },\n        })}\n      >\n        {timestamp}\n      </motion.div>\n\n      <div className=\"flex w-full flex-1 flex-col justify-end\">\n        <motion.div\n          layoutId={id + \"chat-box-initial\"}\n          className=\"relative mb-2 flex flex-col gap-y-2\"\n          {...(totalMessagesAnimated === 0 && {\n            transition: { layout: { delay: (messages.length - 1) * 0.1 } },\n          })}\n        >\n          {messages\n            .slice(0, initialMessagesCount)\n            .map((eachMessage, i) =>\n              eachMessage.type === \"outgoing\" ? (\n                <OutgoingMessage\n                  initial={false}\n                  Message={eachMessage as OutgoingMessageType}\n                  key={`${id}-initial-message-${i}`}\n                />\n              ) : (\n                <IncomingMessage\n                  Message={eachMessage as IncomingMessageType}\n                  key={`${id}-initial-message-${i}`}\n                  typingDurationInSec={typingDurationInSec}\n                  initial={false}\n                />\n              ),\n            )}\n        </motion.div>\n\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          {totalMessagesAnimated !== 0 && (\n            <motion.div\n              key={id + \"chat-box-animate\"}\n              className=\"space-y-2\"\n              exit=\"exit\"\n              variants={{\n                exit: {\n                  transition: { delayChildren: stagger(0.1) },\n                },\n              }}\n            >\n              {messages\n                .slice(\n                  initialMessagesCount,\n                  totalMessagesAnimated + initialMessagesCount,\n                )\n                .map((message, index) =>\n                  message.type === \"outgoing\" ? (\n                    <OutgoingMessage\n                      Message={message}\n                      key={`${id}-animate-message-${index}`}\n                      id={`${id}-animate-message-${index}`}\n                      variants={{\n                        exit: { opacity: 0, scale: 0.95 },\n                      }}\n                    />\n                  ) : (\n                    <IncomingMessage\n                      {...(index + 1 === totalMessagesAnimated && { typing })}\n                      setTyping={setTyping}\n                      Message={message}\n                      key={`${id}-animate-message-${index}`}\n                      id={`${id}-animate-message-${index}`}\n                      typingDurationInSec={typingDurationInSec}\n                      variants={{\n                        exit: { opacity: 0, scale: 0.95 },\n                      }}\n                    />\n                  ),\n                )}\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n    </div>\n  );\n}\n\ninterface IncomingMessageProps extends HTMLMotionProps<\"div\"> {\n  Message: IncomingMessageType;\n  id?: string;\n  typing?: boolean;\n  typingDurationInSec: number;\n  setTyping?: React.Dispatch<React.SetStateAction<boolean>>;\n}\n\nfunction IncomingMessage({\n  Message,\n  typing = false,\n  setTyping,\n  id,\n  typingDurationInSec,\n  initial = true,\n  ...rest\n}: IncomingMessageProps) {\n  React.useEffect(() => {\n    const timeout = setTimeout(\n      () => setTyping?.(false),\n      typingDurationInSec * 1000,\n    );\n    return () => clearTimeout(timeout);\n  }, [setTyping, typingDurationInSec]);\n\n  return (\n    <motion.div\n      {...rest}\n      className=\"grid w-full origin-bottom-left grid-cols-[auto_1fr] gap-x-2 gap-y-1\"\n    >\n      <MotionAvatar\n        {...(initial && { initial: { opacity: 0, scale: 0.95 } })}\n        animate={{\n          opacity: 1,\n          scale: 1,\n          transition: { delay: POP_LAYOUT_SYNC_DURATION },\n        }}\n        {...(id && { layoutId: id + \"-avatar\" })}\n        className=\"mt-auto size-9\"\n      >\n        <AvatarImage src={Message.avatarProps.src} alt=\"\" />\n        <AvatarFallback>{Message.avatarProps.fallback}</AvatarFallback>\n      </MotionAvatar>\n\n      <motion.div\n        {...(initial && { initial: { opacity: 0 } })}\n        animate={{\n          opacity: 1,\n          transition: { delay: POP_LAYOUT_SYNC_DURATION },\n        }}\n        layoutId={id + \"-from\"}\n        className=\"text-muted-foreground col-start-2 row-start-2 origin-bottom-left text-xs\"\n      >\n        {Message.from}\n      </motion.div>\n\n      <AnimatePresence mode=\"popLayout\">\n        {!typing ? (\n          <motion.div\n            key=\"content\"\n            {...(id && { layoutId: id + \"-content\" })}\n            exit={{ opacity: 0 }}\n            {...(initial && { initial: { opacity: 0 } })}\n            animate={{\n              opacity: 1,\n              transition: { delay: POP_LAYOUT_SYNC_DURATION },\n            }}\n            className=\"bg-secondary text-secondary-foreground mt-auto size-fit max-w-3/4 origin-bottom-left rounded-xl rounded-bl-md px-3 py-2 shadow-md\"\n          >\n            {Message.content}\n          </motion.div>\n        ) : (\n          <motion.div\n            key=\"typing\"\n            {...(id && { layoutId: id + \"-typing\" })}\n            exit={{\n              opacity: 0,\n              transition: { duration: POP_LAYOUT_SYNC_DURATION },\n            }}\n            {...(initial && { initial: { opacity: 0, scale: 0.95 } })}\n            animate={{\n              opacity: 1,\n              scale: 1,\n              transition: { delay: POP_LAYOUT_SYNC_DURATION },\n            }}\n            className=\"bg-secondary text-secondary-foreground col-start-2 mt-auto size-fit shrink-0 origin-bottom-left rounded-xl rounded-bl-md px-3 py-2\"\n          >\n            <TypingIndicatorDots className=\"h-[1lh] w-5 shrink-0\" />\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </motion.div>\n  );\n}\n\nconst TypingIndicatorDots = (props: React.ComponentProps<\"svg\">) => (\n  <svg viewBox=\"0 0 100 24\" {...props}>\n    <circle r=\"12\" fill=\"currentColor\" cx=\"12\" cy=\"12\"></circle>\n    <circle r=\"12\" fill=\"currentColor\" cx=\"44\" cy=\"12\"></circle>\n    <circle r=\"12\" fill=\"currentColor\" cx=\"76\" cy=\"12\"></circle>\n  </svg>\n);\n\ninterface OutgoingMessageProps extends HTMLMotionProps<\"div\"> {\n  Message: OutgoingMessageType;\n}\n\nfunction OutgoingMessage({ Message, id, ...rest }: OutgoingMessageProps) {\n  return (\n    <motion.div\n      initial={{ opacity: 0, scale: 0.95 }}\n      animate={{\n        opacity: 1,\n        scale: 1,\n        transition: { delay: POP_LAYOUT_SYNC_DURATION },\n      }}\n      layoutId={id}\n      {...rest}\n      className=\"flex flex-col items-end gap-y-1\"\n    >\n      <div className=\"bg-muted text-muted-foreground max-w-3/4 rounded-xl rounded-br-md px-3 py-2 shadow-md\">\n        {Message.content}\n      </div>\n      <CheckCheckIcon className=\"text-muted-foreground size-4\" />\n    </motion.div>\n  );\n}\n","type":"registry:component"}]}