@ocwilsonchow/editor

@ocwilsonchow/editor is a mention composer: type @ to insert mentions, press Enter to submit, and Shift+Enter for a newline. The package ships no CSS. Style it with className and data-slot.

For the source code, check out the GitHub.

Installation

npm i @ocwilsonchow/editor

Peer dependencies: react and react-dom >=19. TipTap is a hidden runtime dependency — you do not install it yourself.

import { MentionEditor } from "@ocwilsonchow/editor";

The package is ESM-only. Import it from a Client Component in Next.js.

Preset

MentionEditor is a convenience composition. children render in the footer.

<MentionEditor
  items={items}
  onSubmit={onSubmit}
  placeholder="Write a message"
  emptyText="No results"
  loadingText="Loading"
  backText="Back"
>
  <button type="button">Send</button>
</MentionEditor>
Example data
{
  "root": [
    {
      "id": "src",
      "label": "src",
      "action": "navigate",
      "hint": "folder"
    },
    {
      "id": "packages",
      "label": "packages",
      "action": "navigate",
      "hint": "folder"
    },
    {
      "id": "content",
      "label": "content",
      "action": "navigate",
      "hint": "folder"
    },
    {
      "id": "readme",
      "label": "README.md",
      "hint": "md"
    },
    {
      "id": "package-json",
      "label": "package.json",
      "hint": "json"
    },
    {
      "id": "tsconfig",
      "label": "tsconfig.json",
      "hint": "json"
    },
    {
      "id": "agents",
      "label": "AGENTS.md",
      "hint": "md"
    }
  ],
  "src": [
    {
      "id": "src-app",
      "label": "app",
      "action": "navigate",
      "hint": "folder"
    },
    {
      "id": "src-components",
      "label": "components",
      "action": "navigate",
      "hint": "folder"
    },
    {
      "id": "src-lib",
      "label": "lib",
      "action": "navigate",
      "hint": "folder"
    },
    {
      "id": "src-globals",
      "label": "globals.css",
      "hint": "css"
    }
  ],
  "src-app": [
    {
      "id": "src-app-page",
      "label": "page.tsx",
      "hint": "tsx"
    },
    {
      "id": "src-app-layout",
      "label": "layout.tsx",
      "hint": "tsx"
    }
  ],
  "src-components": [
    {
      "id": "src-theme-toggle",
      "label": "theme-toggle.tsx",
      "hint": "tsx"
    },
    {
      "id": "src-install-command",
      "label": "install-command.tsx",
      "hint": "tsx"
    },
    {
      "id": "src-code-block",
      "label": "code-block.tsx",
      "hint": "tsx"
    }
  ],
  "src-lib": [
    {
      "id": "src-mention-items",
      "label": "mention-items.ts",
      "hint": "ts"
    }
  ],
  "packages": [
    {
      "id": "pkg-editor",
      "label": "editor",
      "action": "navigate",
      "hint": "folder"
    }
  ],
  "pkg-editor": [
    {
      "id": "pkg-editor-src",
      "label": "src",
      "action": "navigate",
      "hint": "folder"
    },
    {
      "id": "pkg-editor-package",
      "label": "package.json",
      "hint": "json"
    },
    {
      "id": "pkg-editor-tsup",
      "label": "tsup.config.ts",
      "hint": "ts"
    }
  ],
  "pkg-editor-src": [
    {
      "id": "pkg-editor-index",
      "label": "index.ts",
      "hint": "ts"
    },
    {
      "id": "pkg-editor-root",
      "label": "root.tsx",
      "hint": "tsx"
    },
    {
      "id": "pkg-editor-types",
      "label": "types.ts",
      "hint": "ts"
    }
  ],
  "content": [
    {
      "id": "content-docs",
      "label": "docs",
      "action": "navigate",
      "hint": "folder"
    }
  ],
  "content-docs": [
    {
      "id": "docs-index",
      "label": "index.md",
      "hint": "md"
    },
    {
      "id": "docs-install",
      "label": "installation.md",
      "hint": "md"
    },
    {
      "id": "docs-preset",
      "label": "preset.mdx",
      "hint": "mdx"
    }
  ]
}

items receives the query and navigation context. Use action: "navigate" to drill in, or omit it to insert { id, label }.

items can return a promise. The list shows loadingText until it resolves.

const items: MentionItemsResolver = async (query, { parentId }) => {
  const params = new URLSearchParams({ q: query });
  if (parentId) {
    params.set("parentId", parentId);
  }
  const res = await fetch(`/api/mentions?${params}`);
  return res.json();
};

The preset composes Root, Content, Suggestions, and Footer for the common case. Use the compound parts when you need a custom list row.

Primitives

The public compound interface is four parts. Suggestions owns the portal, caret position, listbox semantics, and navigation.

<MentionEditor.Root items={items} onSubmit={onSubmit}>
  <MentionEditor.Content placeholder="Write a message" />
  <MentionEditor.Suggestions
    empty={<span>Nothing here</span>}
    back={({ path }) => <span>Back from {path.at(-1)?.label}</span>}
  >
    {({ item, active }) => (
      <span data-active={active}>{item.label}</span>
    )}
  </MentionEditor.Suggestions>
  <MentionEditor.Footer>
    <button type="button">Send</button>
  </MentionEditor.Footer>
</MentionEditor.Root>

Extra fields on TItem extends MentionItem pass through to the Suggestions render function. Also available on MentionEditor and Root: char (default @), disabled, autoFocus, and className.

The playground below uses the compound API with custom suggestion rows.

Submit payload

onSubmit and onChange receive:

{
  text: string;      // mentions serialized as `${char}${label}`
  html: string;
  mentions: { id: string; label: string }[];
}

Enter submits only when the suggestion list is closed. A ref exposes focus(), clear(), and getPayload().

const ref = useRef<MentionEditorHandle>(null);

<MentionEditor ref={ref} items={items} onSubmit={onSubmit} />

Styling

Style slots with className or data-slot: mention-editor, mention-editor-content, mention-editor-surface, mention-editor-portal, mention-editor-list, mention-editor-item (data-active when highlighted), mention-editor-footer, and mention.