# Token input

> A text field on Vercel's UI SDK that mixes typed text with tokens for people, files and commands, with @ and / suggestions.

Canonical: https://hextaui.com/docs/ui-sdk-token-input
Last updated: 2026-11-13

> **Beta.** Built on Vercel’s UI SDK 0.1.0-beta.1 (`@ui-sdk/react`), a public beta. Its API may change.

```tsx title="components/examples/ui-sdk-token-input/demo.tsx"
"use client"

import { IconAt } from "@tabler/icons-react"

import {
  TokenInput,
  TokenInputEditor,
  TokenInputToken,
  TokenInputTokenRemove,
  TriggerMenu,
  TriggerMenuContent,
  TriggerMenuEmpty,
  TriggerMenuItem,
  TriggerMenuList,
  type TokenInputValue,
  type TriggerMenuDataItem,
} from "@/components/ui/ui-sdk-token-input"

const people = [
  { value: "hollis", label: "Hollis Grant", keywords: ["design"] },
  { value: "ines", label: "Inès Moreau", keywords: ["engineering"] },
  { value: "kofi", label: "Kofi Mensah", keywords: ["engineering"] },
  { value: "wren", label: "Wren Takahashi", keywords: ["product"] },
  { value: "tomasz", label: "Tomasz Wójcik", keywords: ["support"] },
]

const draft: TokenInputValue = [
  "Thanks ",
  { type: "mention", value: "ines", label: "Inès Moreau" },
  ", can you pair with ",
]

function UiSdkTokenInputDemo() {
  return (
    <div className="w-full max-w-md">
      <TokenInput
        defaultValue={draft}
        renderToken={(token) => (
          <TokenInputToken>
            <IconAt aria-hidden="true" />
            {token.label}
            <TokenInputTokenRemove />
          </TokenInputToken>
        )}
      >
        <TokenInputEditor
          aria-label="Message"
          placeholder="Type @ to mention someone"
        />
        <TriggerMenu triggers={[{ char: "@", type: "mention", items: people }]}>
          <TriggerMenuContent>
            <TriggerMenuEmpty>No one matches.</TriggerMenuEmpty>
            <TriggerMenuList>
              {(state) =>
                (state.items as TriggerMenuDataItem[]).map((item) => (
                  <TriggerMenuItem
                    key={item.value}
                    value={item.value}
                    label={item.label}
                  >
                    <span
                      aria-hidden="true"
                      className="flex size-5 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground"
                    >
                      {item.label.charAt(0)}
                    </span>
                    {item.label}
                  </TriggerMenuItem>
                ))
              }
            </TriggerMenuList>
          </TriggerMenuContent>
        </TriggerMenu>
      </TokenInput>
    </div>
  )
}

export { UiSdkTokenInputDemo }
```

## Installation

### CLI

```bash
npx shadcn@latest add https://hextaui.com/r/ui-sdk-token-input.json
```

This adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

### Manual

Add the HextaUI theme tokens (https://hextaui.com/docs/installation#theme) to your global CSS if you haven't yet, then install the dependencies.

```bash
pnpm add @ui-sdk/react@0.1.0-beta.1 @ui-sdk/core@0.1.0-beta.1 @tabler/icons-react cn
```

Copy and paste the following code into your project.

```tsx title="components/ui/ui-sdk-token-input.tsx"
"use client"

import * as React from "react"
import { IconX } from "@tabler/icons-react"
import * as TokenInputPrimitive from "@ui-sdk/react/token-input"
import * as TriggerMenuPrimitive from "@ui-sdk/react/trigger-menu"
import { cn } from "cn"

type ClassName<State> =
  string | ((state: State) => string | undefined) | undefined

function mergeClassName<State>(base: string, className: ClassName<State>) {
  return typeof className === "function"
    ? (state: State) => cn(base, className(state))
    : cn(base, className)
}

function renderDefaultToken(token: TokenInputPrimitive.Token) {
  return (
    <TokenInputToken>
      {token.label}
      <TokenInputTokenRemove />
    </TokenInputToken>
  )
}

function TokenInput({
  renderToken = renderDefaultToken,
  ...props
}: TokenInputPrimitive.TokenInputProps) {
  return <TokenInputPrimitive.TokenInput renderToken={renderToken} {...props} />
}

function TokenInputEditor({
  className,
  ...props
}: React.ComponentProps<typeof TokenInputPrimitive.TokenInputEditor>) {
  return (
    <TokenInputPrimitive.TokenInputEditor
      className={mergeClassName(
        "block max-h-60 min-h-16 w-full min-w-0 cursor-text overflow-y-auto rounded-md bg-transparent px-3 py-2 text-sm/6 wrap-break-word whitespace-pre-wrap text-foreground inset-ring-(length:--hairline) inset-ring-input transition-[color,background-color,box-shadow] duration-150 ease-out-cubic outline-none selection:bg-primary/20 focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:inset-ring-ring focus-visible:outline-hidden aria-invalid:inset-ring-destructive data-empty:before:pointer-events-none data-empty:before:text-muted-foreground data-empty:before:content-[attr(aria-placeholder)] data-readonly:cursor-default data-readonly:bg-muted/40 motion-reduce:transition-none dark:bg-input/30 dark:data-readonly:bg-input/20 forced-colors:border pointer-coarse:text-[max(16px,1rem)] data-disabled:cursor-not-allowed data-disabled:opacity-50 [@media(hover:hover)]:hover:not-focus-visible:not-data-disabled:not-data-readonly:inset-ring-ring/70",
        className
      )}
      {...props}
    />
  )
}

function TokenInputToken({
  className,
  ...props
}: React.ComponentProps<typeof TokenInputPrimitive.TokenInputToken>) {
  return (
    <TokenInputPrimitive.TokenInputToken
      className={mergeClassName(
        "mx-px inline-flex max-w-full items-center gap-1 rounded-sm bg-muted ps-1.5 pe-0.5 align-bottom text-sm/6 font-medium whitespace-nowrap text-foreground transition-[background-color,box-shadow,opacity] duration-150 ease-out-cubic select-none not-has-data-[slot=token-input-token-remove]:pe-1.5 data-pending:text-muted-foreground data-unresolved:bg-transparent data-unresolved:inset-ring-(length:--hairline) data-unresolved:inset-ring-warning data-[selected]:bg-primary data-[selected]:text-primary-foreground motion-safe:data-pending:animate-pulse motion-reduce:transition-none dark:bg-input/60 forced-colors:border forced-colors:data-[selected]:border-[Highlight] [&_svg]:pointer-events-none [&_svg]:shrink-0 data-[selected]:[&_svg]:text-primary-foreground/70 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-muted-foreground",
        className
      )}
      {...props}
    />
  )
}

function TokenInputTokenRemove({
  className,
  children,
  ...props
}: React.ComponentProps<typeof TokenInputPrimitive.TokenInputTokenRemove>) {
  return (
    <TokenInputPrimitive.TokenInputTokenRemove
      className={mergeClassName(
        "relative inline-flex size-5 shrink-0 cursor-default items-center justify-center rounded-[max(calc(var(--radius-sm)*0.5),calc(var(--radius-sm)-var(--spacing)*0.5))] text-muted-foreground transition-[color,background-color] duration-150 ease-out-cubic before:absolute before:-inset-1 data-readonly:hidden motion-reduce:transition-none pointer-coarse:before:-inset-2.5 data-disabled:hidden [@media(hover:hover)]:hover:bg-foreground/10 [@media(hover:hover)]:hover:text-foreground",
        className
      )}
      {...props}
    >
      {children ?? <IconX aria-hidden="true" className="size-3" />}
    </TokenInputPrimitive.TokenInputTokenRemove>
  )
}

function TriggerMenu(props: TriggerMenuPrimitive.TriggerMenuProps) {
  return <TriggerMenuPrimitive.TriggerMenu {...props} />
}

function TriggerMenuContent({
  className,
  sideOffset = 6,
  align = "start",
  collisionPadding = 8,
  ...props
}: React.ComponentProps<typeof TriggerMenuPrimitive.TriggerMenuContent>) {
  return (
    <TriggerMenuPrimitive.TriggerMenuContent
      sideOffset={sideOffset}
      align={align}
      collisionPadding={collisionPadding}
      className={mergeClassName(
        "m-0 flex max-h-[min(var(--available-height),18rem)] w-60 max-w-(--available-width) origin-(--transform-origin) flex-col overflow-hidden rounded-lg border-0 bg-popover p-0 text-popover-foreground shadow-md ring-(length:--hairline) ring-foreground/10 transition-[opacity,scale] duration-150 ease-out-quint outline-none [--trigger-menu-item-radius:max(calc(var(--radius-sm)*0.5),calc(var(--radius-lg)-0.25rem))] data-ending-style:opacity-0 data-ending-style:duration-100 data-starting-style:opacity-0 motion-safe:data-ending-style:scale-96 motion-safe:data-starting-style:scale-96 motion-reduce:transition-opacity forced-colors:border",
        className
      )}
      {...props}
    />
  )
}

function TriggerMenuList({
  className,
  ...props
}: React.ComponentProps<typeof TriggerMenuPrimitive.TriggerMenuList>) {
  return (
    <TriggerMenuPrimitive.TriggerMenuList
      className={mergeClassName(
        "min-h-0 scroll-py-1 overflow-y-auto overscroll-contain p-1 outline-none empty:hidden aria-busy:opacity-60 motion-safe:transition-opacity",
        className
      )}
      {...props}
    />
  )
}

function TriggerMenuItem<T = string>({
  className,
  ...props
}: React.ComponentProps<typeof TriggerMenuPrimitive.TriggerMenuItem<T>>) {
  return (
    <TriggerMenuPrimitive.TriggerMenuItem<T>
      className={mergeClassName(
        "relative flex min-h-8 w-full cursor-default items-center gap-2 rounded-(--trigger-menu-item-radius) px-2 py-1.5 text-start text-sm wrap-anywhere outline-none select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground forced-colors:data-highlighted:outline-2 forced-colors:data-highlighted:-outline-offset-2 forced-colors:data-highlighted:outline-solid pointer-coarse:min-h-11 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
        className
      )}
      {...props}
    />
  )
}

function TriggerMenuItemIcon({
  className,
  ...props
}: React.ComponentProps<typeof TriggerMenuPrimitive.TriggerMenuItemIcon>) {
  return (
    <TriggerMenuPrimitive.TriggerMenuItemIcon
      className={mergeClassName(
        "flex size-4 shrink-0 items-center justify-center text-muted-foreground data-mirror:rtl:-scale-x-100",
        className
      )}
      {...props}
    />
  )
}

function TriggerMenuEmpty({
  className,
  ...props
}: React.ComponentProps<typeof TriggerMenuPrimitive.TriggerMenuEmpty>) {
  return (
    <TriggerMenuPrimitive.TriggerMenuEmpty
      className={mergeClassName(
        "shrink-0 px-3 py-5 text-center text-sm text-balance wrap-anywhere text-muted-foreground not-data-empty:not-data-loading:hidden",
        className
      )}
      {...props}
    />
  )
}

function TriggerMenuGroup({
  className,
  ...props
}: React.ComponentProps<typeof TriggerMenuPrimitive.TriggerMenuGroup>) {
  return (
    <TriggerMenuPrimitive.TriggerMenuGroup
      className={mergeClassName("not-first:mt-1", className)}
      {...props}
    />
  )
}

function TriggerMenuGroupLabel({
  className,
  ...props
}: React.ComponentProps<typeof TriggerMenuPrimitive.TriggerMenuGroupLabel>) {
  return (
    <TriggerMenuPrimitive.TriggerMenuGroupLabel
      className={mergeClassName(
        "px-2 pt-1.5 pb-1 text-xs font-medium text-muted-foreground",
        className
      )}
      {...props}
    />
  )
}

function TriggerMenuSeparator({
  className,
  ...props
}: React.ComponentProps<typeof TriggerMenuPrimitive.TriggerMenuSeparator>) {
  return (
    <TriggerMenuPrimitive.TriggerMenuSeparator
      className={mergeClassName("-mx-1 my-1 h-px bg-border", className)}
      {...props}
    />
  )
}

function TriggerMenuHint({
  className,
  ...props
}: React.ComponentProps<typeof TriggerMenuPrimitive.TriggerMenuHint>) {
  return (
    <TriggerMenuPrimitive.TriggerMenuHint
      className={mergeClassName(
        "pointer-events-none text-sm/6 whitespace-pre text-muted-foreground/70 select-none pointer-coarse:text-[max(16px,1rem)]",
        className
      )}
      {...props}
    />
  )
}

function TriggerMenuStatus({
  className,
  ...props
}: React.ComponentProps<typeof TriggerMenuPrimitive.TriggerMenuStatus>) {
  return (
    <TriggerMenuPrimitive.TriggerMenuStatus
      className={mergeClassName("sr-only", className)}
      {...props}
    />
  )
}

const createTokenInputController =
  TokenInputPrimitive.createTokenInputController
const fromText = TokenInputPrimitive.fromText
const toLabel = TokenInputPrimitive.toLabel
const toText = TokenInputPrimitive.toText

type TokenInputProps = TokenInputPrimitive.TokenInputProps
type TokenInputValue = TokenInputPrimitive.TokenInputValue
type Token = TokenInputPrimitive.Token
type TokenCodec = TokenInputPrimitive.TokenCodec
type TokenDeletion = TokenInputPrimitive.TokenDeletion
type TriggerMenuDataItem<T = string> =
  TriggerMenuPrimitive.TriggerMenuDataItem<T>

export {
  TokenInput,
  TokenInputEditor,
  TokenInputToken,
  TokenInputTokenRemove,
  TriggerMenu,
  TriggerMenuContent,
  TriggerMenuEmpty,
  TriggerMenuGroup,
  TriggerMenuGroupLabel,
  TriggerMenuHint,
  TriggerMenuItem,
  TriggerMenuItemIcon,
  TriggerMenuList,
  TriggerMenuSeparator,
  TriggerMenuStatus,
  createTokenInputController,
  fromText,
  toLabel,
  toText,
}
export type {
  Token,
  TokenCodec,
  TokenDeletion,
  TokenInputProps,
  TokenInputValue,
  TriggerMenuDataItem,
}
```

Update the import paths to match your project setup.

## Usage

UI SDK adds a little CSS of its own in a cascade layer named `ui-sdk`. Declare that layer at the top of your global stylesheet, above the Tailwind import, so your classes win over it.

```css
@layer ui-sdk;

@import "tailwindcss";
```

```tsx
import {
  TokenInput,
  TokenInputEditor,
  type TokenInputValue,
} from "@/components/ui/ui-sdk-token-input"
```

```tsx
const draft: TokenInputValue = [
  "Explain ",
  { type: "file", value: "src/routes.ts", label: "routes.ts" },
]

<TokenInput defaultValue={draft}>
  <TokenInputEditor aria-label="Prompt" />
</TokenInput>
```

A value is an array of text and tokens. Each token has a `type`, a `value` for your app and a `label` people read. The editor owns its content, selection and undo, so let it render and pass `renderToken` to change how a token looks. Without one, each token shows its label and a remove button.

## Composition

```text
TokenInput
├── TokenInputEditor
└── TriggerMenu
    ├── TriggerMenuHint
    └── TriggerMenuContent
        ├── TriggerMenuEmpty
        └── TriggerMenuList
            └── TriggerMenuItem
                └── TriggerMenuItemIcon

renderToken
└── TokenInputToken
    └── TokenInputTokenRemove
```

A `TriggerMenu` inside the input suggests tokens when someone types a trigger like `@` or `/`. Leave it out when your code supplies the tokens.

## Examples

### Prompt

Type `@` for files or `/` at the start for skills. A file is pending while it loads, and sending marks each skill as running, then done, with `updateToken()`. Enter sends and Shift+Enter adds a line.

```tsx title="components/examples/ui-sdk-token-input/prompt.tsx"
"use client"

import * as React from "react"
import {
  IconArrowUp,
  IconCheck,
  IconFile,
  IconLoader2,
  IconTerminal2,
} from "@tabler/icons-react"

import { Button } from "@/components/ui/button"
import {
  createTokenInputController,
  TokenInput,
  TokenInputEditor,
  TokenInputToken,
  TokenInputTokenRemove,
  toLabel,
  TriggerMenu,
  TriggerMenuContent,
  TriggerMenuEmpty,
  TriggerMenuHint,
  TriggerMenuItem,
  TriggerMenuItemIcon,
  TriggerMenuList,
  type Token,
  type TokenInputValue,
  type TriggerMenuDataItem,
} from "@/components/ui/ui-sdk-token-input"

const files = [
  { value: "src/app.tsx", label: "app.tsx", keywords: ["src"] },
  { value: "src/routes.ts", label: "routes.ts", keywords: ["src"] },
  { value: "src/styles.css", label: "styles.css", keywords: ["src"] },
  { value: "README.md", label: "README.md" },
  { value: "package.json", label: "package.json" },
]

const skills = [
  {
    value: "search",
    label: "search",
    arguments: [{ name: "query", hint: "<query>", required: true }],
  },
  { value: "review", label: "review" },
  { value: "summarize", label: "summarize" },
]

const loadFile = () => new Promise<void>((resolve) => setTimeout(resolve, 800))

function UiSdkTokenInputPrompt() {
  const [prompt] = React.useState(createTokenInputController)
  const [running, setRunning] = React.useState(false)
  const [sent, setSent] = React.useState<string | null>(null)
  const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(
    undefined
  )

  React.useEffect(() => () => clearTimeout(timer.current), [])

  function send(value: TokenInputValue) {
    if (running || !prompt.getLabel().trim()) return
    setRunning(true)
    const skillTokens = value.filter(
      (segment): segment is Token =>
        typeof segment !== "string" && segment.type === "skill"
    )
    for (const skill of skillTokens) {
      prompt.updateToken(skill.id!, { data: { ...skill.data, run: "running" } })
    }
    timer.current = setTimeout(() => {
      for (const skill of skillTokens) {
        prompt.updateToken(skill.id!, { data: { ...skill.data, run: "done" } })
      }
      setSent(toLabel(prompt.getValue()))
      prompt.clear()
      setRunning(false)
    }, 1200)
  }

  return (
    <div className="flex w-full max-w-md flex-col gap-2">
      <TokenInput
        controller={prompt}
        onSubmit={send}
        renderToken={(token) => (
          <TokenInputToken>
            {token.type === "skill" ? (
              <IconTerminal2 aria-hidden="true" />
            ) : (
              <IconFile aria-hidden="true" />
            )}
            {token.type === "skill" ? "/" + token.label : token.label}
            {token.data?.run === "running" ? (
              <IconLoader2
                aria-hidden="true"
                className="motion-safe:animate-spin"
              />
            ) : null}
            {token.data?.run === "done" ? (
              <IconCheck aria-hidden="true" className="text-success" />
            ) : null}
            <TokenInputTokenRemove />
          </TokenInputToken>
        )}
      >
        <div className="flex flex-col gap-2">
          <TokenInputEditor
            aria-label="Prompt"
            placeholder="Ask anything. Type @ for files or / for skills"
          />
          <Button
            type="button"
            size="icon-sm"
            className="self-end"
            aria-label={running ? "Sending" : "Send"}
            disabled={running}
            onClick={() => send(prompt.getValue())}
          >
            {running ? (
              <IconLoader2
                aria-hidden="true"
                className="motion-safe:animate-spin"
              />
            ) : (
              <IconArrowUp aria-hidden="true" />
            )}
          </Button>
        </div>
        <TriggerMenu
          triggers={[
            { char: "@", type: "file", items: files },
            { char: "/", boundary: "start", type: "skill", items: skills },
          ]}
          onSelect={(value, details) =>
            details.trigger === "@" ? loadFile() : undefined
          }
        >
          <TriggerMenuHint />
          <TriggerMenuContent>
            <TriggerMenuEmpty>No matches.</TriggerMenuEmpty>
            <TriggerMenuList>
              {(state) =>
                (state.items as TriggerMenuDataItem[]).map((item) => (
                  <TriggerMenuItem
                    key={item.value}
                    value={item.value}
                    label={item.label}
                  >
                    <TriggerMenuItemIcon>
                      {state.trigger === "/" ? (
                        <IconTerminal2 aria-hidden="true" />
                      ) : (
                        <IconFile aria-hidden="true" />
                      )}
                    </TriggerMenuItemIcon>
                    {item.label}
                    {item.value !== item.label ? (
                      <span className="ms-auto truncate font-mono text-xs text-muted-foreground">
                        {item.value}
                      </span>
                    ) : null}
                  </TriggerMenuItem>
                ))
              }
            </TriggerMenuList>
          </TriggerMenuContent>
        </TriggerMenu>
      </TokenInput>
      <p role="status" className="min-h-5 text-sm text-muted-foreground">
        {sent ? "Sent: " + sent : null}
      </p>
    </div>
  )
}

export { UiSdkTokenInputPrompt }
```

### Deleting tokens

`tokenDeletion` sets what Backspace does at a token. `whole` removes it, `select` selects it first, and `revert` turns it back into text.

```tsx title="components/examples/ui-sdk-token-input/deletion.tsx"
"use client"

import * as React from "react"
import { IconFile } from "@tabler/icons-react"

import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import {
  TokenInput,
  TokenInputEditor,
  TokenInputToken,
  type TokenDeletion,
  type TokenInputValue,
} from "@/components/ui/ui-sdk-token-input"

const draft: TokenInputValue = [
  "Explain ",
  { type: "file", value: "src/routes.ts", label: "routes.ts" },
  " in two sentences.",
]

const modes: { value: TokenDeletion; label: string }[] = [
  { value: "whole", label: "Whole" },
  { value: "select", label: "Select" },
  { value: "revert", label: "Revert" },
]

function UiSdkTokenInputDeletion() {
  const [deletion, setDeletion] = React.useState<TokenDeletion>("whole")

  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <ToggleGroup
        aria-label="Backspace removes"
        value={[deletion]}
        onValueChange={(next) => {
          if (next[0]) setDeletion(next[0] as TokenDeletion)
        }}
        variant="outline"
        size="sm"
      >
        {modes.map((mode) => (
          <ToggleGroupItem key={mode.value} value={mode.value}>
            {mode.label}
          </ToggleGroupItem>
        ))}
      </ToggleGroup>
      <TokenInput
        key={deletion}
        tokenDeletion={deletion}
        defaultValue={draft}
        renderToken={(token) => (
          <TokenInputToken>
            <IconFile aria-hidden="true" />
            {token.label}
          </TokenInputToken>
        )}
      >
        <TokenInputEditor aria-label="Prompt" />
      </TokenInput>
    </div>
  )
}

export { UiSdkTokenInputDeletion }
```

### Controller

A controller from `createTokenInputController()` inserts, updates and removes tokens from code. Pass a promise as `resolve` to keep a token pending until it settles.

```tsx title="components/examples/ui-sdk-token-input/controller.tsx"
"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  createTokenInputController,
  TokenInput,
  TokenInputEditor,
  type Token,
  type TokenInputValue,
} from "@/components/ui/ui-sdk-token-input"

const draft: TokenInputValue = [
  "Compare ",
  { type: "file", value: "src/routes.ts", label: "routes.ts" },
  " with ",
]

const loadFile = () => new Promise<void>((resolve) => setTimeout(resolve, 800))

function UiSdkTokenInputController() {
  const [prompt] = React.useState(createTokenInputController)
  const firstToken = () =>
    prompt
      .getValue()
      .find((segment): segment is Token => typeof segment !== "string")

  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <div
        className="flex flex-wrap gap-2"
        onMouseDown={(event) => event.preventDefault()}
      >
        <Button
          variant="outline"
          size="sm"
          onClick={() =>
            prompt.insert(
              { type: "file", value: "package.json", label: "package.json" },
              { at: "end", resolve: loadFile() }
            )
          }
        >
          Attach file
        </Button>
        <Button
          variant="outline"
          size="sm"
          onClick={() => {
            const token = firstToken()
            if (token) prompt.updateToken(token.id!, { label: token.value })
          }}
        >
          Show path
        </Button>
        <Button
          variant="outline"
          size="sm"
          onClick={() => {
            const token = firstToken()
            if (token) prompt.removeToken(token.id!)
          }}
        >
          Remove first
        </Button>
        <Button variant="ghost" size="sm" onClick={() => prompt.undo()}>
          Undo
        </Button>
        <Button variant="ghost" size="sm" onClick={() => prompt.clear()}>
          Clear
        </Button>
      </div>
      <TokenInput controller={prompt} defaultValue={draft}>
        <TokenInputEditor aria-label="Prompt" />
      </TokenInput>
    </div>
  )
}

export { UiSdkTokenInputController }
```

### Form

With a `name`, the input submits its text with each token written as `[@label](type:value)`. `required` blocks an empty submit, and a reset goes back to `defaultValue`.

```tsx title="components/examples/ui-sdk-token-input/form.tsx"
"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  TokenInput,
  TokenInputEditor,
  type TokenInputValue,
} from "@/components/ui/ui-sdk-token-input"

const draft: TokenInputValue = [
  "Explain ",
  { type: "file", value: "src/routes.ts", label: "routes.ts" },
  " in two sentences.",
]

function UiSdkTokenInputForm() {
  const [submitted, setSubmitted] = React.useState<string | null>(null)

  return (
    <form
      className="flex w-full max-w-md flex-col gap-3"
      onSubmit={(event) => {
        event.preventDefault()
        setSubmitted(String(new FormData(event.currentTarget).get("prompt")))
      }}
      onReset={() => setSubmitted(null)}
    >
      <TokenInput name="prompt" required defaultValue={draft}>
        <TokenInputEditor aria-label="Prompt" />
      </TokenInput>
      <div className="flex gap-2">
        <Button type="submit" size="sm">
          Submit
        </Button>
        <Button type="reset" variant="outline" size="sm">
          Reset
        </Button>
      </div>
      <p role="status" className="text-sm wrap-anywhere text-muted-foreground">
        {submitted ? (
          <>
            Submitted prompt=
            <span className="font-mono text-foreground">{submitted}</span>
          </>
        ) : (
          "Nothing submitted yet."
        )}
      </p>
    </form>
  )
}

export { UiSdkTokenInputForm }
```

### Submit shortcut

With `submitShortcut="mod+enter"`, Enter adds a line and Command or Control+Enter sends, which many Japanese and Chinese writers prefer.

```tsx title="components/examples/ui-sdk-token-input/submit-shortcut.tsx"
"use client"

import * as React from "react"

import { Kbd } from "@/components/ui/kbd"
import {
  TokenInput,
  TokenInputEditor,
  toLabel,
} from "@/components/ui/ui-sdk-token-input"

function UiSdkTokenInputSubmitShortcut() {
  const [sent, setSent] = React.useState<string | null>(null)

  return (
    <div className="flex w-full max-w-md flex-col gap-2">
      <TokenInput
        submitShortcut="mod+enter"
        onSubmit={(value) => setSent(toLabel(value))}
      >
        <TokenInputEditor
          aria-label="Message"
          placeholder="Write a message. Enter adds a line."
        />
      </TokenInput>
      <p
        role="status"
        className="flex flex-wrap items-center gap-1.5 text-sm wrap-anywhere text-muted-foreground"
      >
        {sent ? (
          "Sent: " + sent
        ) : (
          <>
            Send with <Kbd>⌘</Kbd>
            <Kbd>Enter</Kbd> or <Kbd>Ctrl</Kbd>
            <Kbd>Enter</Kbd>
          </>
        )}
      </p>
    </div>
  )
}

export { UiSdkTokenInputSubmitShortcut }
```

### Custom codec

A codec turns tokens into text and back, here as `<@id>`. With `copy="label"`, copying writes what people read, and pasting back still restores the tokens.

```tsx title="components/examples/ui-sdk-token-input/codec.tsx"
"use client"

import * as React from "react"

import {
  fromText,
  TokenInput,
  TokenInputEditor,
  toText,
  type TokenCodec,
} from "@/components/ui/ui-sdk-token-input"

const chat: TokenCodec = {
  format: (token) => "<@" + token.value + ">",
  parse: (text) =>
    text.split(/(<@[\w.-]+>)/).map((part) => {
      const id = /^<@([\w.-]+)>$/.exec(part)?.[1]
      return id ? { type: "mention", value: id, label: "@" + id } : part
    }),
}

const source = "Ping <@hollis> and <@ines> about the launch"

function UiSdkTokenInputCodec() {
  const [text, setText] = React.useState(source)

  return (
    <div className="flex w-full max-w-md flex-col gap-2">
      <TokenInput
        codec={chat}
        copy="label"
        defaultValue={fromText(source, chat)}
        onValueChange={(value) => setText(toText(value, chat))}
      >
        <TokenInputEditor aria-label="Message" />
      </TokenInput>
      <p className="font-mono text-xs wrap-anywhere text-muted-foreground">
        {text}
      </p>
    </div>
  )
}

export { UiSdkTokenInputCodec }
```

### Disabled

A disabled input can’t be focused or changed, hides the remove buttons and submits nothing.

```tsx title="components/examples/ui-sdk-token-input/disabled.tsx"
import {
  TokenInput,
  TokenInputEditor,
  type TokenInputValue,
} from "@/components/ui/ui-sdk-token-input"

const draft: TokenInputValue = [
  "Summarize ",
  { type: "file", value: "README.md", label: "README.md" },
  " for the release notes.",
]

function UiSdkTokenInputDisabled() {
  return (
    <div className="w-full max-w-md">
      <TokenInput disabled defaultValue={draft}>
        <TokenInputEditor aria-label="Prompt" />
      </TokenInput>
    </div>
  )
}

export { UiSdkTokenInputDisabled }
```

### Read-only

With `readOnly`, the text stays focusable, selectable and submitted, but typing, pastes and drops leave it alone.

```tsx title="components/examples/ui-sdk-token-input/read-only.tsx"
import {
  TokenInput,
  TokenInputEditor,
  type TokenInputValue,
} from "@/components/ui/ui-sdk-token-input"

const draft: TokenInputValue = [
  "Summarize ",
  { type: "file", value: "README.md", label: "README.md" },
  " for the release notes.",
]

function UiSdkTokenInputReadOnly() {
  return (
    <div className="w-full max-w-md">
      <TokenInput readOnly defaultValue={draft}>
        <TokenInputEditor aria-label="Prompt" />
      </TokenInput>
    </div>
  )
}

export { UiSdkTokenInputReadOnly }
```

### Right to left

The editor follows the page’s direction, and a token stays one piece inside right-to-left text.

```tsx title="components/examples/ui-sdk-token-input/rtl.tsx"
import {
  TokenInput,
  TokenInputEditor,
  type TokenInputValue,
} from "@/components/ui/ui-sdk-token-input"

const draft: TokenInputValue = [
  "اشرح ",
  { type: "file", value: "src/routes.ts", label: "routes.ts" },
  " في جملتين.",
]

function UiSdkTokenInputRtl() {
  return (
    <div dir="rtl" className="w-full max-w-md">
      <TokenInput defaultValue={draft}>
        <TokenInputEditor aria-label="الطلب" />
      </TokenInput>
    </div>
  )
}

export { UiSdkTokenInputRtl }
```

## Keyboard

| Key | Action |
| --- | --- |
| `Backspace` `Delete` | Removes the token before or after the caret, as tokenDeletion says. |
| `←` `→` | Moves the caret past a token in one press. The caret never stops inside one. |
| `↑` `↓` | Moves through suggestions while the menu is open. |
| `Enter` | Sends. While the menu is open, chooses the highlighted suggestion. |
| `Shift + Enter` | Starts a new line. |
| `⌘ + Enter` `Ctrl + Enter` | Sends when submitShortcut is mod+enter. Enter then starts a new line. |
| `⌘ + Z` `Ctrl + Z` | Undoes the last step. Typing is grouped, and pastes and token changes are steps of their own. |
| `Esc` | Closes the suggestions. |

## Accessibility

The editor is a multiline text box. A trigger menu inside it adds `aria-autocomplete` and `aria-haspopup`, and focus stays in the text while the arrow keys move through suggestions. Each token reads as its label and its kind, like “routes.ts file”, and inserting or removing one is announced. Name the editor with `aria-label` or a label around it.

Remove buttons are hidden from screen readers, since Backspace removes a token from the keyboard. Their hit area grows on touch screens, a pending token only pulses when motion is allowed, and a selected token gets a system highlight border in forced colors mode.

## API reference

Each part wraps the UI SDK part of the same name and accepts all of its props, including `render` and a `className` that can be a function of the part’s state.

### TokenInput

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `TokenInputValue` | – |  |
| `defaultValue` | `TokenInputValue` | – |  |
| `onValueChange` | `(value, details) => void` | – | Runs on typing, pastes, menus, undo and controller calls. details lists the reason and any removed tokens. |
| `onSubmit` | `(value, details) => void` | – | Runs on the submit shortcut, unless a menu is open or an input method is composing. |
| `submitShortcut` | `"enter" \| "mod+enter"` | `"enter"` |  |
| `tokenDeletion` | `"whole" \| "select" \| "revert"` | `"whole"` |  |
| `renderToken` | `(token: Token) => ReactNode` | – | Renders a token. Defaults to a TokenInputToken with its label and a remove button. |
| `copy` | `"codec" \| "label"` | `"codec"` |  |
| `codec` | `TokenCodec` | – |  |
| `getTokenDescription` | `(token: Token) => string` | – | What a token says to screen readers besides its label. |
| `history` | `{ limit, groupDelay }` | – |  |
| `name` | `string` | – |  |
| `form` | `string` | – |  |
| `required` | `boolean` | `false` |  |
| `disabled` | `boolean` | `false` |  |
| `readOnly` | `boolean` | `false` |  |
| `controller` | `TokenInputController` | – | From createTokenInputController(). |

### TokenInputEditor

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `placeholder` | `string` | – | Shown through aria-placeholder while the input is empty. |
| `render` | `ReactElement \| (props, state) => ReactElement` | – |  |

| Attribute | Description |
| --- | --- |
| `data-slot="token-input-editor"` | Target the editor in CSS. |
| `data-empty` | Present while the input holds nothing. |
| `data-disabled` | Present when disabled. |
| `data-readonly` | Present when read-only. |

### TokenInputToken

| Attribute | Description |
| --- | --- |
| `data-slot="token-input-token"` | Target a token in CSS. |
| `data-token-type` | The token’s type, like file or mention. |
| `data-selected` | Present while the token is in the selection, or selected for removal. |
| `data-pending` | Present while the token loads. |
| `data-unresolved` | Present while the token needs the user, like an ambiguous mention. |

### TokenInputTokenRemove

Removes its token and keeps focus in the editor. It renders a Tabler X icon unless you pass children, and hides itself while the input is disabled or read-only.

### TriggerMenu

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `triggers` | `TriggerDefinition[]` | – | The characters that open the menu, the token type each inserts and its items. |
| `onSelect` | `(value, details) => void \| Promise` | – | Runs when an item is chosen. Return a promise to insert the token as pending. |
| `autoHighlight` | `boolean` | `true` |  |
| `loop` | `boolean` | `true` |  |

### TriggerMenuContent

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `align` | `"start" \| "center" \| "end"` | `"start"` |  |
| `sideOffset` | `number` | `6` |  |
| `collisionPadding` | `number` | `8` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="trigger-menu-content"` | Target the popup in CSS. |
| `data-trigger` | The trigger of the current match, like @, so each list can look different. |
| `data-starting-style` | Present while the popup animates in. |
| `data-ending-style` | Present while the popup animates out. |

### TriggerMenuItem

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `T` | – |  |
| `label` | `string` | – | The text inserted and matched, when the content isn’t plain text. |
| `disabled` | `boolean` | `false` |  |

| Attribute | Description |
| --- | --- |
| `data-highlighted` | Present on the highlighted item. Focus stays in the editor. |
| `data-disabled` | Present when the item can’t be chosen. |

## Notes for AI assistants

- Install a component with the shadcn CLI: `npx shadcn@latest add https://hextaui.com/r/<name>.json`. It adds the source, the HextaUI theme tokens and any HextaUI components it depends on. `https://hextaui.com/r/all.json` installs every component.
- The code is then owned by the project, like shadcn/ui. There is no HextaUI npm package. HextaUI is MIT licensed and free for personal and commercial use.
- Behavior and accessibility come from Base UI (`@base-ui/react`). Compose with the `render` prop, not `asChild`.
- Styling uses Tailwind CSS v4 with theme tokens. Merge classes with `cn` from the `cn` package.
- Icons come from `@tabler/icons-react`.
- Import components from `@/components/ui/<name>`, hooks from `@/hooks/<name>` and utilities from `@/lib/<name>`.

Every HextaUI doc: https://hextaui.com/llms.txt

## Related

- [Calendar](https://hextaui.com/docs/calendar) - A date grid for single, range and multiple selection, with sliding months, range previews and touch-sized days.
- [Checkbox](https://hextaui.com/docs/checkbox) - A checkbox whose check draws in, with indeterminate parents, groups and labels that share its hover.
- [Combobox](https://hextaui.com/docs/combobox) - A filterable select with chips, groups and async results, in a popup that resizes as you type.
- [Date picker](https://hextaui.com/docs/date-picker) - A button that opens a calendar in a popover, or a bottom sheet on phones, for single dates and ranges.
- [Field](https://hextaui.com/docs/field) - Labels, descriptions and errors wired to their control, with validation states and layouts for forms.
- [Input](https://hextaui.com/docs/input) - A text input with three sizes, invalid and read-only states, native validation styling and a 16px touch font so phones never zoom in.
