Canvas Design System
Main Site Tokens

Chip Input

Collects several values into removable tokens — the markup and styling only; the behaviour is yours to write.

When to Use

Use when: A field holds an open-ended list the user builds up — attendee emails, tags, keywords. Each value becomes a token they can see and remove individually.
Don't use when: The field holds one value (Input) or the options are known in advance (Select or Checkbox Card Group). For a read-only tag, use Pill.
warning This component ships no JavaScript. components.js contains nothing for chips. The helper renders a labelled field, an add button, and an empty list container — adding, removing, validating, and serialising values for submit are all yours to implement. Budget for that, and see Accessibility for what your handlers must do.

Variants

With a label and add text

Giving the add button text as well as an icon is the accessible default — an icon-only button here has no accessible name.

<?= ws_chip_input('attendees', [
    'label'       => 'Attendees',
    'placeholder' => 'Enter email address',
    'type'        => 'email',
    'addIcon'     => 'person_add',
    'addText'     => 'Add',
]) ?>

Tag input, no label

Omitting the label is only safe when a heading beside the field already names it. Otherwise the input has nothing to announce.

<?= ws_chip_input('tags', ['placeholder' => 'Add tag…', 'addText' => 'Add']) ?>

Chip markup

Your JavaScript renders chips into the __list container. This is the structure the CSS expects — note the aria-label on the remove button, which names which chip it removes.

alice@example.com
bob@example.com
<div class="ws-chip">
    <span>alice@example.com</span>
    <button class="ws-chip__remove" type="button"
            aria-label="Remove alice@example.com">
        <span class="material-symbols-outlined">close</span>
    </button>
</div>

States

StateBehavior
EmptyAs rendered: an empty .ws-chip-input__list and a field showing the placeholder.
With chipsEntirely your code's doing. Nothing populates the list server-side, so pre-existing values need rendering into it on page load.
HoverThe field border moves from --border-default to --border-hover; the add button surface shifts to --surface-hover.
FocusThe inner input is a standard .ws-input, so it inherits Input's focus ring.
Invalid entryNo validation is provided. With type: 'email' the input gets native email semantics, but nothing checks a value before it becomes a chip — validate in your add handler.
Duplicate entryNot prevented. Guard against it yourself, or the same email can be added repeatedly.
DisabledNot supported. There's no disabled option on the helper.

Real-World Usage

Inviting participants to a session. The sketch below covers what the component leaves to you: Enter-to-add, validation, deduplication, an accessible remove button, and a hidden field so the values actually submit.

const field = document.getElementById('invitees');
const list  = document.getElementById('invitees-list');
const store = document.getElementById('invitees-value');  // your hidden input
const emails = [];

function addEmail() {
    const value = field.value.trim();
    if (!value || !field.checkValidity()) return;   // validate
    if (emails.includes(value)) { field.value = ''; return; }   // dedupe

    emails.push(value);
    store.value = emails.join(',');                  // so the form submits it

    const chip = document.createElement('div');
    chip.className = 'ws-chip';
    chip.innerHTML = `<span>${escapeHtml(value)}</span>`;

    const remove = document.createElement('button');
    remove.type = 'button';
    remove.className = 'ws-chip__remove';
    remove.setAttribute('aria-label', `Remove ${value}`);   // names the chip
    remove.innerHTML = '<span class="material-symbols-outlined">close</span>';
    remove.addEventListener('click', () => {
        emails.splice(emails.indexOf(value), 1);
        store.value = emails.join(',');
        chip.remove();
        field.focus();          // never strand focus on a removed element
    });

    chip.appendChild(remove);
    list.appendChild(chip);
    field.value = '';
    field.focus();
}

// Enter should add — users will expect it, and it isn't wired for you.
field.addEventListener('keydown', e => {
    if (e.key === 'Enter') { e.preventDefault(); addEmail(); }
});

Options

OptionTypeDefaultPurpose
$namestring''Positional. Field name and the ID prefix — the list container becomes {name}-list.
labelstringnullLabel text
placeholderstring'Add item...'Input placeholder
typestring'text'email | text. Sets the input type, giving email fields the right keyboard on mobile.
addIconstring'add'Icon on the add button
addTextstringnullText on the add button. Without it the button is icon-only and falls back to an aria-label derived from label.
removeIconstring'close'Intended for the chip remove button. Since chips are yours to render, this is advisory.
idstring$nameElement ID
classstring''Additional CSS classes
attrsarray[]Extra HTML attributes

Accessibility

Because the behaviour is yours, so is most of the accessibility. The list below separates what the component gives you from what your code has to add.

ConcernBehavior
Label bindingCorrectly wired: the template emits <label for="{id}-input"> bound to the field. Passing label gives the input a proper accessible name — unlike Form Row, which doesn't bind. Omit label and the field is unnamed, so supply one or pass attrs => ['aria-label' => …].
Add buttonA real <button type="button">, so it won't accidentally submit the form. With addText it's named by its visible text; without, it falls back to aria-label="Add {label}" so an icon-only button still has a name.
Enter to addNot implemented. Users will press Enter; wire it, and call preventDefault() so it doesn't submit the form.
Remove buttonsYours to render. Give each an aria-label naming the value — "Remove alice@example.com". A column of buttons all labelled "Remove" is unusable by screen reader.
Focus after removalRemoving a chip destroys the focused element and drops focus to <body>. Move focus back to the input, as in the example above.
Announcing changesChips appear and disappear silently. Consider a visually-hidden aria-live="polite" region announcing "alice@example.com added" so the action is confirmed non-visually.
Submitting valuesChips are <div>s, not form controls — they submit nothing. Maintain a hidden input, or the user's carefully built list is lost on submit.

Tokens

TokenUsed for
--surface-default / --surface-hoverChip and add-button backgrounds
--border-default / --border-hoverField and chip borders
--color-inkChip and label text
--radius-smChip corners
--space-2Gap between chips and internal padding
--text-small / --text-h4Chip text and label type scale

CSS Classes

ClassPurpose
.ws-chip-input__labelField label, bound to the input via for="{id}-input"
.ws-chip-input__fieldRow holding the input and add button
.ws-chip-input__addAdd button
.ws-chip-input__listContainer your chips render into, id="{name}-list"
.ws-chipAn individual chip. Styled here, rendered by you.
.ws-chip__removeChip remove button

The inner input also carries .ws-input, so it inherits every Input state and token.

Files

FilePurpose
includes/components/helpers.phpws_chip_input() helper function
includes/components/chip-input.phpTemplate — label, field, add button, empty list
includes/components/components.cssStyles (.ws-chip and .ws-chip-input rules)

There is deliberately no JS file in this list — the interaction layer doesn't exist yet.