| name | pillbox pattern |
| description | User interface pattern. When Claude needs to create an editable text region that contains inline pills or tokens, along with the ability to add them using a mention menu. |
| license | Proprietary |
Pillbox Pattern
This skill provides guidance for implementing inline structured text editors with "pill" components using Tiptap.
Overview
The pillbox pattern creates an inline text editor where special tokens (pills/badges) flow naturally within text content, wrapping across lines like regular text while maintaining their distinct visual appearance. Users insert pills by typing "@" which triggers a filterable dropdown menu with keyboard and mouse navigation.
Implementation Details
Dependencies
pnpm add @tiptap/react @tiptap/core @tiptap/pm @tiptap/starter-kit
pnpm add @tiptap/extension-document @tiptap/extension-paragraph @tiptap/extension-text @tiptap/extension-history
pnpm add @tiptap/suggestion
File Structure
src/
├── extensions/
│ ├── Pill.ts # Custom Tiptap node extension for rendering pills
│ └── Mention.ts # Mention extension for @ trigger and suggestion handling
├── components/
│ ├── PillComponent.tsx # React component for rendering pills
│ ├── MentionMenu.tsx # Floating dropdown menu for pill selection
│ └── Editor.tsx # Main editor wrapper with menu state management
└── pages/ # Front-end views accessible by routes
Custom Pill Extension
Location: src/extensions/Pill.ts
Key characteristics:
group: 'inline'- Makes pills flow inline with textinline: true- Allows pills to appear within paragraphsatom: true- Makes pills non-editable blocks- Attributes:
label(string),color(string) - Uses
ReactNodeViewRendererfor custom React rendering - Includes
insertPillcommand for programmatic insertion
import { Node, mergeAttributes } from '@tiptap/core'
import { ReactNodeViewRenderer } from '@tiptap/react'
import PillComponent from '../components/PillComponent'
export const Pill = Node.create({
name: 'pill',
group: 'inline',
inline: true,
atom: true,
addAttributes() {
return {
label: { default: null },
color: { default: 'default' },
}
},
addNodeView() {
return ReactNodeViewRenderer(PillComponent)
},
addCommands() {
return {
insertPill:
(options) =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: options,
})
},
}
},
})
Mention Extension
Location: src/extensions/Mention.ts
Handles the "@" trigger and suggestion workflow:
- Detects "@" character and activates suggestion plugin
- Manages text range for replacement
- Integrates with Tiptap's suggestion plugin
- Calls
insertPillcommand when option selected
import { Node } from '@tiptap/core'
import { PluginKey } from '@tiptap/pm/state'
import Suggestion, { SuggestionOptions } from '@tiptap/suggestion'
export const MentionPluginKey = new PluginKey('mention')
export const Mention = Node.create({
name: 'mention',
addOptions() {
return {
suggestion: {
char: '@',
pluginKey: MentionPluginKey,
command: ({ editor, range, props }) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertPill({
label: props.label,
color: props.color || 'default',
})
.run()
},
},
}
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
]
},
})
Pill Component
Location: src/components/PillComponent.tsx
Key features:
- Uses
NodeViewWrapperwithas="span"for inline rendering inline-block align-baselinefor proper text flowflex items-center gap-1for icon + text layout- Lucide icons selected via hash function for consistency
- Icon size: 12px for compact appearance
- Color variants: default, blue, green, purple, red
import { NodeViewWrapper } from '@tiptap/react'
import { Badge } from '@/components/ui/badge'
import { User, Mail, Calendar, /* etc */ } from 'lucide-react'
const PillComponent = ({ node }: any) => {
const { label, color } = node.attrs
// Hash-based icon selection
const getIcon = (label: string) => {
const hash = label.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
return icons[hash % icons.length]
}
return (
<NodeViewWrapper as="span" className="inline-block align-baseline">
<Badge className="flex items-center gap-1">
<Icon size={12} />
{label}
</Badge>
</NodeViewWrapper>
)
}
Mention Menu Component
Location: src/components/MentionMenu.tsx
Floating dropdown menu for pill selection:
- Positioned near cursor using absolute positioning
- Displays filtered list of options based on query
- Supports keyboard navigation (arrow keys, Enter, Escape)
- Supports mouse hover and click
- Shows "No results found" when filter yields no matches
- Uses Lucide icons for each option
import { User, Mail, Calendar, /* etc */ } from 'lucide-react'
interface MentionOption {
label: string
color: 'default' | 'blue' | 'green' | 'purple' | 'red'
icon: any
}
const MENTION_OPTIONS: MentionOption[] = [
{ label: 'User Name', color: 'blue', icon: User },
{ label: 'Email Address', color: 'blue', icon: Mail },
// ... more options
]
const MentionMenu = ({ position, selectedIndex, items, onSelect, onKeyDown }) => {
return (
<div
className="absolute z-50 w-64 rounded-md border bg-white dark:bg-zinc-900 shadow-lg"
style={{ top: position.top, left: position.left }}
>
{items.map((option, index) => (
<button
key={option.label}
className={index === selectedIndex ? 'bg-zinc-100' : ''}
onClick={() => onSelect(option)}
>
<Icon size={16} />
{option.label}
</button>
))}
</div>
)
}
Editor Component
Location: src/components/Editor.tsx
Editor with integrated mention menu:
- Essential extensions: Document, Paragraph, Text, History, Pill, Mention
- No formatting extensions (bold, italic, headings, etc.)
- Content passed as object (not string)
- Manages menu state (visibility, position, selected index, filtered items)
- Handles keyboard events (Arrow keys, Enter, Escape, Space)
- Handles click outside to dismiss menu
- Filters options based on text typed after "@"
Key features:
- Filtering:
onUpdatecallback filters options by query text - Position tracking: Uses
editor.view.coordsAtPos()for menu placement - Selection reset: Resets
selectedIndexto 0 when filter changes - Menu dismissal: Space or Escape closes menu, click outside closes menu
import { useEditor, EditorContent } from '@tiptap/react'
import { Document, Paragraph, Text, History } from '@tiptap/extension-*'
import { Pill } from '../extensions/Pill'
import { Mention } from '../extensions/Mention'
import MentionMenu, { MENTION_OPTIONS } from './MentionMenu'
const Editor = ({ content }: { content?: any }) => {
const [menuState, setMenuState] = useState({
open: false,
position: { top: 0, left: 0 },
selectedIndex: 0,
range: null,
query: '',
filteredItems: MENTION_OPTIONS,
})
const editor = useEditor({
extensions: [
Document,
Paragraph,
Text,
History,
Pill,
Mention.configure({
suggestion: {
items: ({ query }) =>
MENTION_OPTIONS.filter((opt) =>
opt.label.toLowerCase().includes(query.toLowerCase())
),
render: () => ({
onStart: (props) => {
// Show menu, calculate position, filter items
const coords = editor.view.coordsAtPos(...)
setMenuState({ open: true, position: coords, ... })
},
onUpdate: (props) => {
// Update position and filter as user types
const filtered = MENTION_OPTIONS.filter(...)
setMenuState({ ...prev, filteredItems: filtered, selectedIndex: 0 })
},
onKeyDown: (props) => {
// Handle arrow keys, Enter, Escape, Space
},
onExit: () => setMenuState({ ...prev, open: false }),
}),
},
}),
],
content: content || '',
})
return (
<div>
<EditorContent editor={editor} />
{menuState.open && menuState.filteredItems.length > 0 && (
<MentionMenu
position={menuState.position}
selectedIndex={menuState.selectedIndex}
items={menuState.filteredItems}
onSelect={handleSelectOption}
onKeyDown={handleMenuKeyDown}
/>
)}
</div>
)
}
Content Format
Pills are represented in Tiptap's JSON format:
const content = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Regular text with ' },
{
type: 'pill',
attrs: { label: 'inline pills', color: 'blue' }
},
{ type: 'text', text: ' that wrap naturally.' },
]
}
]
}
// Pass as object, NOT stringified
<Editor content={content} />
Mention Menu Interaction Flow
- User types "@" → Mention extension detects trigger character
- Menu appears → Positioned below cursor, shows all available options
- User types letters → Menu filters in real-time (case-insensitive substring match)
- Navigation:
- Arrow Up/Down: Move selection through filtered items
- Enter: Insert selected pill and close menu
- Escape: Close menu without inserting
- Space: Close menu and insert space character (@ remains in text)
- Click: Select item with mouse
- Click outside: Close menu without inserting
- Pill inserted → "@text" is replaced with the selected pill
- Menu closes → Focus returns to editor
Key Design Decisions
- Inline rendering: Pills use
inline-blockwithalign-baselineto flow with text - Atomic nodes: Pills are non-editable units that behave as single characters
- Icon consistency: Hash function ensures same label always gets same icon
- Minimal extensions: Only unformatted text to keep editor simple
- Color coding: Visual categorization via background colors
- Dark mode: All colors have dark mode variants
- @ trigger pattern: Standard mention-style autocomplete for discoverability
- Real-time filtering: Immediate feedback as user types query
- Keyboard-first: All actions accessible via keyboard
- Selection reset on filter: Always highlight first result when query changes
Common Pitfalls
❌ Don't stringify content:
content={JSON.stringify(obj)}✅ Pass object directly:content={obj}❌ Don't use
display: inlineon NodeViewWrapper ✅ Useas="span"withinline-blockclass❌ Don't forget
align-baseline✅ Ensures proper vertical alignment with text❌ Don't use random icons on each render ✅ Use deterministic hash function for consistency
❌ Don't reference full option list in keyboard handlers ✅ Use
menuState.filteredItemsfor bounds checking❌ Don't forget to reset
selectedIndexwhen filter changes ✅ Set to 0 inonUpdateto avoid out-of-bounds selection❌ Don't show menu when
filteredItems.length === 0✅ Conditionally render menu based on filtered results❌ Don't forget to install
@tiptap/suggestion✅ Required dependency for mention functionality
Extending the Pattern
To add more functionality:
- Custom options: Replace
MENTION_OPTIONSwith dynamic data from API - Click handlers: Add
onClickto Badge component for pill interactions - Tooltips: Wrap Badge with Tooltip component for additional info
- Custom attributes: Add more attrs in extension definition (e.g.,
id,metadata) - Different trigger: Change
char: '@'to use other triggers like#or/ - Multi-character triggers: Use
char: '//'for slash commands - Async filtering: Fetch filtered results from API in
itemsfunction - Fuzzy search: Replace substring filter with fuzzy matching library
- Grouped options: Add category headers in menu for organization
Example Use Cases
- Template variables (e.g.,
{{user.name}}) - Mentions (@username)
- Tags and categories
- Dynamic fields in forms
- Variable insertion in email templates
- Entity references in rich text
Reference Implementation
If a reference implementation exists in the current codebase, you may find these files:
src/extensions/Pill.ts- Custom pill node extension withinsertPillcommandsrc/extensions/Mention.ts- Mention extension for @ trigger detectionsrc/components/PillComponent.tsx- Visual pill rendering with iconssrc/components/MentionMenu.tsx- Floating dropdown menu with filteringsrc/components/Editor.tsx- Editor setup with menu state managementsrc/pages/EditorPage.tsx- Demo page with usage instructions
Note: These files may not exist in every repository. Use the code examples in this document as patterns to implement from scratch.
Quick Start
- Install dependencies:
pnpm add @tiptap/react @tiptap/core @tiptap/pm @tiptap/starter-kit
pnpm add @tiptap/extension-document @tiptap/extension-paragraph @tiptap/extension-text @tiptap/extension-history
pnpm add @tiptap/suggestion
Create the four core files following the patterns shown above:
src/extensions/Pill.ts- Use the Pill Extension code examplesrc/extensions/Mention.ts- Use the Mention Extension code examplesrc/components/PillComponent.tsx- Use the Pill Component code examplesrc/components/MentionMenu.tsx- Use the Mention Menu Component code examplesrc/components/Editor.tsx- Use the Editor Component code example
Import and use the Editor component:
import Editor from '@/components/Editor'
function MyPage() {
return <Editor content={initialContent} />
}
- Type "@" in the editor to trigger the mention menu
- Type letters to filter options or use arrow keys to navigate
- Press Enter or click to insert a pill