Back

Chat bento card

A Bento chat interface card that mimics live chat interactions, great for embedding conversational UI demos in a Bento grid.

Current Theme:
Chat bento card

A Bento chat interface card that mimics live chat interactions, great for embedding conversational UI demos in a Bento grid.

API usage

components/chat-bento-card.demo.tsx
import {
  ChatBentoCard,
  type ChatMessageType,
} from "@/components/chat-bento-card";

export function ChatBentoCardDemo() {
  return (
    <div className="border-border bg-card box-content w-full max-w-96 rounded-md border px-1 pt-0">
      <ChatBentoCard
        className="aspect-square"
        viewOptions={{ threshold: 0.75 }}
        messages={messages}
      />
      <div className="p-3">
        <div className="text-card-foreground mb-2 font-medium">
          Chat bento card
        </div>
        <p className="text-muted-foreground text-sm">
          A Bento chat interface card that mimics live chat interactions, great
          for embedding conversational UI demos in a Bento grid.
        </p>
      </div>
    </div>
  );
}

const messages: ChatMessageType[] = [
  {
    type: "outgoing",
    content: (
      <>
        Have you seen <span className="font-medium">100xUI</span>? The motion
        components are truly impressive.
      </>
    ),
  },
  {
    type: "incoming",
    from: "Founder",
    avatarProps: {
      src: "/bento-card-avatar.png",
      fallback: "FO",
    },
    content: (
      <p>
        Agreed. That level of craftsmanship is exactly what we need. We should
        hire its creator for our team.
      </p>
    ),
  },
  {
    type: "outgoing",

    content: <>My thoughts exactly. Let's make an offer.</>,
  },
  {
    type: "incoming",
    from: "Founder",
    avatarProps: {
      src: "/bento-card-avatar.png",
      fallback: "FO",
    },
    content: <p>Definitely. Get the process started.</p>,
  },
];

Installation

CLI (recommended)

1
Run the command below to add the component to your project.
It will also generate the required base stylesheet if one doesn't already exist and guide you through setting up the import alias @/components/... if it isn't already configured.
pnpm dlx shadcn@latest add https://100xui.com/components/chat-bento-card.json

Manual

1
Install the following dependencies.
pnpm add @radix-ui/react-avatar clsx motion tailwind-merge
2
Copy and paste the following code into your project.
100xui
components/chat-bento-card.tsx
"use client";

import * as React from "react";
import {
  AnimatePresence,
  motion,
  stagger,
  type HTMLMotionProps,
} from "motion/react";
import { CheckCheckIcon } from "lucide-react";

import { cn } from "@/lib/utils";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";

interface OutgoingMessageType {
  type: "outgoing";
  content: React.ReactNode;
}

interface IncomingMessageType {
  type: "incoming";
  content: React.ReactNode;
  from: string;
  avatarProps: { src?: string | Blob; fallback: string };
}

export type ChatMessageType = OutgoingMessageType | IncomingMessageType;

const MotionAvatar = motion.create(Avatar);

interface ChatBentoCardProps extends React.ComponentPropsWithoutRef<"div"> {
  messages: ChatMessageType[];
  staggerMessagesInSec?: number;
  typingDurationInSec?: number;
  initialMessagesCount?: number;
  timestamp?: React.ReactNode;
  viewOptions?: IntersectionObserverInit;
  startChatOn?: "hover" | "view";
}

const POP_LAYOUT_SYNC_DURATION = 0.2;

export function ChatBentoCard({
  messages,
  className,
  startChatOn = "view",
  initialMessagesCount = 1,
  staggerMessagesInSec = 2,
  typingDurationInSec = 0.75,
  timestamp = "Today",
  onMouseEnter,
  onMouseLeave,
  viewOptions = { threshold: 1 },
  ...rest
}: ChatBentoCardProps) {
  const id = React.useId();

  const [totalMessagesAnimated, setTotalMessagesAnimated] = React.useState(0);
  const [typing, setTyping] = React.useState(true);
  const intervalRef = React.useRef<NodeJS.Timeout>(undefined);

  const chatContainerRef = React.useRef<HTMLDivElement>(null);
  const upcomingMessage = React.useRef<number>(initialMessagesCount);

  const startAnimation = React.useCallback(() => {
    const advanceMessage = () => {
      if (messages[upcomingMessage.current]) {
        setTotalMessagesAnimated((prev) => prev + 1);
        upcomingMessage.current += 1;
        setTyping(true);
      } else {
        clearInterval(intervalRef.current);
      }
    };
    advanceMessage();
    intervalRef.current = setInterval(
      advanceMessage,
      staggerMessagesInSec * 1000,
    );
  }, [messages, staggerMessagesInSec]);

  const resetAnimation = () => {
    setTotalMessagesAnimated(0);
    setTyping(true);
    upcomingMessage.current = 0;
    clearInterval(intervalRef.current);
  };

  React.useEffect(() => {
    if (startChatOn !== "view") return;

    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) startAnimation();
      else resetAnimation();
    }, viewOptions);

    observer.observe(chatContainerRef.current!);

    return () => {
      clearInterval(intervalRef.current);
      observer.disconnect();
    };
  }, [startAnimation, startChatOn, viewOptions]);

  return (
    <div
      ref={chatContainerRef}
      onMouseEnter={(e) => {
        if (startChatOn === "hover") startAnimation();
        onMouseEnter?.(e);
      }}
      onMouseLeave={(e) => {
        if (startChatOn === "hover") resetAnimation();
        onMouseLeave?.(e);
      }}
      className={cn(
        "bg-card relative flex w-full flex-col items-center justify-end gap-y-2 overflow-hidden p-2 text-sm",
        className,
      )}
      aria-hidden
      {...rest}
    >
      <motion.div
        className="text-muted-foreground pt-2 text-xs"
        layoutId={`${id}-timestamp`}
        {...(totalMessagesAnimated === 0 && {
          transition: { layout: { delay: (messages.length - 1) * 0.1 } },
        })}
      >
        {timestamp}
      </motion.div>

      <div className="flex w-full flex-1 flex-col justify-end">
        <motion.div
          layoutId={id + "chat-box-initial"}
          className="relative mb-2 flex flex-col gap-y-2"
          {...(totalMessagesAnimated === 0 && {
            transition: { layout: { delay: (messages.length - 1) * 0.1 } },
          })}
        >
          {messages
            .slice(0, initialMessagesCount)
            .map((eachMessage, i) =>
              eachMessage.type === "outgoing" ? (
                <OutgoingMessage
                  initial={false}
                  Message={eachMessage as OutgoingMessageType}
                  key={`${id}-initial-message-${i}`}
                />
              ) : (
                <IncomingMessage
                  Message={eachMessage as IncomingMessageType}
                  key={`${id}-initial-message-${i}`}
                  typingDurationInSec={typingDurationInSec}
                  initial={false}
                />
              ),
            )}
        </motion.div>

        <AnimatePresence mode="popLayout" initial={false}>
          {totalMessagesAnimated !== 0 && (
            <motion.div
              key={id + "chat-box-animate"}
              className="space-y-2"
              exit="exit"
              variants={{
                exit: {
                  transition: { delayChildren: stagger(0.1) },
                },
              }}
            >
              {messages
                .slice(
                  initialMessagesCount,
                  totalMessagesAnimated + initialMessagesCount,
                )
                .map((message, index) =>
                  message.type === "outgoing" ? (
                    <OutgoingMessage
                      Message={message}
                      key={`${id}-animate-message-${index}`}
                      id={`${id}-animate-message-${index}`}
                      variants={{
                        exit: { opacity: 0, scale: 0.95 },
                      }}
                    />
                  ) : (
                    <IncomingMessage
                      {...(index + 1 === totalMessagesAnimated && { typing })}
                      setTyping={setTyping}
                      Message={message}
                      key={`${id}-animate-message-${index}`}
                      id={`${id}-animate-message-${index}`}
                      typingDurationInSec={typingDurationInSec}
                      variants={{
                        exit: { opacity: 0, scale: 0.95 },
                      }}
                    />
                  ),
                )}
            </motion.div>
          )}
        </AnimatePresence>
      </div>
    </div>
  );
}

interface IncomingMessageProps extends HTMLMotionProps<"div"> {
  Message: IncomingMessageType;
  id?: string;
  typing?: boolean;
  typingDurationInSec: number;
  setTyping?: React.Dispatch<React.SetStateAction<boolean>>;
}

function IncomingMessage({
  Message,
  typing = false,
  setTyping,
  id,
  typingDurationInSec,
  initial = true,
  ...rest
}: IncomingMessageProps) {
  React.useEffect(() => {
    const timeout = setTimeout(
      () => setTyping?.(false),
      typingDurationInSec * 1000,
    );
    return () => clearTimeout(timeout);
  }, [setTyping, typingDurationInSec]);

  return (
    <motion.div
      {...rest}
      className="grid w-full origin-bottom-left grid-cols-[auto_1fr] gap-x-2 gap-y-1"
    >
      <MotionAvatar
        {...(initial && { initial: { opacity: 0, scale: 0.95 } })}
        animate={{
          opacity: 1,
          scale: 1,
          transition: { delay: POP_LAYOUT_SYNC_DURATION },
        }}
        {...(id && { layoutId: id + "-avatar" })}
        className="mt-auto size-9"
      >
        <AvatarImage src={Message.avatarProps.src} alt="" />
        <AvatarFallback>{Message.avatarProps.fallback}</AvatarFallback>
      </MotionAvatar>

      <motion.div
        {...(initial && { initial: { opacity: 0 } })}
        animate={{
          opacity: 1,
          transition: { delay: POP_LAYOUT_SYNC_DURATION },
        }}
        layoutId={id + "-from"}
        className="text-muted-foreground col-start-2 row-start-2 origin-bottom-left text-xs"
      >
        {Message.from}
      </motion.div>

      <AnimatePresence mode="popLayout">
        {!typing ? (
          <motion.div
            key="content"
            {...(id && { layoutId: id + "-content" })}
            exit={{ opacity: 0 }}
            {...(initial && { initial: { opacity: 0 } })}
            animate={{
              opacity: 1,
              transition: { delay: POP_LAYOUT_SYNC_DURATION },
            }}
            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"
          >
            {Message.content}
          </motion.div>
        ) : (
          <motion.div
            key="typing"
            {...(id && { layoutId: id + "-typing" })}
            exit={{
              opacity: 0,
              transition: { duration: POP_LAYOUT_SYNC_DURATION },
            }}
            {...(initial && { initial: { opacity: 0, scale: 0.95 } })}
            animate={{
              opacity: 1,
              scale: 1,
              transition: { delay: POP_LAYOUT_SYNC_DURATION },
            }}
            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"
          >
            <TypingIndicatorDots className="h-[1lh] w-5 shrink-0" />
          </motion.div>
        )}
      </AnimatePresence>
    </motion.div>
  );
}

const TypingIndicatorDots = (props: React.ComponentProps<"svg">) => (
  <svg viewBox="0 0 100 24" {...props}>
    <circle r="12" fill="currentColor" cx="12" cy="12"></circle>
    <circle r="12" fill="currentColor" cx="44" cy="12"></circle>
    <circle r="12" fill="currentColor" cx="76" cy="12"></circle>
  </svg>
);

interface OutgoingMessageProps extends HTMLMotionProps<"div"> {
  Message: OutgoingMessageType;
}

function OutgoingMessage({ Message, id, ...rest }: OutgoingMessageProps) {
  return (
    <motion.div
      initial={{ opacity: 0, scale: 0.95 }}
      animate={{
        opacity: 1,
        scale: 1,
        transition: { delay: POP_LAYOUT_SYNC_DURATION },
      }}
      layoutId={id}
      {...rest}
      className="flex flex-col items-end gap-y-1"
    >
      <div className="bg-muted text-muted-foreground max-w-3/4 rounded-xl rounded-br-md px-3 py-2 shadow-md">
        {Message.content}
      </div>
      <CheckCheckIcon className="text-muted-foreground size-4" />
    </motion.div>
  );
}
3
Finally, Update the import paths to match your project setup.

API reference

<ChatBentoCard/>

PropsTypeDescriptionDefault value
messages
(
  | {
      type: "outgoing";
      content: React.ReactNode;
    }
  | {
      type: "incoming";
      content: React.ReactNode;
      from: string;
      avatarProps: {
        src?: string | Blob;
        fallback: string;
      };
    }
)[];
Array of messages to display in the chat bento card.
(required)
staggerMessagesInSec?
number
Delay in seconds between rendering each message.
2
typingDurationInSec?
number
Duration in seconds to show the typing indicator for incoming messages.Typically shorter than staggerMessagesInSec.
0.75
initialMessagesCount?
number
Number of messages to be rendered initially.
1
timestamp?
React.ReactNode
Header or timestamp shown at the top of the chat (e.g., Today, Yesterday).
"Today"
viewOptions?
IntersectionObserverInit
Options for IntersectionObserver when startChatOn is "view".
{ threshold: 1 }
startChatOn?
"hover" | "view"
Defines when to start the chat animation: "hover" or "view".
"view"
...rest
React.ComponentPropsWithoutRef<"div">
Any standard React div props, like id, style or className, which will be applied directly to the component's root element, except for ref.
undefined