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.
<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>
<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
pnpm add slab-elements lit
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>
| 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 |
| 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 |
| 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
|
| Name | Type | Description |
|---|---|---|
input
|
Event |
|
change
|
Event |
| Name | Type | Description |
|---|---|---|
description
|
Element | string |
| 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.
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);
}