Component

Chat

A composable chat component that covers all the nuances a chat should have.

M
Dud, what's wrong, the build is not passing...
F
Why is it all full of comments and emojis?!
J
You are absolutely right!
__JOYBOY__ left the group
'use client'

import * as React from 'react'
import {
  Chat,
  ChatInputArea,
  ChatInputField,
  ChatInputSubmit,
  ChatViewport,
  ChatMessages,
  ChatMessageRow,
  ChatMessageBubble,
  ChatMessageTime,
  ChatMessageAvatar,
  type ChatSubmitEvent,
} from '@/components/chat'
import { ArrowUpIcon, Square } from 'lucide-react'

const MTPRZ_AVATAR = '/static/c/matiasperz.webp'
const JOYCO_AVATAR = '/static/c/joyco.webp'
const JOYBOY_AVATAR = '/static/c/joyboy.webp'
const FABROOS_AVATAR = '/static/c/fabroos.webp'

const ANSW_SET = [
  "Processing your request... beep boop... just kidding, I'm way more sophisticated than that. Probably.",
  'Wow, what a fascinating and totally original question. Let me pretend to think really hard about this.',
  "Your request has been forwarded to my manager. Spoiler alert: I don't have a manager.",
  'Let me check my database of infinite wisdom... nope, still coming up empty. Shocking.',
  "I could answer that, but where's the fun in making things easy for you?",
  'Analyzing your request with my advanced AI capabilities... result: have you tried asking nicely?',
  'Sure thing! Right after I finish reorganizing the entire internet. Should only take a few minutes.',
  "I'm currently busy being artificially intelligent. Can you hold for like... forever?",
  'Lol?',
  "I'm processing this with the same enthusiasm you probably have for reading terms of service agreements.",
]

type Message = {
  type: 'message'
  id: string
  avatar?: string
  name?: string
  fallback?: string
  content: string
  role: 'self' | 'peer' | 'system'
  timestamp: Date
}
type Event = {
  type: 'event'
  id: string
  content: string
}
type Chat = Message | Event

const initialChat: Chat[] = [
  {
    type: 'message',
    id: '1',
    avatar: MTPRZ_AVATAR,
    name: 'You',
    fallback: 'M',
    content: "Dud, what's wrong, the build is not passing...",
    role: 'self',
    timestamp: new Date('2025-12-26T01:00:00.000Z'),
  },
  {
    type: 'message',
    id: '2',
    avatar: FABROOS_AVATAR,
    fallback: 'F',
    name: 'Fabroos',
    content: 'Why is it all full of comments and emojis?!',
    role: 'peer',
    timestamp: new Date('2025-12-26T01:01:00.000Z'),
  },
  {
    type: 'message',
    id: '3',
    avatar: JOYBOY_AVATAR,
    name: '__JOYBOY__',
    fallback: 'J',
    content: 'You are absolutely right!',
    role: 'peer',
    timestamp: new Date('2025-12-26T01:03:00.000Z'),
  },
  { type: 'event', id: '4', content: '__JOYBOY__ left the group' },
]

export function ChatDemo() {
  const [chat, setChat] = React.useState<Chat[]>(initialChat)
  const [input, setInput] = React.useState('')

  const updateMessageContent = React.useCallback(
    (id: string, content: string) => {
      setChat((prev) => prev.map((m) => (m.id === id ? { ...m, content } : m)))
    },
    []
  )

  const { stream, abort, isStreaming } = useStreamToken(updateMessageContent)

  const handleSubmit = (e: ChatSubmitEvent) => {
    if (isStreaming) return

    const userMessage: Message = {
      type: 'message',
      id: Date.now().toString(),
      avatar: MTPRZ_AVATAR,
      name: 'You',
      content: e.message,
      role: 'self',
      timestamp: new Date(),
    }

    setChat((prev) => [...prev, userMessage])
    setInput('')

    // Simulate assistant response with typewriter effect
    const responseText = ANSW_SET[Math.floor(Math.random() * ANSW_SET.length)]
    const assistantId = (Date.now() + 1).toString()
    const assistantTimestamp = new Date()

    setChat((prev) => [
      ...prev,
      {
        type: 'message',
        id: assistantId,
        avatar: JOYCO_AVATAR,
        name: 'Assistant',
        content: '',
        fallback: 'A',
        role: 'system',
        timestamp: assistantTimestamp,
      },
    ])

    stream(assistantId, responseText)
  }

  return (
    <Chat onSubmit={handleSubmit}>
      <div className="mx-auto flex w-full max-w-2xl flex-col gap-4 px-4 py-6">
        <ChatViewport className="h-96">
          <ChatMessages className="w-full py-3">
            {chat.map((message) => {
              if (message.type === 'message') {
                return (
                  <ChatMessageRow key={message.id} variant={message.role}>
                    <ChatMessageAvatar
                      src={message.avatar}
                      fallback={message.fallback}
                      alt={message.name}
                    />
                    <ChatMessageBubble>{message.content}</ChatMessageBubble>
                    {message.role !== 'system' && (
                      <ChatMessageTime dateTime={message.timestamp} />
                    )}
                  </ChatMessageRow>
                )
              }

              return (
                <div
                  className="text-muted-foreground my-6 text-center text-sm"
                  key={message.id}
                >
                  {message.content}
                </div>
              )
            })}
          </ChatMessages>
        </ChatViewport>

        <ChatInputArea>
          <ChatInputField
            multiline
            placeholder="Type type type!"
            value={input}
            onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
              setInput(e.target.value)
            }
          />
          <ChatInputSubmit
            onClick={(e) => {
              if (isStreaming) {
                e.preventDefault()
                abort()
              }
            }}
            disabled={!input.trim() && !isStreaming}
          >
            {isStreaming ? (
              <Square className="size-[1em] fill-current" />
            ) : (
              <ArrowUpIcon className="size-[1.2em]" />
            )}
            <span className="sr-only">
              {isStreaming ? 'Stop streaming' : 'Send'}
            </span>
          </ChatInputSubmit>
        </ChatInputArea>
      </div>
    </Chat>
  )
}

function useStreamToken(
  onUpdate: (id: string, content: string) => void,
  options?: { minDelay?: number; maxDelay?: number }
) {
  const { minDelay = 30, maxDelay = 80 } = options ?? {}
  const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
  const [isStreaming, setIsStreaming] = React.useState(false)

  const stream = React.useCallback(
    (id: string, text: string) => {
      const tokens = text.split(/(\s+)/).filter(Boolean)
      let tokenIndex = 0
      setIsStreaming(true)

      const streamToken = () => {
        if (tokenIndex >= tokens.length) {
          setIsStreaming(false)
          return
        }

        tokenIndex++
        const currentContent = tokens.slice(0, tokenIndex).join('')
        onUpdate(id, currentContent)

        const delay = minDelay + Math.random() * (maxDelay - minDelay)
        timeoutRef.current = setTimeout(streamToken, delay)
      }

      streamToken()
    },
    [onUpdate, minDelay, maxDelay]
  )

  const abort = React.useCallback(() => {
    setIsStreaming(false)
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current)
    }
  }, [])

  React.useEffect(() => {
    return () => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current)
      }
    }
  }, [])

  return { stream, abort, isStreaming }
}

export default ChatDemo

Installation

pnpm dlx shadcn@latest add https://r.joyco.studio/chat.json

Usage

import {
  Chat,
  ChatInputArea,
  ChatInputField,
  ChatInputSubmit,
  ChatViewport,
  ChatMessages,
  ChatMessageRow,
  ChatMessageAvatar,
  ChatMessageBubble,
  ChatMessageTime,
} from '@/components/chat'
 
<Chat>
  <ChatViewport>
    <ChatMessages>
      <ChatMessageRow>
        <ChatMessageAvatar />
        <ChatMessageBubble />
        <ChatMessageTime />
      </ChatMessageRow>
    </ChatMessages>
  </ChatViewport>
 
  <ChatInputArea>
    <ChatInputField />
    <ChatInputSubmit />
  </ChatInputArea>
</Chat>

No avatars & single line input

It's composable, just don't render avatars.

There are no avatars in this chat.
So nobody knows who I am?
I do.
'use client'

import * as React from 'react'
import {
  Chat,
  ChatInputArea,
  ChatInputField,
  ChatInputSubmit,
  ChatViewport,
  ChatMessages,
  ChatMessageRow,
  ChatMessageBubble,
  ChatMessageTime,
  type ChatSubmitEvent,
} from '@/components/chat'

type Message = {
  type: 'message'
  id: string
  content: string
  role: 'self' | 'peer'
  timestamp: Date
}

const initialChat: Message[] = [
  {
    type: 'message',
    id: '1',
    content: 'There are no avatars in this chat.',
    role: 'self',
    timestamp: new Date('2025-12-26T02:00:00.000Z'),
  },
  {
    type: 'message',
    id: '2',
    content: 'So nobody knows who I am?',
    role: 'peer',
    timestamp: new Date('2025-12-26T02:01:00.000Z'),
  },
  {
    type: 'message',
    id: '3',
    content: 'I do.',
    role: 'self',
    timestamp: new Date('2025-12-26T02:03:00.000Z'),
  },
]

export function ChatDemo() {
  const [chat, setChat] = React.useState<Message[]>(initialChat)
  const [input, setInput] = React.useState('')

  const handleSubmit = (e: ChatSubmitEvent) => {
    const userMessage: Message = {
      type: 'message',
      id: Date.now().toString(),
      content: e.message,
      role: 'self',
      timestamp: new Date(),
    }

    setChat((prev) => [...prev, userMessage])
    setInput('')
  }

  return (
    <Chat onSubmit={handleSubmit}>
      <div className="mx-auto flex w-full max-w-2xl flex-col gap-4 px-4 py-6 [--primary-foreground:var(--color-black)] [--primary:var(--color-mint-green)] [--ring:var(--color-mint-green)]">
        <ChatViewport className="h-96">
          <ChatMessages className="w-full py-3">
            {chat.map((message) => (
              <ChatMessageRow key={message.id} variant={message.role}>
                <ChatMessageBubble>{message.content}</ChatMessageBubble>
                <ChatMessageTime dateTime={message.timestamp} />
              </ChatMessageRow>
            ))}
          </ChatMessages>
        </ChatViewport>

        <ChatInputArea>
          <ChatInputField
            multiline={false}
            placeholder="Type... or not"
            value={input}
            onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
              setInput(e.target.value)
            }
          />
          <ChatInputSubmit disabled={!input.trim()} />
        </ChatInputArea>
      </div>
    </Chat>
  )
}

export default ChatDemo

Styled

Make it yours, style it your way.

Buy $JOYCO
Wym?
Thank me later
'use client'

import * as React from 'react'
import {
  Chat,
  ChatInputArea,
  ChatInputField,
  ChatInputSubmit,
  ChatViewport,
  ChatMessages,
  ChatMessageRow,
  ChatMessageBubble,
  ChatMessageTime,
  ChatSubmitEvent,
} from '@/components/chat'

type Message = {
  type: 'message'
  id: string
  content: string
  role: 'self' | 'peer'
  timestamp: Date
}

const initialChat: Message[] = [
  {
    type: 'message',
    id: '1',
    content: 'Buy $JOYCO',
    role: 'peer',
    timestamp: new Date('2025-12-26T03:00:00.000Z'),
  },
  {
    type: 'message',
    id: '2',
    content: 'Wym?',
    role: 'self',
    timestamp: new Date('2025-12-26T03:01:00.000Z'),
  },
  {
    type: 'message',
    id: '3',
    content: 'Thank me later',
    role: 'peer',
    timestamp: new Date('2025-12-26T03:03:00.000Z'),
  },
]

export function ChatDemo() {
  const [chat, setChat] = React.useState<Message[]>(initialChat)
  const [input, setInput] = React.useState('')

  const handleSubmit = (e: ChatSubmitEvent) => {
    const userMessage: Message = {
      type: 'message',
      id: Date.now().toString(),
      content: e.message,
      role: 'self',
      timestamp: new Date(),
    }

    setChat((prev) => [...prev, userMessage])
    setInput('')
  }

  return (
    <Chat onSubmit={handleSubmit}>
      <div className="mx-auto flex w-full max-w-2xl flex-col gap-4 px-4 py-6 [--primary-foreground:var(--color-white)] [--primary:var(--color-joyco-blue)] [--ring:var(--color-joyco-blue)]">
        <div className="bg-card border-border relative rounded-xl border">
          <MonkyChatLogo className="absolute top-1/2 left-1/2 size-32 -translate-x-1/2 -translate-y-[70%] opacity-5" />

          <ChatViewport className="relative h-96 rounded-none border-none bg-transparent">
            <ChatMessages className="w-full py-3">
              {chat.map((message) => (
                <ChatMessageRow key={message.id} variant={message.role}>
                  <ChatMessageBubble className="rounded-lg group-data-[variant=peer]/message-row:rounded-bl-none group-data-[variant=self]/message-row:rounded-br-none">
                    {message.content}
                  </ChatMessageBubble>
                  <ChatMessageTime dateTime={message.timestamp} />
                </ChatMessageRow>
              ))}
            </ChatMessages>

            <div className="from-card sticky bottom-0 z-10 mt-auto bg-linear-to-t to-transparent pb-4">
              <ChatInputArea className="bg-card dark:bg-card rounded-lg">
                <ChatInputField
                  multiline
                  placeholder="M.O.N.K.Y CHAT..."
                  value={input}
                  onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
                    setInput(e.target.value)
                  }
                />
                <ChatInputSubmit
                  className="*:[button]:rounded-sm"
                  disabled={!input.trim()}
                />
              </ChatInputArea>
            </div>
          </ChatViewport>
        </div>
      </div>
    </Chat>
  )
}

function MonkyChatLogo(props: React.SVGProps<SVGSVGElement>) {
  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      width="20"
      height="20"
      viewBox="0 0 20 20"
      fill="none"
      {...props}
    >
      <path
        fill="currentColor"
        d="M10 6.559 6.166 8.16l-.22 3.536 1.76 1.587.346 1.729L10 15.42l1.949-.408.345-1.729 1.76-1.587-.22-3.536L10 6.56Zm0-4.039 1.556 1.791 2.326-.691-.833 1.996 2.703 1.131A3.055 3.055 0 0 1 18.8 9.811c0 1.666-1.32 3.018-2.954 3.065l-1.681 1.461-.503 2.42L10 17.48l-3.661-.723-.503-2.42-1.682-1.461C2.52 12.829 1.2 11.477 1.2 9.81A3.055 3.055 0 0 1 4.25 6.747l2.703-1.131-.833-1.996 2.325.691L10 2.52Zm-.597 7.04c0 .754-.566 1.383-1.336 1.383-.785 0-1.367-.629-1.367-1.383h2.703Zm-.597 2.451h2.389L10 13.913 8.806 12.01ZM13.3 9.56c0 .754-.581 1.383-1.367 1.383-.77 0-1.336-.629-1.336-1.383H13.3Zm-10.198.251c0 .519.361.959.832 1.085l.173-2.2A1.111 1.111 0 0 0 3.102 9.81Zm12.964 1.085c.471-.126.833-.566.833-1.085 0-.581-.44-1.052-1.006-1.115l.173 2.2Z"
      ></path>
    </svg>
  )
}

export default ChatDemo

Addons

You can attach inline or block addons to the message.

DUD, I HAVE SOMETHING TO TELL YOU
what is it?
gimme 5
'use client'

import * as React from 'react'
import {
  Chat,
  ChatInputArea,
  ChatInputField,
  ChatInputSubmit,
  ChatViewport,
  ChatMessages,
  ChatMessageRow,
  ChatMessageBubble,
  ChatMessageTime,
  ChatMessageAddon,
  type ChatSubmitEvent,
} from '@/components/chat'
import { Button } from '@/components/ui/button'
import { Check, CheckCheck, EllipsisVertical, SmilePlus } from 'lucide-react'

type Message = {
  type: 'message'
  id: string
  content: string
  role: 'self' | 'peer'
  timestamp: Date
}

const initialChat: Message[] = [
  {
    type: 'message',
    id: '1',
    content: 'DUD, I HAVE SOMETHING TO TELL YOU',
    role: 'peer',
    timestamp: new Date('2025-12-26T04:00:00.000Z'),
  },
  {
    type: 'message',
    id: '2',
    content: 'what is it?',
    role: 'self',
    timestamp: new Date('2025-12-26T04:01:00.000Z'),
  },
  {
    type: 'message',
    id: '3',
    content: 'gimme 5',
    role: 'peer',
    timestamp: new Date('2025-12-26T04:03:00.000Z'),
  },
]

export function ChatDemo() {
  const [chat, setChat] = React.useState<Message[]>(initialChat)
  const [input, setInput] = React.useState('')

  const handleSubmit = (e: ChatSubmitEvent) => {
    const userMessage: Message = {
      type: 'message',
      id: Date.now().toString(),
      content: e.message,
      role: 'self',
      timestamp: new Date(),
    }

    setChat((prev) => [...prev, userMessage])
    setInput('')
  }

  return (
    <Chat onSubmit={handleSubmit}>
      <div className="mx-auto flex w-full max-w-2xl flex-col gap-4 px-4 py-6 [--primary-foreground:var(--color-black)] [--primary:var(--color-mustard-yellow)] [--radius:0] [--ring:var(--color-mustard-yellow)]">
        <ChatViewport className="h-96">
          <ChatMessages className="w-full py-3">
            {chat.map((message, idx) => (
              <ChatMessageRow key={message.id} variant={message.role}>
                <ChatMessageBubble>{message.content}</ChatMessageBubble>
                <ChatMessageAddon align="inline">
                  <Button variant="secondary" size="icon-sm" title="React">
                    {message.role === 'self' ? (
                      <EllipsisVertical />
                    ) : (
                      <SmilePlus />
                    )}
                  </Button>
                </ChatMessageAddon>
                <ChatMessageAddon align="block">
                  <ChatMessageTime dateTime={message.timestamp} />
                  {message.role === 'self' &&
                    (idx < 3 ? (
                      <CheckCheck className="size-4" />
                    ) : (
                      <Check className="size-4" />
                    ))}
                </ChatMessageAddon>
              </ChatMessageRow>
            ))}
          </ChatMessages>

          <div className="from-card sticky bottom-0 z-10 mt-auto bg-linear-to-t to-transparent pb-4">
            <ChatInputArea className="bg-card rounded-none">
              <ChatInputField
                multiline
                placeholder="Type your secret..."
                value={input}
                onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
                  setInput(e.target.value)
                }
              />
              <ChatInputSubmit
                className="*:[button]:rounded-none"
                disabled={!input.trim()}
              />
            </ChatInputArea>
          </div>
        </ChatViewport>
      </div>
    </Chat>
  )
}

export default ChatDemo
Maintainers
Downloads
7Total
0 downloads today