yace
v1.1.1
Published
tiny text editor with plugins
Maintainers
Readme
Yace
Yace is a tiny, extensible code editor component for cases where a plain
<textarea> is not enough and Monaco or CodeMirror would be overkill. Under
2KB gzipped. Zero dependencies. Framework agnostic.
When Yace fits
Use Yace for embedded code samples, documentation playgrounds, Markdown inputs, prompt editors, token visualizers, and custom text experiences. It gives you syntax highlighting and editing hooks without bringing an IDE into the page.
Large documents, multicursor editing, rich text, decorations, and SSR are out of scope by design: they need a document model rather than a textarea overlay. Yace deliberately stays smaller and gives you the pieces to build the editor you actually need.
Quick start
Install Yace from npm:
npm install yaceAdd a mount point:
<div id="editor"></div>Create an editor with syntax highlighting and familiar editing behavior:
import { Yace } from "yace";
import { code } from "yace/highlighters/code";
import { history, preserveIndent, tab } from "yace/plugins";
const editor = new Yace("#editor", {
value: "const answer = 42;",
lineNumbers: true,
highlighters: [code()],
plugins: [history(), tab(), preserveIndent()],
});
editor.onUpdate((value) => console.log(value));Keep
history()first: it checkpoints the textarea state before later plugins transform it.
Yace is ESM-only. You can also load the core directly from a CDN without a build step:
import { Yace } from "https://unpkg.com/yace?module";How it works
A transparent <textarea> sits over a highlighted <pre>. The textarea owns
input, caret, selection, IME, and accessibility. The <pre> paints the same
text underneath it.
The overlay pattern is established. Yace keeps the implementation small and turns the two extension points into explicit pipelines:
- Plugins receive the textarea state and an input event, then return the next text and selection
- Highlighters receive the current value and return the HTML rendered by the
<pre>; chain them to tokenize first and decorate the result afterward
The core ships no required highlighter or editing behavior. Start with the native textarea and add only what the editor needs.
Examples
- Getting started with the bundled code highlighter
- Use highlight.js as the highlighter
- Use PrismJS as the highlighter
- Write a highlighter in about 15 lines
- Build a Markdown editor
- Highlight Markdown and fenced code blocks
- Add undo, tab, indentation, and cut-line plugins
- Write a plugin
- Layer shimmer and glitch effects over selected words
- Visualize LLM tokens in an editable OpenAI-style view
- Use Yace with React
Constructor options
new Yace(target, options) takes a CSS selector or DOM Node. Every option is
optional:
new Yace(target, {
value: "", // sets the initial text
highlighters: [], // defines the rendering pipeline
plugins: [], // defines the editing pipeline, reduced from left to right
lineNumbers: false, // adds a line-number gutter
styles: {}, // applies inline styles to the editor root, such as `{ fontSize: "20px" }`
});Instance API
The DOM Yace built, and the methods to drive it:
editor.valueholds the current texteditor.textareaexposes the real<textarea>for focus,readOnly,spellcheck, and other native featureseditor.rootexposes the mount elementeditor.preexposes the highlighted<pre>layereditor.onUpdate((value: string) => void)registers a callback for editseditor.update({ value?, selectionStart?, selectionEnd? })updates the text or selection; a value-only update preserves the current selectioneditor.updateOptions({ value?, lineNumbers?, highlighters?, plugins?, styles? })changes constructor options on a live editoreditor.destroy()removes listeners and nodes and restores the container; laterupdate()calls are silent no-ops
Plugins
A plugin receives the textarea state and DOM event, then returns the next state or nothing:
type Plugin = (
props: TextareaProps,
event: Event,
) => Partial<TextareaProps> | void;
interface TextareaProps {
value: string;
selectionStart: number;
selectionEnd: number;
}Yace runs plugins from left to right on keydown, input, and
compositionend. Each plugin sees the previous plugin's result.
import { Yace } from "yace";
const upperCase = ({ value }) => ({ value: value.toUpperCase() });
new Yace("#editor", { plugins: [upperCase] });First-party plugins are available from yace/plugins or individual subpaths
such as yace/plugins/tab:
history({ limit = 300, coalesceMs = 300 })adds undo and redo, caps stored states withlimit, and merges nearby edits usingcoalesceMs; it must be first in the pipelinetab(tabCharacter = " ")indents or outdents the selection with Tab and Shift+TabpreserveIndent()carries the current line's indentation to the next linecutLine(predicate?)cuts the selection or current line on Ctrl/Cmd+X;predicatecan override the triggerautoClose(pairs?)inserts matching brackets, wraps selections, steps over existing closing characters, and removes empty pairs togethertoggleComment(prefix = "// ", predicate?)toggles line comments on the current line or selection; blank lines are skippedisKey(shortcut, event)matches shortcuts with Ctrl/Cmd normalization and a physical-code fallback for non-Latin keyboard layouts
Highlighting
A highlighter turns text into HTML. Yace assigns the result to innerHTML, so
the first highlighter must escape user text. Returning raw input is an XSS
vector.
type Highlighter = (value: string, context?: { html: boolean }) => string;Highlighters run as a pipeline. The first stage receives raw text with
context.html === false. Later stages receive the previous stage's HTML with
context.html === true, so they must preserve tags instead of escaping the
whole input again. An empty pipeline falls back to Yace's escaping highlighter.
import { Yace } from "yace";
import { code } from "yace/highlighters/code";
import { shimmer } from "yace/highlighters/shimmer";
new Yace("#editor", {
highlighters: [code(), shimmer({ words: ["TODO"] })],
});Highlighters must keep glyph advance widths constant or the painted text will drift away from the native caret. Bold and italic are safe when every face in the font stack is strictly monospace.
First-party highlighters are available from yace/highlighters or individual
subpaths such as yace/highlighters/code:
code(extraRules?)is an extensible tokenizer that emitsyace-tokclasses without imposing colors. Provides approximate highlighting, without a language parser. For language-specific highlighting, see the highlight.js and PrismJS examples.markdown()highlights Markdown with the vendored mdhl and emitsmdhl-*classes; place it first or use it alonesliceGlitch(options?)splits lines into displaced RGB slices and exposes--yace-slice-aand--yace-slice-bfor themingshimmer(options?)sweeps a light band over text and exposes--yace-shimmer-baseand--yace-shimmer-bandfor theming
sliceGlitch and shimmer also accept a words array to decorate only
matching words. They inject their animation CSS at runtime; code and
markdown emit classes only, so the consumer supplies token colors.
Browser support
Yace supports evergreen Chromium, Firefox, WebKit, and current mobile browsers. It is client-only and needs the DOM. IE and SSR are not supported.
Gotchas
- With a bundler,
yace/pluginsandyace/highlighterstree-shake normally; with native browser modules, prefer exact paths such asyace/plugins/tabto avoid fetching every sibling require("yace")on Node 22+ returns the ESM module namespace, so useconst { Yace } = require("yace"); the same applies to every subpath- TypeScript consumers compiling to CommonJS need
"module": "nodenext"and TypeScript 5.8 or newer;node16rejectsrequireof an ESM package - Jest's default CommonJS runtime cannot load ESM; use Vitest or Jest's ESM mode
- Token styles may use bold or italic only when the complete font stack is strictly monospace; proportional fallback faces shift the painted layer away from the caret
Development
Install dependencies and start the landing page on http://localhost:5714:
npm install
npm run devVerify the library before submitting a change:
npm test
npm run typecheck
npm run prettier:check
npm run test:e2e
npm run attw