# PillsInput (/docs/pills-input)





`PillsInput` is the input shell for multi-value fields. It wears the same chrome as [`Input`](/docs/input) — border, focus ring, invalid / disabled states — but **wraps** as values pile up, laying out [`Pill`](/docs/pill)s next to a search field. It's the target [`MultiSelect`](/docs/multi-select) and [`TagsInput`](/docs/tags-input) render into; reach for it directly when you're wiring your own multi-value control.

```tsx
import { PillsInput } from '@42-central/ui-react/pills-input';
import { Pill } from '@42-central/ui-react/pill';

<PillsInput>
  {values.map((v) => (
    <Pill key={v} withRemoveButton onRemove={() => remove(v)}>{v}</Pill>
  ))}
  <PillsInput.Field placeholder="Add a skill…" />
</PillsInput>
```

> Presentational by design — it holds no value state. Drive the values yourself, or let `MultiSelect` / `TagsInput` do it.

## Playground [#playground]

<story.Playground />

## Anatomy [#anatomy]

Host `Pill`s and a single `PillsInput.Field` inside. The shell sets the pill `size` and `disabled` through context, so the pills match the field with no extra props. `PillsInput.Field` grows to fill the current line and pops the last value on Backspace-when-empty.

```tsx
'use client';
import { useState } from 'react';
import { PillsInput } from '@42-central/ui-react/pills-input';
import { Pill } from '@42-central/ui-react/pill';

export function TagField() {
  const [values, setValues] = useState<string[]>(['c', 'unix']);
  const [search, setSearch] = useState('');

  const add = (raw: string) => {
    const v = raw.trim();
    if (v && !values.includes(v)) setValues((cur) => [...cur, v]);
    setSearch('');
  };

  return (
    <PillsInput>
      {values.map((v) => (
        <Pill key={v} withRemoveButton onRemove={() => setValues((cur) => cur.filter((x) => x !== v))}>
          {v}
        </Pill>
      ))}
      <PillsInput.Field
        value={search}
        placeholder={values.length === 0 ? 'Add a skill…' : ''}
        onChange={(e) => setSearch(e.currentTarget.value)}
        onKeyDown={(e) => {
          if (e.key === 'Enter') { e.preventDefault(); add(search); }
        }}
        onRemoveLast={() => setValues((cur) => cur.slice(0, -1))}
      />
    </PillsInput>
  );
}
```

## Sizes & variants [#sizes--variants]

`size` (xs–xl) and `variant` (`default` / `unstyled`) match [`Input`](/docs/input) — try them in the Playground above. The shell grows in height as pills wrap, and the pills scale to the chosen size automatically.

## Max lines [#max-lines]

`maxLines&#x60; caps the pills at N rows; any beyond collapse into a static **+N** counter (measured, so it adapts to the field width). The convention is **pills first, the field last** — only the pills are capped; the field always stays visible. Since `PillsInput` has no dropdown, the hidden pills aren't individually removable while capped, so it's best when you also surface the full set elsewhere.

<story.Preview variant="Max lines" />

## Start & end slots [#start--end-slots]

`startSlot` and `endSlot` flank the pills region with the same slot layout as [`Input`](/docs/input) — drop in a leading icon or a trailing control. They sit outside the wrapping pills, so a trailing control stays pinned as the pills wrap.

## In Field [#in-field]

Like the rest of the family, wrap it in [`Field`](/docs/field) for a label, description, and error:

```tsx
<Field label="Tags" description="Press Enter to add">
  <TagField />
</Field>
```

## API [#api]

### PillsInput [#pillsinput]

<TypeTable
  type="{
  size: {
    type: '&#x22;xs&#x22; | &#x22;sm&#x22; | &#x22;md&#x22; | &#x22;lg&#x22; | &#x22;xl&#x22;',
    default: '&#x22;md&#x22;',
    description: 'Shell sizing; also sizes the hosted pills via context.',
  },
  variant: {
    type: '&#x22;default&#x22; | &#x22;unstyled&#x22;',
    default: '&#x22;default&#x22;',
    description: 'Shell chrome, matching Input.',
  },
  color: { type: 'Color', description: 'Focus-ring accent.' },
  invalid: { type: 'boolean', default: 'false', description: 'Red border + ring.' },
  disabled: {
    type: 'boolean',
    default: 'false',
    description: 'Dims the shell and disables the hosted pills + field.',
  },
  maxLines: { type: 'number', description: 'Cap the pills at N rows; the rest collapse into a static &#x22;+N&#x22; counter.' },
  startSlot: { type: 'ReactNode', description: 'Inline-start adornment (icon, label).' },
  endSlot: { type: 'ReactNode', description: 'Inline-end adornment, pinned past the wrapping pills.' },
  classNames: { type: '{ root?, content?, startSlot?, endSlot? }', description: 'Per-part class overrides.' },
  testId: { type: 'string', description: 'data-testid on the shell div itself — meaningful mainly when PillsInput is used bare, without a nested control. See the Testing guide.' },
}"
/>

### PillsInput.Field [#pillsinputfield]

<TypeTable
  type="{
  onRemoveLast: {
    type: '() => void',
    description: 'Fired on Backspace when the field is empty — pop the last value.',
  },
  testId: {
    type: 'string',
    description: &#x22;data-testid on the real underlying <input>, distinct from the shell's own testId. See the Testing guide.&#x22;,
  },
}"
/>

`PillsInput.Field` forwards every other prop to the underlying `<input>` (`value`, `onChange`, `placeholder`, `onKeyDown`, …). `PillsInput` forwards to its root `div`.

`PillsInput` and `PillsInput.Field` each take their own `testId` prop landing on different elements: the base `PillsInput`'s `testId` lands on its own shell `<div>` — useful mainly when it's used bare — while `PillsInput.Field`'s `testId` lands on the real `<input>`. See the [Testing](/docs/testing) guide.

## Accessibility [#accessibility]

* `PillsInput.Field` is a real `<input>` — label it via [`Field`](/docs/field) (or `aria-label`).
* Backspace on the empty field removes the last value (`onRemoveLast`), matching the expected tag-field interaction; each pill's × is independently focusable and labelled.
* RTL: the pills region is a wrapping flex row with logical start/end slots, so pills, field, and any adornments flow correctly under `dir="rtl"`.
