Agent skill
pillbox pattern
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.
Install this agent skill to your Project
npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/data/pillbox-pattern
SKILL.md
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
Didn't find tool you were looking for?