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