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:
id="about" className="h-160"
This is still a server component.

id="pricing" className="h-fit"
This is still a server component.

API usage

components/chat-bento-card.demo.tsx
import { ChatBentoCard, type ChatMessageType } from "./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&apos;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/r/chat-bento-card.json

Manual

1
Install the following dependencies.
pnpm add clsx lucide-react 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 "./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

<InPageNavbar/>

PropsTypeDescriptionDefault value
logo
React.ReactElement
The logo to be displayed, typically on the left side of the navbar. Accepts any renderable React element.
(required)
sections
{
  label: string;
  id: string;
  ...rest: Omit<React.ComponentProps<"a">, "href">;
}[]
label: The text displayed for the navigation link.
id: The id of the section element used for progress tracking and as a link target for smooth scrolling.
rest: Any standard React anchor props, like target, rel, or className, which will be applied directly to the element, except for href.
(required)
...rest
React.ComponentProps<'div'>
Any standard React div props, like id, style or className, which will be applied directly to the component&pos;s root element.
undefined
Good to know:
  • <InPageNavbar/> automatically transforms into a sidebar on screens smaller than 640px (sm).
  • Place this component at the end of your page or layout to ensure it correctly detects and tracks all sections above it.