Slab

Form

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.

Element
<slab-tags-input>
Built on
<input> + <button>s
As Markdown
tags-input.md

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. Switch to "As it ships" to see the same markup with the headless element and no CSS at all. That's your starting point.

Styled example As it ships
Press Enter or type a comma. Try pasting "rebar, cure, slump".
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>
Press Enter or type a comma. Try pasting "rebar, cure, slump".
HTML
<slab-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-tags-input>

Usage

Install
pnpm add slab-elements lit
ReactLitVueSvelteHTML
import 'slab-elements/tags-input.js';

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

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

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>
    `;
  }
}

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

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

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

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

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

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

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

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

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

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

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 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.

ReactLitVueSvelte

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.

Each component registers its headless element and imports its CSS. Use it like the element: its props are the element's attributes and properties.

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

export function TagsInput(props) {
  return <slab-tags-input {...props} />;
}
tags-input.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
: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);
}

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.

tags-input.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
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
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
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);
  }
`;

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.

Each component registers its headless element and imports its CSS. Use it like the element: its props are the element's attributes and properties.

TagsInput.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.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
: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);
}

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.

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.

tags-input.js
import 'slab-elements/tags-input.js';
import './tags-input.css';
tags-input.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
: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);
}