Pillbox
Multi-select tag interface with removable pills, keyboard support, and validation options
Overview
The Pillbox component transforms inputs into multi-select tag interfaces. Perfect for tagging, categorizing, skill selection, email recipients, and any scenario where users need to select or create multiple items. Each selection appears as a removable "pill" with full keyboard support and validation options.
Key Features
- Multi-Select Tags - Visual pill-based selection with easy removal
- Searchable Dropdown - Filter available options as you type
- Creatable Mode - Allow users to create custom tags
- Validation - Max items, duplicates prevention, custom validators
- Keyboard Navigation - Backspace to remove, arrow keys, Enter to add
- Value Management - Separate value/label support for complex data
- Customizable Rendering - Custom pill and option templates
When to Use
- Tagging and categorization systems
- Skill or interest selection
- Email recipient fields (To, CC, BCC)
- Multi-select filters and facets
- Product attribute selection
Basic Usage
// Select from predefined options
const pillbox = Domma.elements.pillbox('#tags', {
data: ['React', 'Vue', 'Angular', 'Svelte'],
value: ['React'],
maxItems: 5,
onChange: (values) => console.log('Selected:', values)
});
// Allow creating custom tags
Domma.elements.pillbox('#custom-tags', {
data: ['JavaScript', 'Python', 'Java'],
creatable: true,
duplicates: false
});
Getting Started
Add a multi-select tag input to your page in three simple steps
Step 1: Add Input Element
Create a text input that will be transformed into a pillbox:
<input type="text" id="my-tags" placeholder="Select tags...">
Step 2: Include Domma
Add Domma CSS and JavaScript to your page:
<!-- CSS -->
<link rel="stylesheet" href="dist/domma.css">
<link rel="stylesheet" href="dist/elements.css">
<!-- JavaScript -->
<script src="dist/domma.min.js"></script>
Step 3: Initialize the Pillbox
Call Domma.elements.pillbox() to transform the input:
// Select from predefined options
Domma.elements.pillbox('#my-tags', {
data: ['JavaScript', 'Python', 'Java', 'C++', 'Ruby', 'Go'],
value: ['JavaScript'], // Initially selected
maxItems: 5, // Limit selections
onChange: (values) => {
console.log('Selected:', values);
}
});
// Or allow users to create custom tags
Domma.elements.pillbox('#my-tags', {
data: ['React', 'Vue', 'Angular'],
creatable: true, // Allow custom entries
duplicates: false // Prevent duplicates
});
Note: The pillbox component transforms a regular text input into a multi-select tag interface. It auto-generates the dropdown and pills UI. Use backspace to remove the last tag, arrow keys to navigate, and Enter to add selections.
Quick Start
Simple Tag Selector
Select your favourite programming languages:
<input type="text" id="quick-pillbox" class="pillbox-input-style">/**
* Simple Tag Selector - from public/showcase/elements/pillbox.
*/
export function mount(root) {
const languages = [
{value: 'js', label: 'JavaScript'},
{value: 'ts', label: 'TypeScript'},
{value: 'py', label: 'Python'},
{value: 'java', label: 'Java'},
{value: 'go', label: 'Go'},
{value: 'rust', label: 'Rust'},
{value: 'cpp', label: 'C++'},
{value: 'csharp', label: 'C#'}
];
// Quick Start
E.pillbox(root.querySelector('#quick-pillbox'), {
data: languages,
value: ['js'],
maxItems: 3
});
}<input type="text" id="tech-stack">
<script>
const languages = [
{ value: 'js', label: 'JavaScript' },
{ value: 'ts', label: 'TypeScript' },
{ value: 'py', label: 'Python' },
{ value: 'java', label: 'Java' },
{ value: 'go', label: 'Go' },
{ value: 'rust', label: 'Rust' }
];
Domma.elements.pillbox('#tech-stack', {
data: languages,
value: ['js'],
maxItems: 3,
onChange: (values) => {
console.log('Selected languages:', values);
}
});
</script>
Tutorial
Let's build an email recipient field with validation and custom rendering.
0 recipient(s) selected
<input type="text" id="tutorial-pillbox" placeholder="Add recipients..." class="pillbox-input-style">
<div id="tutorial-result" class="mt-4 p-3 bg-surface rounded">
<p class="pillbox-p-text-sm" data-bind-class="changed.value ? '' : 'text-muted'"><span data-bind-text="count.value">0</span> recipient(s) selected</p>
</div>/**
* Tutorial - from public/showcase/elements/pillbox.
*
* The recipient count is bound (data-bind-text), so onChange only sets an
* observable. It is muted until the first change, as before.
*/
export function mount(root) {
const contacts = [
{value: 'alice@company.com', label: 'Alice Johnson'},
{value: 'bob@company.com', label: 'Bob Smith'},
{value: 'carol@company.com', label: 'Carol White'},
{value: 'david@company.com', label: 'David Brown'},
{value: 'eve@company.com', label: 'Eve Davis'}
];
const count = M.observable(0);
const changed = M.observable(false);
const handle = M.applyBindings({count, changed}, root);
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const pillbox = E.pillbox(root.querySelector('#tutorial-pillbox'), {
data: contacts,
creatable: true,
duplicates: false,
maxItems: 10,
validatePill: (email) => {
if (!emailRegex.test(email)) {
return 'Invalid email address';
}
return true;
},
onChange: (values) => {
count.value = values.length;
changed.value = true;
},
onValidationError: (error, value) => {
E.toast(`Error: ${error} - "${value}"`);
}
});
return () => {
handle.dispose();
pillbox?.destroy?.();
};
}Key Points: The pillbox validates emails, prevents duplicates, enforces a max limit, and allows creating custom email addresses not in the contacts list.
Examples
Example 1: Creatable Tags (Free-Form)
Create custom tags that aren't in the predefined list:
Type any skill and press Enter to add it
<input type="text" id="creatable-pillbox" placeholder="Type and press Enter to create..." class="pillbox-input-style">
<p class="text-xs text-muted mt-2">Type any skill and press Enter to add it</p>/**
* Example 1: Creatable Tags (Free-Form) - from public/showcase/elements/pillbox.
*/
export function mount(root) {
const skills = ['JavaScript', 'React', 'Vue', 'Angular', 'Node.js', 'Python', 'MongoDB', 'PostgreSQL'];
// Creatable Example
E.pillbox(root.querySelector('#creatable-pillbox'), {
data: skills,
creatable: true,
searchable: true,
duplicates: false
});
}Domma.elements.pillbox('#skills-input', {
data: ['JavaScript', 'React', 'Node.js', 'MongoDB'],
creatable: true,
searchable: true,
duplicates: false,
onCreate: (skill) => {
console.log('Created new skill:', skill);
}
});
Example 2: Size Variants
Control the size of pills and input:
<div class="mb-3">
<label class="text-sm mb-2">Small:</label>
<input type="text" id="size-small" placeholder="Small pills..." class="pillbox-input-style">
</div>
<div class="mb-3">
<label class="text-sm mb-2">Medium (Default):</label>
<input type="text" id="size-medium" placeholder="Medium pills..." class="pillbox-input-style">
</div>
<div class="mb-3">
<label class="text-sm mb-2">Large:</label>
<input type="text" id="size-large" placeholder="Large pills..." class="pillbox-input-style">
</div>/**
* Example 2: Size Variants - from public/showcase/elements/pillbox.
*/
export function mount(root) {
const colors = ['Red', 'Blue', 'Green', 'Yellow', 'Purple', 'Orange', 'Pink', 'Black'];
// Size variants
E.pillbox(root.querySelector('#size-small'), {
data: colors,
size: 'small',
value: ['Red']
});
E.pillbox(root.querySelector('#size-medium'), {
data: colors,
size: 'medium',
value: ['Blue', 'Green']
});
E.pillbox(root.querySelector('#size-large'), {
data: colors,
size: 'large',
value: ['Yellow']
});
}// Small
Domma.elements.pillbox('#small', {
data: items,
size: 'small'
});
// Large
Domma.elements.pillbox('#large', {
data: items,
size: 'large'
});
Example 3: Max Items Limit
Limit the number of selections (try adding more than 3):
<input type="text" id="max-items-pillbox" placeholder="Max 3 items..." class="pillbox-input-style">
<p id="max-items-status" class="pillbox-max-items-status text-xs mt-2"></p>/**
* Example 3: Max Items Limit - from public/showcase/elements/pillbox.
*/
export function mount(root) {
const items = ['Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon', 'Zeta', 'Eta', 'Theta'];
// Max items example
E.pillbox(root.querySelector('#max-items-pillbox'), {
data: items,
maxItems: 3,
maxItemsMessage: 'Maximum {max} items allowed',
onChange: (values) => {
const remaining = 3 - values.length;
$(root).find('#max-items-status')[0].textContent =
remaining > 0 ? `You can add ${remaining} more item(s)` : 'Maximum reached!';
},
onMaxReached: () => {
$(root).find('#max-items-status')[0].style.color = 'var(--dm-danger)';
}
});
}Domma.elements.pillbox('#limited-input', {
data: ['Option 1', 'Option 2', 'Option 3', 'Option 4', 'Option 5'],
maxItems: 3,
maxItemsMessage: 'You can only select {max} items',
onMaxReached: () => {
alert('Maximum items reached!');
}
});
Example 4: Custom Pill Rendering
Customize how pills appear with icons and colors:
<input type="text" id="custom-render-pillbox" placeholder="Select team members..." class="pillbox-input-style">/**
* Example 4: Custom Pill Rendering - from public/showcase/elements/pillbox.
*/
export function mount(root) {
const team = [
{value: '1', label: 'Alice', role: 'Admin'},
{value: '2', label: 'Bob', role: 'Developer'},
{value: '3', label: 'Carol', role: 'Designer'},
{value: '4', label: 'David', role: 'Developer'},
{value: '5', label: 'Eve', role: 'Admin'}
];
// Custom rendering
E.pillbox(root.querySelector('#custom-render-pillbox'), {
data: team,
renderPill: (item) => {
const icon = item.role === 'Admin' ? 'π' :
item.role === 'Developer' ? 'π»' : 'π¨';
return `${icon} ${item.label} <span class="pillbox-tag-display">(${item.role})</span>`;
},
renderOption: (item) => {
const icon = item.role === 'Admin' ? 'π' :
item.role === 'Developer' ? 'π»' : 'π¨';
return `
<div class="pillbox-flex-align-center-gap-2">
<span>${icon}</span>
<div class="pillbox-flex-col-align-start">
<div><strong>${item.label}</strong></div>
<div class="pillbox-option-text-opacity-7">${item.role}</div>
</div>
</div>
`;
}
});
}const team = [
{ value: '1', label: 'Alice', role: 'Admin' },
{ value: '2', label: 'Bob', role: 'Developer' },
{ value: '3', label: 'Carol', role: 'Designer' }
];
Domma.elements.pillbox('#team-select', {
data: team,
renderPill: (item) => {
const icon = item.role === 'Admin' ? 'π' :
item.role === 'Developer' ? 'π»' : 'π¨';
return `${icon} ${item.label} <span style="opacity: 0.6;">(${item.role})</span>`;
},
renderOption: (item) => {
const icon = item.role === 'Admin' ? 'π' :
item.role === 'Developer' ? 'π»' : 'π¨';
return `
<div style="display: flex; align-items: center; gap: 0.5rem;">
<span>${icon}</span>
<div>
<div><strong>${item.label}</strong></div>
<div style="font-size: 0.75rem; opacity: 0.7;">${item.role}</div>
</div>
</div>
`;
}
});
Example 5: Programmatic Control
Control pills via JavaScript API:
<input type="text" id="programmatic-pillbox" placeholder="Add items..." class="pillbox-input-style">
<div class="mt-3 flex gap-2">
<button type="button" class="btn btn-sm btn-primary" data-action="1">Add Random</button>
<button type="button" class="btn btn-sm btn-secondary" data-action="2">Remove Last</button>
<button type="button" class="btn btn-sm btn-danger" data-action="3">Clear All</button>
<button type="button" class="btn btn-sm" data-action="4">Show Values</button>
</div>/**
* Example 5: Programmatic Control - from public/showcase/elements/pillbox.
*/
export function mount(root) {
const items = ['Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon', 'Zeta', 'Eta', 'Theta'];
// Programmatic control
const programmaticPillbox = E.pillbox(root.querySelector('#programmatic-pillbox'), {
data: items
});
function addRandomPill() {
const available = items.filter(item => !programmaticPillbox.getValue().includes(item));
if (available.length > 0) {
const random = available[Math.floor(Math.random() * available.length)];
programmaticPillbox.addPill(random, random);
}
};
function removeLast() {
const count = programmaticPillbox.getCount();
if (count > 0) {
programmaticPillbox.removePillAt(count - 1);
}
};
function clearAll() {
programmaticPillbox.clear();
};
function showValues() {
const values = programmaticPillbox.getValue();
E.toast('Current values: ' + (values.length > 0 ? values.join(', ') : 'None'));
};
// Handlers the old page had inline (onclick=β¦), wired here.
$(root).find('[data-action="1"]').on('click', function (event) {
addRandomPill();
});
$(root).find('[data-action="2"]').on('click', function (event) {
removeLast();
});
$(root).find('[data-action="3"]').on('click', function (event) {
clearAll();
});
$(root).find('[data-action="4"]').on('click', function (event) {
showValues();
});
}const pillbox = Domma.elements.pillbox('#controlled-pillbox', {
data: ['Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon']
});
// Add pill programmatically
pillbox.addPill('Alpha', 'Alpha');
// Remove pill by value
pillbox.removePill('Alpha');
// Remove last pill
pillbox.removePillAt(pillbox.getCount() - 1);
// Clear all
pillbox.clear();
// Get current values
const values = pillbox.getValue();
console.log(values); // ['Beta', 'Gamma']
// Set values programmatically
pillbox.setValue(['Delta', 'Epsilon']);
Best Practices
Accessibility
- Labels: Always provide visible labels for pillbox inputs
- ARIA: Component includes proper ARIA attributes automatically
- Keyboard: Ensure Backspace, Arrow keys, Enter, and Escape work
- Focus Management: Pills should be removable via keyboard
UX Guidelines
- Max Items: Set reasonable limits (3-10) to prevent overwhelming UI
- Clear Feedback: Show validation errors clearly (onValidationError)
- Duplicate Prevention: Enable
duplicates: falsein most cases - Searchable: Keep searchable enabled for lists > 10 items
- Placeholders: Use descriptive placeholders ("Add skills...", "Type email...")
Data Management
- Value/Label: Use objects with value/label for complex data
- Validation: Implement
validatePillfor format checks (emails, URLs) - Initial Values: Set
valueoption for pre-selected items - onChange: Track changes to sync with your app state/backend
Common Pitfalls
- Avoid: No max limit on creatable fields - users may add too many
- Avoid: Allowing duplicates without clear reason - creates confusion
- Avoid: Not validating created pills - can lead to invalid data
- Avoid: Hiding the clear button on fields that accumulate many items
API Reference
Options
| Option | Type | Default | Description |
|---|---|---|---|
data |
Array | [] |
Available options (strings or {value, label} objects) |
value |
Array | [] |
Initial selected values |
placeholder |
String | 'Add items...' |
Input placeholder text |
searchable |
Boolean | true |
Enable dropdown search filtering |
creatable |
Boolean | false |
Allow creating custom tags |
maxItems |
Number | null |
Maximum number of pills allowed |
duplicates |
Boolean | false |
Allow duplicate values |
clearable |
Boolean | true |
Show clear-all button |
size |
String | 'medium' |
Size variant: 'small', 'medium', 'large' |
renderPill |
Function | null |
Custom pill renderer: (item) => HTML |
renderOption |
Function | null |
Custom option renderer: (item) => HTML |
pillTemplate |
String | '{label}' |
Default pill HTML template |
validatePill |
Function | null |
Validator: (value) => true | string (error message) |
maxItemsMessage |
String | 'Maximum {max} items allowed' |
Message when max items reached |
duplicateMessage |
String | 'Item already exists' |
Message when duplicate detected |
noResultsMessage |
String | 'No results found' |
Empty dropdown message |
onAdd |
Function | null |
Callback when pill added: (value, element) => {} |
onRemove |
Function | null |
Callback when pill removed: (value, element) => {} |
onChange |
Function | null |
Callback on value change: (values) => {} |
onCreate |
Function | null |
Callback when custom pill created: (value) => {} |
onMaxReached |
Function | null |
Callback when max items reached: () => {} |
onValidationError |
Function | null |
Callback on validation error: (error, value) => {} |
Methods
| Method | Returns | Description |
|---|---|---|
getValue() |
Array | Get array of current values |
setValue(values) |
this | Set values (clears existing pills first) |
addPill(value, label) |
this | Add a single pill |
removePill(value) |
this | Remove pill by value |
removePillAt(index) |
this | Remove pill at index |
clear() |
this | Remove all pills |
getCount() |
Number | Get number of pills |
setData(data) |
this | Update available options |
open() |
this | Open dropdown |
close() |
this | Close dropdown |
isOpen() |
Boolean | Check if dropdown is open |
focus() |
this | Focus the input |
enable() |
this | Enable the component |
disable() |
this | Disable the component |
destroy() |
void | Remove component and clean up |
CSS Classes
| Class | Description |
|---|---|
.dm-pillbox |
Wrapper container |
.dm-pillbox-container |
Pills + input container |
.dm-pillbox-input |
The text input |
.dm-pill |
Individual pill element |
.dm-pill-remove |
Remove button on pill |
.dm-pillbox-clear |
Clear all button |
.dm-pillbox-dropdown |
Dropdown container |
.dm-pillbox-options |
Options list (ul) |
.dm-pillbox-option |
Individual option (li) |
.dm-pillbox-option.active |
Currently focused option |
.dm-pillbox-empty |
Empty state message |
.dm-pillbox-small |
Small size variant |
.dm-pillbox-large |
Large size variant |
.dm-pillbox.disabled |
Disabled state |
Config Engine
Use Domma's declarative configuration system to initialize pillbox components from JSON config.
Basic Configuration
$.setup({
'#skills-input': {
component: 'pillbox',
options: {
data: ['JavaScript', 'React', 'Node.js', 'MongoDB'],
maxItems: 5,
duplicates: false
}
}
});
With Creatable Mode
$.setup({
'#tags-input': {
component: 'pillbox',
options: {
data: ['Frontend', 'Backend', 'DevOps'],
creatable: true,
searchable: true,
onCreate: (tag) => {
console.log('Created new tag:', tag);
}
}
}
});
Multiple Instances
$.setup({
'#skills': {
component: 'pillbox',
options: {
data: ['Python', 'Java', 'C++'],
size: 'small',
maxItems: 3
}
},
'#interests': {
component: 'pillbox',
options: {
data: ['Music', 'Sports', 'Reading'],
size: 'large',
creatable: true
}
}
});
Model Integration
Pillbox integrates seamlessly with Domma's reactive Model system (M) for two-way data binding. When bound to a model, the pillbox automatically syncs with model changes and updates the model when user input changes.
Basic Model Binding
<div class="mb-3">
<label for="model-tags" class="form-label">Select Tags:</label>
<input type="text" id="model-tags" class="form-input" placeholder="Add tagsβ¦">
</div>
<div class="alert alert-info pillbox-alert-info-style">
<strong>Current Model Value:</strong> <span id="model-display">[]</span>
</div>/**
* Basic Model Binding - from public/showcase/elements/pillbox.
*/
export function mount(root) {
// Model Integration Demo
const tagsModel = M.create({
selectedTags: {type: 'array', default: []}
});
E.pillbox(root.querySelector('#model-tags'), {
data: ['React', 'Vue', 'Angular', 'Svelte', 'Ember', 'Backbone'],
model: tagsModel,
modelKey: 'selectedTags'
});
// Bind model value display
M.bind(tagsModel, 'selectedTags', '#model-display', {
format: (tags) => tags.length > 0 ? JSON.stringify(tags) : '[]'
});
}// Create a model with an array field
const tagsModel = M.create({
selectedTags: { type: 'array', default: [] }
});
// Bind pillbox to model
Domma.elements.pillbox('#tags', {
data: ['React', 'Vue', 'Angular', 'Svelte'],
model: tagsModel,
modelKey: 'selectedTags'
});
// Display model value elsewhere
M.bind(tagsModel, 'selectedTags', '#tag-display', {
format: (tags) => tags.length > 0 ? tags.join(', ') : 'None selected'
});
Two-Way Synchronisation
Changes to the model automatically update the pillbox, and vice versa:
// Model changes update the pillbox
tagsModel.set('selectedTags', ['React', 'Vue']); // Pills appear
// Adding/removing pills updates the model
// User adds pill β model.set() called β other bindings update
Config Engine Integration
const userModel = M.create({
skills: { type: 'array', default: [] }
});
$.setup({
'#skills': {
component: 'pillbox',
options: {
data: ['JavaScript', 'Python', 'Java'],
model: userModel,
modelKey: 'skills'
}
}
});
Multiple Bindings
Multiple elements can bind to the same model field for synchronised updates:
const formModel = M.create({
tags: { type: 'array', default: [] }
});
// Pillbox component
Domma.elements.pillbox('#tags-input', {
data: tagOptions,
model: formModel,
modelKey: 'tags'
});
// Display tag count
M.bind(formModel, 'tags', '#tag-count', {
format: (tags) => `${tags.length} tag(s) selected`
});
// Display as JSON
M.bind(formModel, 'tags', '#tags-json', {
format: (tags) => JSON.stringify(tags, null, 2)
});
CSS Customisation
Override these CSS variables to customise Pillbox appearance and match your design system.
| Variable | Default | Controls |
|---|---|---|
--dm-pillbox-bg |
var(--dm-surface) |
Pillbox background |
--dm-pillbox-border |
var(--dm-border) |
Pillbox border |
--dm-pill-bg |
var(--dm-primary-light) |
Individual pill background |
--dm-pill-color |
var(--dm-text) |
Pill text colour |
--dm-pill-hover-bg |
currentColor at 15% |
The x on a pill, hovered |
Example Override
/* Solid brand pills. Set them on .dm-pillbox (or anything inside the
themed ) rather than :root, so they follow a theme switch. */
.dm-pillbox {
--dm-pill-bg: var(--dm-primary);
--dm-pill-color: var(--dm-text-inverse);
}
Full CSS Customisation Cheat-Sheet β
Related Elements
Explore related Domma components:
<div class="grid grid-cols-2 gap-4"><a href="/showcase/elements/autocomplete" class="card card-hover">
<div class="card-body">
<h4 class="font-semibold mb-2">Autocomplete</h4>
<p class="text-sm text-muted">Text input with suggestions</p>
</div>
</a>
<a href="/showcase/elements/dropdown" class="card card-hover">
<div class="card-body">
<h4 class="font-semibold mb-2">Dropdown</h4>
<p class="text-sm text-muted">Contextual menus</p>
</div>
</a>
</div>