Kosmesis
Components

Tags Input

A free-text chip input — type + Enter/comma to add, Backspace to remove the last. Purely presentational — no Morphos equivalent.

Installation

npx kosmesis add tags-input
pnpm dlx kosmesis add tags-input
yarn dlx kosmesis add tags-input
bunx kosmesis add tags-input

Install the following dependencies:

npm install @morphos/icons
pnpm add @morphos/icons
yarn add @morphos/icons
bun add @morphos/icons

Copy and paste the following code into your project.

tags-input.tsx
import { StatefulComponent } from "@praxisjs/core";import { Component, Emit, FunctionProp, Prop, Ref, State, type Ref as RefType } from "@praxisjs/decorators";import { Icon } from "@morphos/icons";import { cn } from "@/lib/utils";export interface TagsInputProps {  value?: string[];  defaultValue?: string[];  onChange?: (tags: string[]) => void;  placeholder?: string;  disabled?: boolean;  max?: number;  class?: string;  id?: string;  "aria-label"?: string;}/** `value` accepts a plain array (uncontrolled) or a getter function (controlled). */@Component()export class TagsInput extends StatefulComponent {  @Prop() value?: string[];  @Prop() defaultValue?: string[];  @Prop() placeholder = "Add tag...";  @Prop() disabled = false;  @Prop() max?: number;  @Prop() class?: string;  @Prop() id?: string;  @Prop() "aria-label"?: string;  @FunctionProp() onChange?: TagsInputProps["onChange"];  @Ref<HTMLInputElement>()  inputRef!: RefType<HTMLInputElement>;  @State() _tags: string[] = [];  @State() _draft = "";  onBeforeMount() {    this._tags = this.defaultValue ?? this.value ?? [];  }  get tags(): string[] {    return this.value ?? this._tags;  }  @Emit("onChange")  private commit(next: string[]): string[] {    if (this.value === undefined) this._tags = next;    return next;  }  addTag(raw: string): void {    const tag = raw.trim();    if (!tag || this.disabled || this.tags.includes(tag)) {      this._draft = "";      return;    }    if (this.max !== undefined && this.tags.length >= this.max) return;    this.commit([...this.tags, tag]);    this._draft = "";  }  removeTag(tag: string): void {    if (this.disabled) return;    this.commit(this.tags.filter((t) => t !== tag));  }  private readonly _handleKeyDown = (event: KeyboardEvent) => {    if (event.key === "Enter" || event.key === ",") {      event.preventDefault();      this.addTag(this._draft);    } else if (event.key === "Backspace" && this._draft === "" && this.tags.length > 0) {      this.removeTag(this.tags[this.tags.length - 1]);    }  };  render() {    return (      <div        id={this.id}        role="group"        aria-label={this["aria-label"] ?? "Tags"}        data-disabled={() => (this.disabled ? "" : undefined)}        class={cn(          "flex min-h-9 w-full flex-wrap items-center gap-1.5 rounded-md border border-input bg-transparent px-2 py-1.5 shadow-xs transition-[color,box-shadow]",          "focus-within:ring-[3px] focus-within:ring-ring/50 focus-within:border-ring",          "data-disabled:cursor-not-allowed data-disabled:opacity-50",          this.class,        )}        onClick={() => { this.inputRef.current?.focus(); }}      >        {() =>          this.tags.map((tag) => (            <span              key={tag}              class="inline-flex items-center gap-1 rounded-sm bg-secondary px-1.5 py-0.5 text-xs font-medium text-secondary-foreground"            >              {tag}              <button                type="button"                aria-label={`Remove ${tag}`}                disabled={this.disabled}                class="rounded-xs text-secondary-foreground/60 hover:text-secondary-foreground disabled:pointer-events-none"                onClick={(event: MouseEvent) => {                  event.stopPropagation();                  this.removeTag(tag);                }}              >                <Icon name="X" size={12} />              </button>            </span>          ))        }        <input          ref={this.inputRef}          value={() => this._draft}          placeholder={() => (this.tags.length === 0 ? this.placeholder : "")}          disabled={this.disabled}          class="min-w-24 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"          onInput={(event: Event) => { this._draft = (event.target as HTMLInputElement).value; }}          onKeyDown={this._handleKeyDown}        />      </div>    );  }}

Install the following dependencies:

npm install @praxisjs/css @morphos/icons
pnpm add @praxisjs/css @morphos/icons
yarn add @praxisjs/css @morphos/icons
bun add @praxisjs/css @morphos/icons

Copy and paste the following code into your project.

tags-input.tsx
import { StatefulComponent } from "@praxisjs/core";import { cx, Stylesheet, Styled, tokenVars } from "@praxisjs/css";import { Component, Emit, FunctionProp, Prop, Ref, State, type Ref as RefType } from "@praxisjs/decorators";import { Icon } from "@morphos/icons";import { KosmesisTokens } from "@/lib/kosmesis-theme";const t = tokenVars(KosmesisTokens);class TagsInputStyles extends Stylesheet {  $root = this.css({    display: "flex",    minHeight: "2.25rem",    width: "100%",    flexWrap: "wrap",    alignItems: "center",    gap: "0.375rem",    borderRadius: "0.375rem",    border: `1px solid ${t.input}`,    backgroundColor: "transparent",    padding: "0.375rem 0.5rem",    boxShadow: "0 1px 2px 0 rgb(0 0 0 / 0.05)",    transition: "color 150ms ease, box-shadow 150ms ease",    cursor: "text",  })    .on("&:focus-within", { borderColor: t.ring, boxShadow: `0 0 0 3px color-mix(in oklab, ${t.ring} 50%, transparent)` })    .on("&[data-disabled]", { cursor: "not-allowed", opacity: 0.5 });  $tag = this.css({    display: "inline-flex",    alignItems: "center",    gap: "0.25rem",    borderRadius: "0.25rem",    backgroundColor: t.secondary,    padding: "0.125rem 0.375rem",    fontSize: "0.75rem",    fontWeight: 500,    color: t.secondaryForeground,  });  $remove = this.css({    borderRadius: "2px",    color: `color-mix(in oklab, ${t.secondaryForeground} 60%, transparent)`,    cursor: "pointer",  }).on("&:hover", { color: t.secondaryForeground });  $input = this.css({    minWidth: "6rem",    flex: "1 1 0%",    backgroundColor: "transparent",    fontSize: "0.875rem",    outline: "none",    color: t.foreground,  })    .placeholder({ color: t.mutedForeground })    .disabled({ cursor: "not-allowed" });}export interface TagsInputProps {  value?: string[];  defaultValue?: string[];  onChange?: (tags: string[]) => void;  placeholder?: string;  disabled?: boolean;  max?: number;  class?: string;  id?: string;  "aria-label"?: string;}/** `value` accepts a plain array (uncontrolled) or a getter function (controlled). */@Component()export class TagsInput extends StatefulComponent {  @Styled(TagsInputStyles) $s!: TagsInputStyles;  @Prop() value?: string[];  @Prop() defaultValue?: string[];  @Prop() placeholder = "Add tag...";  @Prop() disabled = false;  @Prop() max?: number;  @Prop() class?: string;  @Prop() id?: string;  @Prop() "aria-label"?: string;  @FunctionProp() onChange?: TagsInputProps["onChange"];  @Ref<HTMLInputElement>()  inputRef!: RefType<HTMLInputElement>;  @State() _tags: string[] = [];  @State() _draft = "";  onBeforeMount() {    this._tags = this.defaultValue ?? this.value ?? [];  }  get tags(): string[] {    return this.value ?? this._tags;  }  @Emit("onChange")  private commit(next: string[]): string[] {    if (this.value === undefined) this._tags = next;    return next;  }  addTag(raw: string): void {    const tag = raw.trim();    if (!tag || this.disabled || this.tags.includes(tag)) {      this._draft = "";      return;    }    if (this.max !== undefined && this.tags.length >= this.max) return;    this.commit([...this.tags, tag]);    this._draft = "";  }  removeTag(tag: string): void {    if (this.disabled) return;    this.commit(this.tags.filter((t) => t !== tag));  }  private readonly _handleKeyDown = (event: KeyboardEvent) => {    if (event.key === "Enter" || event.key === ",") {      event.preventDefault();      this.addTag(this._draft);    } else if (event.key === "Backspace" && this._draft === "" && this.tags.length > 0) {      this.removeTag(this.tags[this.tags.length - 1]);    }  };  render() {    return (      <div        id={this.id}        role="group"        aria-label={this["aria-label"] ?? "Tags"}        data-disabled={() => (this.disabled ? "" : undefined)}        class={cx(this.$s.$root, this.class)}        onClick={() => { this.inputRef.current?.focus(); }}      >        {() =>          this.tags.map((tag) => (            <span key={tag} class={this.$s.$tag}>              {tag}              <button                type="button"                aria-label={`Remove ${tag}`}                disabled={this.disabled}                class={this.$s.$remove}                onClick={(event: MouseEvent) => {                  event.stopPropagation();                  this.removeTag(tag);                }}              >                <Icon name="X" size={12} />              </button>            </span>          ))        }        <input          ref={this.inputRef}          value={() => this._draft}          placeholder={() => (this.tags.length === 0 ? this.placeholder : "")}          disabled={this.disabled}          class={this.$s.$input}          onInput={(event: Event) => { this._draft = (event.target as HTMLInputElement).value; }}          onKeyDown={this._handleKeyDown}        />      </div>    );  }}

Examples

About

Purely presentational — no Morphos equivalent. value behaves like CalendarState.selected: pass a plain array for uncontrolled use, or a getter function to stay controlled.

Usage

import { TagsInput } from "@/components/ui/tags-input";

<TagsInput defaultValue={["design", "frontend"]} onChange={(tags) => console.log(tags)} />

Props

PropTypeDefault
valuestring[]— (uncontrolled when omitted)
defaultValuestring[][]
maxnumber— (unlimited)
disabledbooleanfalse
placeholderstring"Add tag..."
onChange(tags: string[]) => void
classstring

On this page