@budly/kit
v0.1.0
Published
Headless React hooks for building custom AI chat interfaces
Readme
@budly/kit
🎯 Philosophy
Headless = No UI, Full Control
@budly/kit provides only the logic for building chat interfaces. You bring your own components and styles. This gives you:
- ✅ Complete design freedom - Use any CSS framework or design system
- ✅ No style conflicts - Zero CSS shipped
- ✅ Smaller bundle - Only import what you use
- ✅ Framework agnostic styling - Tailwind, CSS Modules, Styled Components, anything
📦 Installation
npm install @budly/kit🚀 Quick Start
import { useChatMessages, useChatInput, useChatScroll, generateId } from '@budly/kit'
function MyChat() {
// Manage messages
const { messages, addMessage, visibleMessages } = useChatMessages()
// Manage input
const input = useChatInput({
onSubmit: async text => {
// Add user message
addMessage({ id: generateId(), role: 'user', content: text })
input.clear()
// Call your API and add assistant response
const response = await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ message: text })
})
const data = await response.json()
addMessage({ id: generateId(), role: 'assistant', content: data.message })
}
})
// Auto-scroll
const scroll = useChatScroll()
return (
<div
ref={scroll.containerRef}
style={{ height: '500px', overflow: 'auto' }}
>
{/* Your custom message components */}
{visibleMessages.map(msg => (
<div
key={msg.id}
style={{
textAlign: msg.role === 'user' ? 'right' : 'left',
padding: '8px'
}}
>
<strong>{msg.role}:</strong> {msg.content}
</div>
))}
{/* Scroll target */}
<div ref={scroll.targetRef} />
{/* Your custom input */}
<textarea
value={input.value}
onChange={input.onChange}
onKeyDown={input.onKeyDown}
placeholder='Type a message...'
/>
<button
onClick={input.submit}
disabled={input.isEmpty}
>
Send
</button>
</div>
)
}📚 Core Hooks
useChatMessages
Manages the list of chat messages with filtering, CRUD operations, and queries.
const {
messages, // All messages
visibleMessages, // Filtered messages for display
addMessage, // Add a new message
updateMessage, // Update message by ID
removeMessage, // Remove message by ID
clear, // Clear all messages
setMessages, // Replace all messages
findLastByRole, // Find last message by role
count, // Total message count
isEmpty // Is empty
} = useChatMessages({
initialMessages: [],
filter: msg => msg.role !== 'system' // Optional custom filter
})useChatInput
Manages input state, keyboard handling (Enter to submit), and validation.
const {
value, // Current input value
setValue, // Set input value
clear, // Clear input
onChange, // Input onChange handler
onKeyDown, // Keyboard handler (Enter = submit, Shift+Enter = newline)
submit, // Programmatically submit
isEmpty, // Is input empty
length, // Character count
ref // Ref to attach to textarea
} = useChatInput({
initialValue: '',
maxLength: 4000,
onSubmit: text => {
/* handle submission */
},
onChange: text => {
/* optional: handle changes */
}
})useChatScroll
Manages auto-scroll behavior with scroll direction detection.
const {
containerRef, // Attach to scrollable container
targetRef, // Attach to end-of-messages div
scrollToBottom, // Scroll to bottom programmatically
isScrollingUp, // User is scrolling up
isAtBottom // Is at bottom of scroll
} = useChatScroll({
bottomOffset: 10, // Pixels from bottom to trigger "at bottom"
smooth: true // Use smooth scrolling
})useFileUpload
Manages file uploads with progress tracking and validation.
const {
resources, // Uploaded files as ChatResource[]
uploading, // Map of files currently uploading
isUploading, // Is any file uploading
upload, // Upload files
cancel, // Cancel upload by filename
removeResource, // Remove uploaded resource
clearResources, // Clear all resources
openFilePicker, // Open file dialog
inputProps, // Props to spread on hidden <input type="file">
} = useFileUpload({
uploadEndpoint: '/api/upload',
accept: '.pdf,.doc,.docx,.txt',
maxSize: 10 * 1024 * 1024, // 10MB
maxFiles: 5,
onUploadComplete: (file, resource) => console.log('Uploaded:', resource),
onUploadError: (file, error) => console.error('Failed:', error),
})
// Usage
<>
<input {...inputProps} />
<button onClick={openFilePicker}>Attach File</button>
{resources.map((r) => (
<div key={r.id}>
{r.name} ({formatFileSize(r.size)})
<button onClick={() => removeResource(r.id)}>×</button>
</div>
))}
</>useMarkdown
Parses markdown content into structured tokens for custom rendering.
const { tokens, processedContent } = useMarkdown(message.content)
// Render tokens with your own components
function renderToken(token: MarkdownToken, index: number): React.ReactNode {
switch (token.type) {
case 'heading':
const Tag = `h${token.level}` as keyof JSX.IntrinsicElements
return <Tag key={index}>{token.children?.map(renderToken)}</Tag>
case 'paragraph':
return <p key={index}>{token.children?.map(renderToken)}</p>
case 'bold':
return <strong key={index}>{token.content}</strong>
case 'italic':
return <em key={index}>{token.content}</em>
case 'code':
return <code key={index}>{token.content}</code>
case 'codeblock':
return (
<pre key={index}>
<code className={`language-${token.language}`}>{token.content}</code>
</pre>
)
case 'link':
return (
<a
key={index}
href={token.href}
>
{token.content}
</a>
)
case 'text':
return token.content
default:
return token.content
}
}
return <div>{tokens.map(renderToken)}</div>useTextareaAutosize
Auto-resizes textarea height based on content.
const { ref } = useTextareaAutosize({
maxRows: 6,
minHeight: 40,
})
<textarea ref={ref} value={value} onChange={onChange} />useGenerativeUI
Renders custom interactive components (quizzes, code runners, etc.) based on backend responses.
import { useGenerativeUI, type GenerativeUIComponentProps } from '@budly/kit'
// 1. Define your components
function MultipleChoice({ data, onSubmit }: GenerativeUIComponentProps<MyData>) {
// Your component implementation
return <div>{/* Quiz UI */}</div>
}
// 2. Create a registry
const registry = {
'multiple_choice': MultipleChoice,
'true_false': TrueFalseComponent,
'code_runner': CodeRunnerComponent,
}
// 3. Use the hook
const {
renderBlock, // Render a single GenUI block
renderBlocks, // Render all blocks for a message
hasComponent, // Check if type is registered
registeredTypes // List of registered types
} = useGenerativeUI({
registry,
onSubmit: (blockId, type, answer) => {
// Send answer to your backend
api.submitAnswer(blockId, answer)
},
onInteraction: (blockId, type, result) => {
// Track user interactions
}
})
// 4. Render in your chat
{messages.map(msg => (
<div key={msg.id}>
<p>{msg.content}</p>
{msg.generativeUI && renderBlocks(msg)}
</div>
))}Backend Response Format:
{
"id": "msg_123",
"role": "assistant",
"content": "Here's a quiz for you:",
"generativeUI": [
{
"id": "block_1",
"type": "multiple_choice",
"data": {
"question": "What is 2 + 2?",
"options": [
{ "id": "a", "text": "3" },
{ "id": "b", "text": "4" }
],
"correctAnswer": "b"
}
}
]
}Component Props Interface:
interface GenerativeUIComponentProps<T = unknown> {
id: string // Block ID
type: string // Component type
data: T // Your custom data
messageId?: string // Parent message ID
isStreaming?: boolean
onInteraction?: (result: unknown) => void
onSubmit?: (answer: unknown) => void
}🛠 Utilities
generateId()
Generates a unique ID using crypto.randomUUID().
formatFileSize(bytes)
Formats bytes to human-readable string ("1.5 MB").
formatDate(date)
Formats date for chat display ("2:30 PM", "Yesterday 2:30 PM").
cn(...classes)
Utility for merging class names (Tailwind-compatible).
📂 Module Structure
@budly/kit
├── / # All hooks + utilities (main entry)
├── /core # Core hooks only
├── /hooks # Utility hooks (useMobileLandscape, useIsXss)
├── /utils # Helper functions
└── /types # TypeScript typesImport Examples
// Everything from main entry
import { useChatMessages, useChatInput, generateId } from '@budly/kit'
// Just core hooks
import { useChatMessages, useChatScroll } from '@budly/kit/core'
// Just utilities
import { formatFileSize, cn } from '@budly/kit/utils'
// Just types
import type { ChatMessage, MarkdownToken } from '@budly/kit/types'🔌 Integration with Vercel AI SDK
import { useChat } from 'ai/react'
import { useChatScroll, useMarkdown } from '@budly/kit'
function MyChat() {
// Use Vercel AI SDK for streaming
const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat({
api: '/api/chat'
})
// Use @budly/kit for scroll behavior
const scroll = useChatScroll()
// Auto-scroll on new messages
useEffect(() => {
if (scroll.isAtBottom) {
scroll.scrollToBottom()
}
}, [messages.length])
return (
<div
ref={scroll.containerRef}
className='h-screen overflow-auto'
>
{messages.map(msg => (
<MessageWithMarkdown
key={msg.id}
content={msg.content}
role={msg.role}
/>
))}
<div ref={scroll.targetRef} />
<form onSubmit={handleSubmit}>
<input
value={input}
onChange={handleInputChange}
/>
<button
type='submit'
disabled={isLoading}
>
Send
</button>
</form>
</div>
)
}
function MessageWithMarkdown({ content, role }) {
const { tokens } = useMarkdown(content)
// Render tokens with your components...
}📖 Complete Example
Here's a full chat implementation with Tailwind CSS:
import {
useChatMessages,
useChatInput,
useChatScroll,
useFileUpload,
useTextareaAutosize,
useMarkdown,
generateId,
formatFileSize
} from '@budly/kit'
function CompleteChat() {
const { visibleMessages, addMessage } = useChatMessages()
const input = useChatInput({
onSubmit: async text => {
addMessage({ id: generateId(), role: 'user', content: text })
input.clear()
// Your API call here...
}
})
const scroll = useChatScroll()
const textarea = useTextareaAutosize({ maxRows: 4 })
const files = useFileUpload({ accept: '.pdf,.doc' })
return (
<div className='flex h-screen flex-col'>
{/* Messages */}
<div
ref={scroll.containerRef}
className='flex-1 overflow-auto p-4'
>
{visibleMessages.map(msg => (
<Message
key={msg.id}
{...msg}
/>
))}
<div ref={scroll.targetRef} />
</div>
{/* File previews */}
{files.resources.length > 0 && (
<div className='flex gap-2 border-t p-2'>
{files.resources.map(r => (
<div
key={r.id}
className='flex items-center gap-1 rounded bg-gray-100 px-2 py-1'
>
<span className='text-sm'>{r.name}</span>
<button onClick={() => files.removeResource(r.id)}>×</button>
</div>
))}
</div>
)}
{/* Input */}
<div className='border-t p-4'>
<input {...files.inputProps} />
<div className='flex gap-2'>
<button onClick={files.openFilePicker}>📎</button>
<textarea
ref={textarea.ref}
value={input.value}
onChange={input.onChange}
onKeyDown={input.onKeyDown}
placeholder='Type a message...'
className='flex-1 resize-none rounded border p-2'
/>
<button
onClick={input.submit}
disabled={input.isEmpty}
className='rounded bg-blue-500 px-4 text-white disabled:opacity-50'
>
Send
</button>
</div>
</div>
</div>
)
}
function Message({ role, content }: { role: string; content: string }) {
const { tokens } = useMarkdown(content)
return (
<div className={`mb-4 ${role === 'user' ? 'text-right' : ''}`}>
<div
className={`inline-block rounded-lg px-4 py-2 ${role === 'user' ? 'bg-blue-500 text-white' : 'bg-gray-100'}`}
>
{/* Render your markdown tokens here */}
{content}
</div>
</div>
)
}📄 License
MIT © Budly
