← Guides

Using Aardink in a web app

Aardink runs in the browser as Kotlin/Wasm through Compose Multiplatform. This page covers the Kotlin side (com.aardarch:aardink-editor-web), turning it into an npm package, using that package from a Vite app, and what the browser verification checklist found.

Before adopting it, read Known limitations: a disposed editor is not fully released until Compose Multiplatform 1.13.

Compose Multiplatform for the web is Beta. Expect the rendering layer underneath Aardink to change between Compose releases.

How the pieces fit

your web app (Svelte/React/...)  ── calls ──▶  your JS exports  (aardinkCreate, aardinkSetValue, ...)
                                                     │  your wasmJs module: binaries.executable()
                                                     ▼
                                          AardinkWeb (com.aardarch:aardink-editor-web)
                                                     │
                                          CodeEditorLayout + languages (:editor, :languages)

There are two ways in:

The Kotlin API: AardinkWeb

FunctionDoes
mount(containerId, initialText, options, registry, themes)Renders an editor into the element with that id (it fills the element) and returns a handle.
getValue / setValueRead or replace the whole text. setValue clears undo history, like Monaco’s, and keeps the text exactly as given (CR LF included).
replaceValue(text)Replaces the whole text as one undo step that keeps the history, unlike setValue: for a rewrite the user can undo, such as an assistant’s. Only what differs changes (lines compared, then characters), so carets, folds and diagnostics in unchanged text stay put. Reported as an "edit"; undo brings the old text back and getAlternativeVersionId its value then. Works in a read-only editor, as setValue does. Returns whether the text changed.
executeEdits(edits, endSelections) / executeEditsJsonMonaco’s executeEdits: WebEdits ({ range, text }, a 1-based, end-exclusive WebRange) applied as one undo step that keeps the history, each reduced to what it really changes. The selections are endSelections when given (positions in the text after the edits, as Monaco’s endCursorState), else carried through the edits. false in a read-only editor, as in Monaco; edits that overlap throw.
updateOptions(options) / patchOptions(json) / currentOptionsReplace all options, or apply only the JSON keys given (Monaco’s updateOptions(partial)).
onChange(callback)Full text after each change, typed or programmatic; at most once per frame. One listener; null removes it.
onContentChange(callback)Like onChange, with the text’s version, what the change was ("edit", "undo", "redo" or "flush") and the changes since the last call as JSON, in Monaco’s IModelContentChange shape ({ range, rangeOffset, rangeLength, text }), each in the text the ones before it left.
onCursorChange(callback)1-based line and column.
onSelectionChange(callback)Every change to the selections, as JSON { selections, reason, source }: reason is keyboard, pointer, edit, undo, redo, flush or api, source Monaco’s.
onFocusChange(callback)true when the text gains the keyboard focus, false when it loses it.
getPosition / setPosition(line, column)The primary caret, 1-based; setPosition places one caret without scrolling.
revealLine(line, center) / revealPosition(line, column, center)Scroll a place into view, to the middle with center, without moving the caret.
trigger(actionId, payloadJson) / canTrigger / actionLabelMonaco’s trigger: an action the host added, or one of the editor’s own by Monaco’s id (editor.action.formatDocument, editor.action.marker.next, editor.foldLevel2, editor.action.insertSnippet with { snippet }, …).
addCommand(keybinding, run) / addAction(id, label, keybindings, inContextMenu, run) / removeActionMonaco’s addCommand and addAction: keys ("CtrlCmd+S", "Shift+F8", or Monaco’s KeyMod | KeyCode number) that run a host function ahead of the editor’s own binding, and actions that trigger runs, with keys and a right-click menu entry.
setDiagnostics(list) / setDiagnosticsJson(json)Squiggles and gutter markers, in Monaco-marker shape: 1-based lines and columns, endColumn exclusive. They replace the language’s own diagnostics, which show until then; null ("null" in JSON) goes back to those. Either way they move with the text as it is edited.
onDiagnosticsChange(callback)The language’s own diagnostics, as a JSON array in the same shape, each time they are collected: 500 ms after the editor appears, after each pause in typing, and on revalidate. Not called while the host’s list is shown. One listener; null removes it.
revalidateCollects the language’s own diagnostics again at once, without an edit: for when what they depend on outside the text has changed. They reach onDiagnosticsChange. Nothing happens while the host’s list is shown.
navigateTo(line, column)Scroll to and place the caret at a 1-based position, clamped to the document.
showFind, undo, redoAs named. showFind moves the keyboard focus to the find field. undo/redo return whether anything changed.
canUndo, canRedo, pushUndoStop, getAlternativeVersionIdMonaco’s undo surface. Keep the alternative version id when saving: the text is unsaved while it differs, and undo brings it back.
getSelections / setSelections (and …Json)Every selection in Monaco’s Selection shape, the primary one first.
setBaseline(text)The text the gutter’s diff lane compares with, typically what was last saved; "" turns it off.
format(then)The language’s formatter, as one undo step changing only what differs; then gets whether anything changed.
focusMoves the keyboard focus to the editor.
tokenize(languageId, text)For debugging a grammar: the tokens per line as JSON, in the shape of Monaco’s tokenize. A grammar’s comments and strings, being the editor’s own, come back as comment and string, and the built-in languages’ tokens under Monaco’s standard names.
dispose / isDisposedStop the editor’s work and take its element out of the container (the editor mounts into an element of its own inside it). Idempotent. The container itself is left for you to remove or reuse. See W-1 below: on Compose Multiplatform 1.12 memory is not fully released.
preloadFont()Starts fetching the bundled font, so the first editor shows in it at once; preloadAardink() in the npm package calls it.
registerLanguage(definitionJson, providers)Adds a language highlighted by a DeclarativeGrammar (a Monarch subset); extends takes a built-in language’s service and folding, inheritCompletions: false leaves out its completions, triggerCharacters open the completion list, and schema (an XmlSchema as JSON) gives an XML dialect completions, hover and checks from its vocabulary. WebLanguageProviders answers completions ({ suggestions, incomplete: false } to be asked once per word), hover and diagnostics as JSON. Editors mounted with the default registry see it.
registerTheme(name, themeJson)Adds a theme from VS Code theme JSON; its tokenColors scopes also colour grammars’ token names by dotted prefix.
setResourceUrl(path, url)Fetch a bundled resource (e.g. BUNDLED_FONT_PATH) from a URL of your choosing; see Fonts.

WebEditorOptions fields: language, theme, fontSize, wordWrap, readOnly, showGutter, showLineNumbers, showFoldMarkers, minimap, stickyScroll, bracketPairColorization, highlightCurrentLine, tabSize, insertSpaces, renderWhitespace, quickSuggestions. An unknown language falls back to plain text and an unknown theme to vscode-dark. Built-in theme keys are in AardinkWeb.builtInThemes. Changing language keeps the text, the selection and the undo history.

Typed and pasted text gets the document’s line endings: those of the text’s first line break when it was set (a CR LF file stays CR LF), LF otherwise. Text a host passes to setValue is kept verbatim.

The export template

Copy editor-web/src/wasmJsTest/kotlin/com/aardarch/aardink/web/ExportsTemplate.kt into your executable module, change its package, and fill in its three marked places: your language registry, your themes and your version string. It lives in Aardink’s test sources so every Aardink build proves it still compiles, and ExportsTemplateTest exercises it. :sample-web’s Exports.kt is the running copy, and :sample-web:verifyExportsMatchTemplate fails the build if the two ever export different functions. The template is not duplicated here because a copy in a document would drift.

Editors cross the JS boundary as integer ids. Every export is prefixed aardink so it cannot collide with your module’s own exports.

With Kotlin 2.4.20, @JsExport functions declared in a dependency klib do end up in the consuming executable’s exports. The template stays the recommended route anyway, because only your module knows your registry and themes.

Building an npm package

:sample-web is the reference for this step; copy its npmPackage Gradle task and src/npm/ folder into your own module.

./gradlew :sample-web:npmPackage     # → sample-web/build/npm, the @aardarch/aardink-web package

The package contains the raw ES modules (kotlin/aardink-web.mjs and friends), both .wasm files, Skiko and the bundled font. It also holds index.js and index.d.ts, which wrap the integer-handle exports in a Monaco-shaped API:

import { createEditor } from '@aardarch/aardink-web';

const editor = await createEditor(container, (text) => save(text), {
  value: source, language: 'xml', theme: 'vscode-dark', wordWrap: 'on',
});
editor.updateOptions({ readOnly: true });
editor.setDiagnostics([{ line: 3, startColumn: 5, endColumn: 9, message: 'Unknown tag', severity: 'error' }]);
const stop = editor.onDidChangeCursor((line, column) => status(line, column));
const off = editor.onDidChangeDiagnostics((markers) => showProblems(markers));
editor.dispose();

createEditor is async: the first call loads the WebAssembly module (~13 MB of .wasm, ~4.7 MB gzipped), and preloadAardink() starts that early. The package depends on @js-joda/core, which Compose’s date/time support imports. Its dependency list is copied from the one Kotlin generates for the module, so it stays accurate.

Vite

// vite.config.js
export default defineConfig({
  // The dev-server pre-bundler rewrites the package into .vite/deps, which breaks the relative
  // new URL('./x.wasm', import.meta.url) references the loader relies on.
  optimizeDeps: { exclude: ['@aardarch/aardink-web'] },
  build: { target: 'es2022', chunkSizeWarningLimit: 3500 },
});

vite build emits both .wasm files and the font as hashed assets. It prints two harmless warnings about node:fs and node:url being externalised: the Kotlin loader has Node and Deno branches that never run in a browser. tools/vite-smoke/ is a complete, minimal example. CI runs it on every change: pnpm smoke builds it and drives it in headless Chrome, failing on any failed check or failed request.

Your own languages and themes

import { createEditor, defineTheme, registerLanguage } from '@aardarch/aardink-web';

await registerLanguage(
  {
    id: 'aardflex',
    extends: 'xml', // XML's completions, diagnostics and folding too
    grammar: {
      tokenPostfix: '.aardflex',
      tokenizer: {
        root: [
          [/<\/?(layer|text)\b/, 'tag'],
          [/[a-z-]+(?==)/, 'attribute.name'],
          [/"[^"]*"/, 'attribute.value'],
        ],
      },
    },
    comments: { blockComment: ['<!--', '-->'] },
  },
  {
    triggerCharacters: ['{', '|', '@'], // besides XML's < / space : " =
    provideCompletionItems: (text, line, column) => [{ label: 'layer', kind: 'element' }],
    provideHover: (text, line, column) => ({ title: 'layer', contents: 'A group of elements.', example: '<layer>…</layer>' }),
    provideDiagnostics: async (text) => validate(text), // AardinkDiagnostic[]
  },
);
await defineTheme('aardflex-dark', {
  base: 'vs-dark',
  inherit: true,
  rules: [{ token: 'tag.aardflex', foreground: 'c586c0' }],
});
const editor = await createEditor(container, save, { language: 'aardflex', theme: 'aardflex-dark' });

Option names coming from Monaco

MonacoAardinkNotes
valuevaluecreateEditor only; afterwards setValue.
languagelanguagekotlin, typescript, json, toml, xml, html, css, markdown, plaintext, plus any you register.
themethemeAardink’s theme keys, or your own (see the template).
fontSizefontSizeCSS pixels; line height scales with it.
wordWrap ('on' / 'off')wordWrapSame values.
readOnlyreadOnly
lineNumbers ('on' / 'off')showLineNumbersBoolean.
foldingshowFoldMarkersBoolean.
glyphMarginshowGutterHides the whole gutter.
minimap: { enabled }minimapThe same object, or a boolean. Blocks rather than characters, as with Monaco’s renderCharacters: false.
stickyScroll: { enabled }stickyScrollThe same object, or a boolean.
bracketPairColorization: { enabled }bracketPairColorizationThe same object, or a boolean.
renderLineHighlightrenderLineHighlight / highlightCurrentLineAnything but 'none' highlights each caret’s line.
tabSize, insertSpacestabSize, insertSpaces
Multi-cursor and column selectionThe same keys and mouseAlt+click, Ctrl/Cmd+D, Ctrl+Shift+L, Ctrl+Alt+Up/Down, Shift+Alt+drag, Ctrl+Shift+Alt+arrows.
model.onDidChangeContentonDidChangeContentReceives the full text, at most once per frame, and { versionId, isUndoing, isRedoing, isFlush, changes }. changes are Monaco’s IModelContentChanges, each in the text the ones before it left, so applying them in order rebuilds the text.
onDidChangeCursorSelectionThe same{ selection, secondarySelections, reason, source }; reason is a word ('keyboard', 'pointer', 'edit', 'undo', 'redo', 'flush', 'api').
onDidFocusEditorText, onDidBlurEditorTextThe same
getPosition, setPositionThe samesetPosition does not scroll.
revealLine, revealLineInCenter, revealRange, revealRangeInCenterThe sameScroll without moving the caret; a range by its start.
trigger(source, handlerId, payload)The sameYour actions, and the editor’s own by Monaco’s ids (see index.d.ts).
addCommand(keybinding, handler)The same'CtrlCmd+S'-style text, or Monaco’s KeyMod | KeyCode number for a letter, digit, function, navigation or punctuation key. Runs ahead of the editor’s own binding.
addAction(descriptor), getAction(id)The samekeybindings, and contextMenuGroupId for a right-click menu entry; getAction also knows the editor’s own actions.
quickSuggestionsquickSuggestionsA boolean, or Monaco’s { other, comments, strings } of which other decides; off by default.
getSelections / setSelectionsThe sameMonaco’s Selection fields.
model.canUndo, canRedo, pushStackElement, getAlternativeVersionIdcanUndo, canRedo, pushUndoStop, getAlternativeVersionId
editor.executeEdits(source, edits, endCursorState)executeEditsThe same arguments; endCursorState as an array of selections (a computer function is not called). Each edit is reduced to what it really changes, so carets inside an edit whose text is mostly unchanged stay put. Each call is an undo step of its own, as with pushUndoStop before and after.
model.pushEditOperations over getFullModelRange(), to replace the text undoablyreplaceValue(text)One call; only what differs changes. setValue stays for opening a file.
getAction('editor.action.formatDocument').run()format()Resolves with whether anything changed.
monaco.editor.tokenizetokenizeSame arguments and shape: a grammar’s own names, and Monaco’s names for the built-in languages’ tokens (keyword.flow, comment.doc, string.escape, …).
monaco.editor.setModelMarkerssetDiagnosticsMonaco’s IMarkerData as it is: startLineNumber to endLineNumber, MarkerSeverity numbers, code (or { value, target }), tags, relatedInformation. null shows the language’s own again. One owner.
monaco.languages.register + setMonarchTokensProviderregisterLanguageOne call: { id, grammar, extends?, comments? }, plus providers.
monaco.editor.defineThemedefineTheme / registerThemeMonaco’s { base, inherit, rules, colors }, or VS Code theme JSON. A rule’s foreground and fontStyle apply; its background is ignored.
monaco.editor.onDidChangeMarkersonDidChangeDiagnosticsThe language’s own diagnostics, as marker objects.
Re-running a model’s validationrevalidate()Monaco has no single call; this asks the language’s diagnostics again without an edit.
revealPositionInCenterIfOutsideViewportrevealPositionAlso places the caret there. A position already on screen does not scroll; one off screen comes to the middle of the view.
renderWhitespacerenderWhitespace'none', 'boundary', 'selection' (the default), 'trailing', 'all'; the colour is the theme’s editorWhitespace.foreground.
registerCompletionItemProvider(id, { triggerCharacters, provideCompletionItems })registerLanguage’s providerstriggerCharacters and provideCompletionItems on the same object; items with filterText, sortText, range, detail, commitCharacters and Monaco’s kind names or numbers. Answer { suggestions, incomplete: false } to be asked once per word, Monaco’s behaviour; without it your provider is asked on each letter, as in 0.6.
Completion snippets (insertTextRules: InsertAsSnippet)insertTextRulesCompletionItemInsertTextRule.InsertAsSnippet: tab stops, placeholders, choices and the TM_ variables; Tab and Shift+Tab step through them. KeepWhitespace as in Monaco.
deltaDecorations, createDecorationsCollectionThe sameisWholeLine, hoverMessage, overviewRuler.color and minimap.color as Monaco’s; colours (backgroundColor, borderColor, underlineColor, gutterColor, CSS hex) where Monaco takes a CSS class. Nothing that changes the text’s layout (before, after).
overviewRulerLanesoverviewRulerLanesAbove 0 shows the ruler; off by default.
occurrencesHighlight, linkedEditing, colorDecoratorsThe sameOff by default.
fontLigaturesThe sameOn by default.
getVisibleRanges, getScrollTop, setScrollTop, getScrollHeight, onDidScrollChange, layoutThe sameCSS pixels. layout() measures the container again; the editor follows its size anyway.
monaco.editor.setModelMarkers(model, owner, markers)setModelMarkers(owner, markers), or setDiagnostics(markers, owner)Each owner’s markers are shown with every other owner’s and the language’s own.
languages.registerDocumentSymbolProvider, registerColorProviderregisterLanguage’s provideDocumentSymbols, provideDocumentColorsRead the result with getDocumentSymbols() and getSymbolPathAt(position), the caret-to-preview mapping.
KeyMod.chord(...)The same, or 'CtrlCmd+K CtrlCmd+S'In addCommand and addAction.
fontFamily, automaticLayoutNoneThe font is the bundled JetBrains Mono; the editor always follows its container’s size.

Fonts

The browser canvas has no system fonts, so aardink-editor-web bundles JetBrains Mono Regular (SIL Open Font License 1.1; see editor-web/JETBRAINS_MONO_OFL.txt) as a Compose resource. Skia synthesizes the bold and italic the editor occasionally uses.

Compose looks for resources relative to the page, not the module, which a package installed under node_modules never satisfies. So index.js points the font at its own copy with a literal new URL(..., import.meta.url), which is also what makes Vite emit the file. Outside the npm package, call aardinkSetBundledFontUrl(url) before the first aardinkCreate, or serve composeResources/ next to the page. If the font cannot be loaded at all, the editor still works: it logs Aardink: could not load the bundled JetBrains Mono ... and falls back to the default monospace font.

A new editor waits up to half a second for the font before it shows, rather than appearing in the fallback and then jumping to JetBrains Mono (W-9); preloadAardink() starts the fetch early, so usually it waits for nothing. If the font takes longer, the editor shows in the fallback and switches when the font arrives.

Host page CSS

Browser requirements

Wasm GC and exception handling: Chrome/Edge 119+, Firefox 120+, Safari 18.2+.

Performance

Measured with tools/vite-smoke/perf.mjs in headless Chrome 154 on a desktop machine, on highlighted Kotlin with the language’s own diagnostics running (the 0.6.0 code, 2026-09-27). Expect slower on phones.

DocumentMount → idleKey → idle, p50 / p95Key → change callback, p95setValue → idleKeys lost at 10 keys/s
50 lines (2 KB)26 ms9.6 / 32.7 ms9.4 ms8 ms0 of 30
250 lines (8 KB)74 ms10.6 / 24.0 ms8.6 ms14 ms0 of 30
1,000 lines (33 KB)101 ms8.2 / 23.3 ms7.8 ms19 ms0 of 30
5,000 lines (163 KB), two runs62–70 ms7.8–9.9 / 16.9–21.6 ms3.3–6.0 ms11–18 ms0 of 30

A keystroke costs the same in any size of document: the editor lays out and draws only the lines on screen, the input method sees a window of text around the caret rather than the whole document, and the first highlighting pass over a large document runs in slices of about 8 ms, so frames keep coming while it works. No main-thread task over 50 ms occurs anywhere in these runs: mounting, typing, setValue or scrolling.

The tails are the language’s diagnostics: they run half a second after each pause in typing, as for a user, and perf.mjs pauses longer than that between keys, so a key sometimes arrives while a slice of them is running and waits for it. Before automatic diagnostics (PR 7 of the 0.6 plan), the 5,000-line document measured 8.6 / 18.9 ms per key and 2.6 ms to the change callback. Measurements on a busy machine (a build running alongside) show single 50–60 ms tasks and p95s past 30 ms; the table is from an otherwise idle one.

The minimap (minimap: { enabled: true }) adds 0.1 to 0.5 ms of main-thread work per frame while scrolling the 5,000-line document, and scrolling stays at 60 frames a second: it draws the text as blocks into tiles of 256 lines, kept until a line in them changes, and draws only the tiles on screen.

For comparison, 0.5 (one BasicTextField holding the whole document) took 3.2 s to settle after mounting the same 5,000 lines, 3.7 s after each keystroke, and lost 15 to 29 of 30 keys typed at 10 keys a second.

CI runs perf.mjs --gate on every push; a measurement past its gate fails the build. The gates (400 ms to mount, 50 ms p95 per key, no lost keys, 2 ms per frame for the minimap) are looser than the targets above to absorb shared-runner noise.

Verification checklist

Automated items run against the production build: pnpm checklist in tools/vite-smoke/ covers W-1, part of W-2, W-3, W-7, W-8 and W-10 (node checklist.mjs W-2 W-10 runs a subset), and pnpm perf measures latency and lost keys. The rest need real devices or a person.

IDCheckResult
W-1Mount + dispose 50×, heapLeaks ~320 KB per disposed editor (275 KB in 0.5). JS heap after forced GC goes 9.1 → 25.1 → 41.2 MB over two batches of 50; no canvases or other elements are left behind, since dispose removes the editor’s own element. Each ComposeViewport adds resize, focus, blur, visibilitychange and dragend listeners to window that Compose 1.12.1 never removes, and there is no public API to tear a viewport down. Removing those listeners by hand does not free the memory, so something inside the scene (such as the Recomposer’s snapshot observers) also holds it. Reuse one editor with setValue/updateOptions instead of mounting a new one per view. Tracked upstream as CMP-9090 and CMP-10507; fixed for Compose Multiplatform 1.13, which disposes a viewport when its element leaves the DOM (compose-multiplatform-core#3242).
W-2IME composition: CJK, macOS dead keys, Android Chrome GboardAutomated part passes: checklist.mjs W-2 drives composition through the DevTools protocol (Input.imeSetComposition / Input.insertText), the path Chrome’s own IME bridge uses: kana → kanji, a second composition, a simulated dead key and Pinyin-style letters all commit exactly (4/4). Real IMEs still manual: Windows/macOS CJK, macOS dead keys and press-and-hold, Android Chrome with Gboard.
W-3Paste: 100 KB latency, CRLF, Firefox permission promptA 101 KB paste reports its change after ~21 ms. CRLF used to be kept verbatim; typed and pasted text is now normalised to LF. The editor takes pasted text from the browser’s paste event, which needs no permission; Firefox and Safari: manual.
W-4Selection: mouse drag, Shift+arrows, double-click, touch handlesManual.
W-5Mobile soft keyboard: viewport resize, toolbar placementManual (needs a phone).
W-6Focus: click-to-focus; Tab stays in the field; Ctrl+F/Ctrl+S are not the browser’sClick-to-focus works (the checklist types after a click). Tab and browser shortcuts: manual.
W-7Wheel over the editor must not scroll the pageContained: a 600 px wheel over the editor leaves the page’s scrollY at 0.
W-8devicePixelRatio and zoomAt DPR 2 the canvas has a 2000×1200 backing store for 1000×600 CSS px, and text and squiggles are sharp. A change of density or font scale (zoom, another screen) lays every line out again (EditorViewTest). Browser zoom by hand: manual.
W-9No flicker when Compose fetches a fallback font for a missing glyphManual. A new editor waits up to 500 ms for the bundled font instead of showing a first frame in the default monospace; preloadAardink() fetches it early.
W-10Typing latency, 5,000-line fileAt 163 KB of Kotlin a keystroke settles in 8–10 ms (p50), no key is lost at 10 keys/s, and the initial highlighting pass causes no long task. perf.mjs measures it and CI gates it; see Performance.

Known limitations

Source: docs/WEB_INTEGRATION.mdat v0.8.0.