npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 types

Import 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