# Tags Input

> A text input that turns what you type into removable tags, for values with no list to pick from, submitted one entry per tag.

`<slab-tags-input>` · Form · built on `<input> + <button>s`

## Install

```sh
pnpm add slab-elements lit
```

## Usage

### React

`MyTagsInput.jsx`. React 19 or later, which sets props on custom elements as properties.

```jsx
import 'slab-elements/tags-input.js';

export function MyTagsInput() {
  return (
    <slab-tags-input label="Keywords" name="keywords" value="concrete, rebar"></slab-tags-input>
  );
}
```

### Lit

`my-tags-input.ts`. Slab is built on Lit, so its elements are at home in Lit's templates.

```ts
import 'slab-elements/tags-input.js';
import { html, LitElement } from 'lit';
import { customElement } from 'lit/decorators.js';

@customElement('my-tags-input')
export class MyTagsInput extends LitElement {
  render() {
    return html`
      <slab-tags-input label="Keywords" name="keywords" value="concrete, rebar"></slab-tags-input>
    `;
  }
}
```

### Vue

`MyTagsInput.vue`. Let Vue's compiler know slab- tags are custom elements with isCustomElement.

```vue
<script setup>
import 'slab-elements/tags-input.js';
</script>

<template>
  <slab-tags-input label="Keywords" name="keywords" value="concrete, rebar"></slab-tags-input>
</template>
```

### Svelte

`MyTagsInput.svelte`. Svelte binds properties and events to custom elements like any other element.

```svelte
<script>
  import 'slab-elements/tags-input.js';
</script>

<slab-tags-input label="Keywords" name="keywords" value="concrete, rebar"></slab-tags-input>
```

### HTML

`index.html`. Any bundler (or an import map) takes care of the imports.

```html
<script type="module">
  import 'slab-elements/tags-input.js';
</script>

<slab-tags-input label="Keywords" name="keywords" value="concrete, rebar"></slab-tags-input>
```

## Example

Here's a styled version, `<slab-styled-tags-input>`. It's not in the package — it's an example of what you can build with the headless element. Copy it and make it yours at the bottom of this page.

```html
<slab-styled-tags-input label="Pour tags" value="footing, east wing" placeholder="Add a tag" max="5">
  <span slot="description">Press Enter or type a comma. Try pasting "rebar, cure, slump".</span>
</slab-styled-tags-input>
```

Swap `slab-styled-` for `slab-` in that markup and you've got the same example as it ships: the headless elements with no CSS at all. That's your starting point.

Enter adds what's typed as a tag, and so does typing `delimiter`, a comma unless you change it, so a pasted list becomes several tags at once. Tags are trimmed, and a tag that's already there isn't added twice. With `max`, no more are added once there are that many, and what didn't fit stays in the input. What's typed is added when focus leaves too, so it isn't lost on a submit.

Each tag is a button before the input that removes it, named "Remove" and the tag unless you set `removeLabel`. `values` lists the tags in the order they were added, and each is submitted as its own entry, the way a `<select multiple>` submits them. The `value` attribute is the starting tags separated by commas, as on an `<input type="email" multiple>`. To pick tags from a list instead, use [Combobox](combobox.md) with `multiple`, whose tags work the same way.

Style a field with no tags through `:state(empty)` and one with `max` tags through `:state(full)`.

## Keyboard

| Key                             | What it does                                                                                    |
| ------------------------------- | ----------------------------------------------------------------------------------------------- |
| Enter                           | Adds what's typed as a tag, or submits the form when nothing is typed.                          |
| `delimiter`                     | Adds what's typed before it as a tag.                                                           |
| Escape                          | Clears what's typed.                                                                            |
| Backspace                       | In an empty input, removes the last tag.                                                        |
| Left                            | At the start of the input, moves to the last tag.                                               |
| Left, Right                     | On a tag, moves to the previous or next one. Right from the last goes back to the input.        |
| Backspace, Delete, Enter, Space | On a tag, removes it and moves to the one that takes its place, or the input when there's none. |
| Escape                          | On a tag, goes back to the input.                                                               |
| Tab, Shift+Tab                  | Moves out of the field. The tags aren't Tab stops.                                              |

## Behavior

A text input that turns what's typed into tags, with no visual styles. It's for values with no list to pick from, such as keywords or email addresses. When there is a list, use Combobox with `multiple`, whose tags work the same way.

Enter adds what's typed as a tag, and so does typing `delimiter`, a comma unless it's changed, so "red, green, blue" pasted in is three tags. What's typed is trimmed, and text that's empty, or a tag already there, adds nothing. With `max`, no more are added once there are that many, and what didn't fit is left in the input. When focus leaves, what's typed is added too, so it isn't lost on a submit.

Each tag is a `<button>` before the input that removes it. Backspace in an empty input removes the last, and Left at the start of the input moves focus to the tags, where Left and Right move between them and Backspace, Delete, Enter and Space remove one. They aren't Tab stops, so Tab still leaves in one press. Enter in an empty input submits the form, as it does in a native one.

`values` lists the tags in the order they were added, and each is submitted as its own entry, the way a `<select multiple>` submits them. The `value` attribute is the initial tags separated by commas, as on an `<input type="email" multiple>`, and the `value` property is the current ones, joined by commas. "input" and "change" are dispatched together when a tag is added or removed, rather than on every keystroke.

Style it through its parts and through `:state(disabled)`, `:state(empty)` when it has no tags, `:state(error)`, `:state(full)` when it has `max` tags, and `:state(required)`.

## API

### `<slab-tags-input>`

#### Attributes

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `label` (required) | `string` |  |  |
| `delimiter` | `string` | `','` | Typed or pasted, adds what's before it as a tag. Only Enter does when it's empty. |
| `disabled` | `boolean` | `false` |  |
| `hide-label` | `boolean` | `false` |  |
| `max` | `number` |  | The most tags the user can add. A `values` set with more keeps them all. |
| `name` | `string` | `''` |  |
| `placeholder` | `string` |  |  |
| `required` | `boolean` | `false` |  |
| `value` | `string` | `''` | The tags, separated by commas, each trimmed. Like a native input, the attribute is the default value and the property is the current one. Use `values` for tags with commas in them. |
| `version` | `string` |  |  |

#### Properties

| Name | Type | Description |
| --- | --- | --- |
| `removeLabel` | `(text: string) => string` | Names each tag's button, which shows only the tag, from the tag. "Remove" and the tag when unset. |
| `values` | `string[]` | Every tag, in the order they were added. |
| `form` (read-only) | `HTMLFormElement \| null` |  |
| `validity` (read-only) | `ValidityState` |  |

#### Methods

| Signature | Description |
| --- | --- |
| `checkValidity(): boolean` |  |
| `focus(options?: FocusOptions): void` |  |
| `reportValidity(): boolean` |  |
| `resetValidityFeedback(): void` | Clears the validity feedback without changing `validity`. |
| `setCustomValidity(message: string): void` |  |
| `setValidity(flags?: ValidityStateFlags, message?: string): void` |  |

#### Events

| Name | Type | Description |
| --- | --- | --- |
| `input` | `Event` |  |
| `change` | `Event` |  |

#### Slots

| Name | Type | Description |
| --- | --- | --- |
| `description` | `Element \| string` |  |

#### CSS parts

| Name | Description |
| --- | --- |
| `description` | Text about the input, and its validity message when it has one |
| `input` |  |
| `label` | The label |
| `tag` | A tag's button, which removes it |
| `tags` | The tags |
| `validity-message` |  |

## Copy and own it

`<slab-styled-tags-input>` isn't in the package. It's an example — copy it into your project and change whatever you want. Keyboard, focus, ARIA, forms, and events all come from the headless element, and the styles only reach in through `::part()` and `:state()`, same as yours would.

### Lit

Under the hood, it's just the headless element, subclassed, with `styles` set. That's it. To use it, copy these files, swap the tag in `@customElement()` for one of your own, and import it. `tokens.styles.ts` holds the colors and fonts every styled example shares, so you only need it once.

#### TypeScript

These files are TypeScript, and `@customElement()` is one of Lit's decorators (TypeScript's experimental flavor). You'll need two options in your tsconfig, per [Lit's docs](https://lit.dev/docs/components/decorators/#decorators-typescript): `experimentalDecorators` to compile them, and `useDefineForClassFields` turned off so class fields don't stomp on the accessors Lit sets up.

```json
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "useDefineForClassFields": false
  }
}
```

Using plain JavaScript? Drop the types and register the class with `customElements.define()` instead of the decorator.

#### tags-input.ts

```ts
import { customElement } from 'lit/decorators.js';
import TagsInput from 'slab-elements/tags-input.js';
import textInput from './text-input.styles.js';
import styles from './tags-input.styles.js';
import tokens from './tokens.styles.js';

declare global {
  interface HTMLElementTagNameMap {
    'slab-styled-tags-input': StyledTagsInput;
  }
}

/**
 * Tags Input, styled: Text Field's input, with its tags as bordered chips
 * above it, as Combobox's are.
 */
@customElement('slab-styled-tags-input')
export default class StyledTagsInput extends TagsInput {
  static override styles = [tokens, textInput, styles];
}
```

#### text-input.styles.ts

```ts
import { css } from 'lit';

/*
  The look shared by everything typed into: Text Field, Textarea, Number, Date
  Field, Time Field, Combobox and Tags Input, whose fields are
  "::part(input)", or "::part(textarea)" for Textarea. File Drop starts from it
  too, for its label and description.
*/
export default css`
  :host {
    color: var(--ink);
    display: grid;
    font-family: var(--font-body);
  }

  [part~='label'] {
    font-family: var(--font-display);
    font-size: 1.0625rem;
    font-weight: 600;
    margin-block-end: 0.375rem;
  }

  :host(:state(required)) [part~='label']::after {
    color: var(--ember-deep);
    content: ' *' / '';
  }

  [part~='input'],
  [part~='textarea'] {
    background-color: var(--paper);
    block-size: 3rem;
    border: var(--border);
    border-radius: 0;
    box-sizing: border-box;
    color: inherit;
    font: inherit;

    /* A fixed height, shared with Select, so fields line up in a row. */
    padding: 0 0.75rem;
    transition:
      background-color 100ms,
      border-color 100ms,
      box-shadow 100ms,
      translate 100ms;

    &::placeholder {
      color: var(--ash);
    }

    /*
      The outline is transparent rather than gone, so it still shows in forced
      colors, where box shadows don't.
    */
    &:focus-visible {
      box-shadow: 4px 4px 0 var(--ember);
      outline: 2px solid transparent;
      translate: -2px -2px;
    }

    &:disabled {
      background-color: var(--chalk);
      border-style: dashed;
      color: var(--ash);
      cursor: not-allowed;
    }
  }

  [part~='textarea'] {
    block-size: auto;
    min-block-size: 7rem;
    padding: 0.625rem 0.75rem;
    resize: vertical;
  }

  :host(:state(readonly)) [part~='input'],
  :host(:state(readonly)) [part~='textarea'] {
    background-color: var(--chalk);
  }

  :host(:state(error)) [part~='input'],
  :host(:state(error)) [part~='textarea'] {
    border-color: var(--ember-deep);
  }

  /*
    Spaced by what's in it rather than by a gap, so a description with
    nothing in it takes no room, and a control beside the field lines up
    with its bottom edge.
  */
  [part~='description'] {
    color: var(--ash);
    display: grid;
    font-size: 0.875rem;
  }

  ::slotted([slot='description']) {
    margin-block-start: 0.375rem;
  }

  [part~='validity-message'] {
    color: var(--ember-deep);
    font-weight: 600;
    margin-block-start: 0.375rem;

    &::before {
      content: '■ ' / '';
    }
  }

  @media (prefers-reduced-motion: reduce) {
    [part~='input'],
    [part~='textarea'] {
      transition: none;
    }
  }
`;
```

#### tags-input.styles.ts

```ts
import { css } from 'lit';

/*
  Text Field's input, with the tags as bordered chips above it, as Combobox's
  are with "multiple".
*/
export default css`
  /*
    The tags sit in a row between the label and the input, rather than inside
    it, so the input keeps Text Field's fixed height.
  */
  [part~='tags'] {
    column-gap: 0.375rem;
    display: flex;
    flex-wrap: wrap;
  }

  [part~='tag'] {
    align-items: center;
    background-color: var(--paper);
    border: var(--border);
    border-radius: 0;
    color: var(--ink);
    cursor: pointer;
    display: inline-flex;
    font: inherit;
    font-size: 0.875rem;
    font-weight: 600;
    gap: 0.5rem;

    /* Below each tag rather than below the row, so no tags take no room. */
    margin-block-end: 0.375rem;
    padding: 0.125rem 0.5rem;
    transition:
      background-color 100ms,
      color 100ms;

    /* The cross: what a click does. */
    &::after {
      content: '×' / '';
      font-size: 1.125rem;
      line-height: 1;
    }

    &:hover {
      background-color: var(--chalk);
    }

    &:focus-visible {
      background-color: var(--ember-deep);
      color: var(--paper);
      outline: 2px solid transparent;
    }

    &:disabled {
      background-color: var(--chalk);
      border-style: dashed;
      color: var(--ash);
      cursor: not-allowed;
    }

    @media (prefers-reduced-motion: reduce) {
      transition: none;
    }
  }
`;
```

#### tokens.styles.ts

```ts
import { css } from 'lit';

/*
  The styled components' palette and type, taken from the logo: ink for the
  courses, ember for the base, paper behind them.

  Each is a short alias for a "--slab-styled-*" property, with the default as
  its fallback. Retheming is "--slab-styled-ember" and the rest on ":root",
  which every shadow root inherits. Were the defaults declared under those
  names here instead, each host's own declaration would shadow the page's.

  Each default is "light-dark()", so the components follow the page's
  "color-scheme": "dark" for dark, "light" for light, and "light dark" to
  follow the operating system. Ink and paper trade places in the dark, so
  ink is always the text and paper always what's behind it. Contrast is given
  as light, then dark.
*/
export default css`
  :host {
    /* Secondary text. 6.8:1 and 6.9:1 on paper. */
    --ash: var(--slab-styled-ash, light-dark(#5e5a55, #a8a19a));
    --border: 2px solid var(--ink);

    /* Behind disabled and read-only controls, and hovered rows. */
    --chalk: var(--slab-styled-chalk, light-dark(#f4f1ec, #2b2926));

    /*
      The logo's orange, brighter in the dark. 4.25:1 and 5.2:1 on paper is
      enough for borders, fills and large text, but not always for small text,
      which takes "--ember-deep".
    */
    --ember: var(--slab-styled-ember, light-dark(#d9492a, #ec5f3b));

    /*
      Orange for small text on paper, and for paper text on it. 5.7:1 and
      7.4:1.
    */
    --ember-deep: var(--slab-styled-ember-deep, light-dark(#b3381a, #ff8566));
    --font-body: var(
      --slab-styled-font-body,
      'IBM Plex Sans',
      system-ui,
      sans-serif
    );
    --font-display: var(
      --slab-styled-font-display,
      'Zilla Slab',
      rockwell,
      'Roboto Slab',
      serif
    );
    --font-mono: var(
      --slab-styled-font-mono,
      'IBM Plex Mono',
      ui-monospace,
      monospace
    );
    --ink: var(--slab-styled-ink, light-dark(#1c1c1c, #f4f1ec));
    --paper: var(--slab-styled-paper, light-dark(#fff, #1a1918));

    /* The hard, offset shadow a raised surface casts. */
    --shadow: 4px 4px 0 var(--ink);
  }
`;
```

### React, Vue and Svelte

The same styles, written from outside the element, so there's nothing to subclass. The CSS styles every `<slab-tags-input>` on the page. Import `tokens.css`, the colors and fonts every styled example shares, once, wherever your global styles go.

In React and Vue, each component registers its headless element and imports its CSS. Use it like the element: its props are the element's attributes and properties.

In Svelte, import `tags-input.js` once, such as in your root layout, and use `<slab-tags-input>` as is. A Svelte component around it would take `slot="…"` content as its own slot and drop it.

#### TagsInput.jsx

```jsx
import 'slab-elements/tags-input.js';
import './tags-input.css';

export function TagsInput(props) {
  return <slab-tags-input {...props} />;
}
```

#### TagsInput.vue

```vue
<script setup>
import 'slab-elements/tags-input.js';
import './tags-input.css';
</script>

<template>
  <slab-tags-input><slot /></slab-tags-input>
</template>
```

#### tags-input.js

```js
import 'slab-elements/tags-input.js';
import './tags-input.css';
```

#### tags-input.css

```css
slab-tags-input {
  color: var(--ink);
  display: grid;
  font-family: var(--font-body);
}

slab-tags-input::part(label) {
  font-family: var(--font-display);
  font-size: 1.0625rem;
  font-weight: 600;
  margin-block-end: 0.375rem;
}

slab-tags-input:state(required)::part(label)::after {
  color: var(--ember-deep);
  content: ' *' / '';
}

slab-tags-input::part(input),
slab-tags-input::part(textarea) {
  background-color: var(--paper);
  block-size: 3rem;
  border: var(--border);
  border-radius: 0;
  box-sizing: border-box;
  color: inherit;
  font: inherit;
  /* A fixed height, shared with Select, so fields line up in a row. */
  padding: 0 0.75rem;
  transition:
    background-color 100ms,
    border-color 100ms,
    box-shadow 100ms,
    translate 100ms;
}

slab-tags-input::part(input)::placeholder,
slab-tags-input::part(textarea)::placeholder {
  color: var(--ash);
}

/*
  The outline is transparent rather than gone, so it still shows in forced
  colors, where box shadows don't.
*/
slab-tags-input::part(input):focus-visible,
slab-tags-input::part(textarea):focus-visible {
  box-shadow: 4px 4px 0 var(--ember);
  outline: 2px solid transparent;
  translate: -2px -2px;
}

slab-tags-input::part(input):disabled,
slab-tags-input::part(textarea):disabled {
  background-color: var(--chalk);
  border-style: dashed;
  color: var(--ash);
  cursor: not-allowed;
}

slab-tags-input::part(textarea) {
  block-size: auto;
  min-block-size: 7rem;
  padding: 0.625rem 0.75rem;
  resize: vertical;
}

slab-tags-input:state(readonly)::part(input),
slab-tags-input:state(readonly)::part(textarea) {
  background-color: var(--chalk);
}

slab-tags-input:state(error)::part(input),
slab-tags-input:state(error)::part(textarea) {
  border-color: var(--ember-deep);
}

/*
  Spaced by what's in it rather than by a gap, so a description with
  nothing in it takes no room, and a control beside the field lines up
  with its bottom edge.
*/
slab-tags-input::part(description) {
  color: var(--ash);
  display: grid;
  font-size: 0.875rem;
}

slab-tags-input > [slot='description'] {
  margin-block-start: 0.375rem;
}

slab-tags-input::part(validity-message) {
  color: var(--ember-deep);
  font-weight: 600;
  margin-block-start: 0.375rem;
}

slab-tags-input::part(validity-message)::before {
  content: '■ ' / '';
}

@media (prefers-reduced-motion: reduce) {
  slab-tags-input::part(input),
  slab-tags-input::part(textarea) {
    transition: none;
  }
}

/*
  The tags sit in a row between the label and the input, rather than inside
  it, so the input keeps Text Field's fixed height.
*/
slab-tags-input::part(tags) {
  column-gap: 0.375rem;
  display: flex;
  flex-wrap: wrap;
}

slab-tags-input::part(tag) {
  align-items: center;
  background-color: var(--paper);
  border: var(--border);
  border-radius: 0;
  color: var(--ink);
  cursor: pointer;
  display: inline-flex;
  font: inherit;
  font-size: 0.875rem;
  font-weight: 600;
  gap: 0.5rem;
  /* Below each tag rather than below the row, so no tags take no room. */
  margin-block-end: 0.375rem;
  padding: 0.125rem 0.5rem;
  transition:
    background-color 100ms,
    color 100ms;
}

/* The cross: what a click does. */
slab-tags-input::part(tag)::after {
  content: '×' / '';
  font-size: 1.125rem;
  line-height: 1;
}

slab-tags-input::part(tag):hover {
  background-color: var(--chalk);
}

slab-tags-input::part(tag):focus-visible {
  background-color: var(--ember-deep);
  color: var(--paper);
  outline: 2px solid transparent;
}

slab-tags-input::part(tag):disabled {
  background-color: var(--chalk);
  border-style: dashed;
  color: var(--ash);
  cursor: not-allowed;
}

@media (prefers-reduced-motion: reduce) {
  slab-tags-input::part(tag) {
    transition: none;
  }
}
```

#### tokens.css

```css
:root {
  /* Secondary text. 6.8:1 and 6.9:1 on paper. */
  --ash: var(--slab-styled-ash, light-dark(#5e5a55, #a8a19a));
  --border: 2px solid var(--ink);

  /* Behind disabled and read-only controls, and hovered rows. */
  --chalk: var(--slab-styled-chalk, light-dark(#f4f1ec, #2b2926));

  /*
    The logo's orange, brighter in the dark. 4.25:1 and 5.2:1 on paper is
    enough for borders, fills and large text, but not always for small text,
    which takes "--ember-deep".
  */
  --ember: var(--slab-styled-ember, light-dark(#d9492a, #ec5f3b));

  /*
    Orange for small text on paper, and for paper text on it. 5.7:1 and
    7.4:1.
  */
  --ember-deep: var(--slab-styled-ember-deep, light-dark(#b3381a, #ff8566));
  --font-body: var(
    --slab-styled-font-body,
    'IBM Plex Sans',
    system-ui,
    sans-serif
  );
  --font-display: var(
    --slab-styled-font-display,
    'Zilla Slab',
    rockwell,
    'Roboto Slab',
    serif
  );
  --font-mono: var(
    --slab-styled-font-mono,
    'IBM Plex Mono',
    ui-monospace,
    monospace
  );
  --ink: var(--slab-styled-ink, light-dark(#1c1c1c, #f4f1ec));
  --paper: var(--slab-styled-paper, light-dark(#fff, #1a1918));

  /* The hard, offset shadow a raised surface casts. */
  --shadow: 4px 4px 0 var(--ink);
}
```
