Skip to content

Editor

Inkwell is a WYSIWYG Markdown editor for React. The content you see is visually formatted, but the underlying content is always the Markdown source string. The Markdown source is the text.

The primary API is the InkwellEditor component. Give it your content string and an onChange handler.

import { InkwellEditor } from "@railway/inkwell";
import { useState } from "react";
function App() {
const [content, setContent] = useState("# Hello **world**");
return (
<InkwellEditor
content={content}
onChange={setContent}
placeholder="Write something..."
/>
);
}

content is the Markdown source string. onChange fires on every edit with the updated content.

Use a ref when you need imperative editor actions. The ref exposes getState(), focus(), clear(), setContent(), and insertContent().

import { type InkwellEditorHandle, InkwellEditor } from "@railway/inkwell";
import { useRef } from "react";
const editorRef = useRef<InkwellEditorHandle>(null);
<InkwellEditor ref={editorRef} content={content} onChange={setContent} />;
editorRef.current?.getState().content;
editorRef.current?.focus({ at: "end" });
editorRef.current?.setContent("Replacement");
editorRef.current?.insertContent(" **more**");

setContent() and clear() replace editor content without calling onChange. insertContent() behaves like an edit at the current selection and flows through normal change handling.

Type Markdown syntax and the editor renders it visually as you go. Type ## at the start of a line and it becomes a heading. Wrap text in ** and it turns bold. The Markdown characters stay in the document — they’re styled to show you what the output will look like.

All formatting is enabled by default.

Use these Markdown patterns at the start of a line. Headings, blockquotes, and code fences also have typing triggers; block images are recognized when Markdown is loaded, pasted, or inserted. List markers stay plain text in the editor.

PatternResult
# through ###### Headings (h1–h6)
> Blockquotes
```Fenced code blocks
![alt](url) on its own lineBlock image

Wrap text with these markers anywhere in a line:

SyntaxResult
**text**Bold
_text_Italic
~~text~~Strikethrough
`text`Inline code
[text](url)Link
https://...Auto-linked URL

Pasting a URL on top of a non-empty selection wraps the selection as [selected](pasted-url). Pasting a URL with no selection drops it in bare — the autolink decoration picks it up.

ShortcutAction
⌘B / Ctrl+BToggle bold
⌘I / Ctrl+IToggle italic
⌘D / Ctrl+DToggle strikethrough

These shortcuts come from the built-in bubble menu plugin. You can customize or disable them.

Use the features option to turn off specific block-level formatting. All features are enabled by default — you only need this prop to disable something.

<InkwellEditor
content={content}
onChange={setContent}
features={{
codeBlocks: false,
headings: { h4: false, h5: false, h6: false },
}}
/>;
FeatureDefaultControls
headingstrueHeading levels, with optional h1h6 overrides
blockquotestrueBlockquotes (>)
codeBlockstrueFenced code blocks (```)
imagestrueBlock images (![alt](url) on their own line)

When a feature is disabled, its syntax is treated as source text with no visual formatting.

Fenced code blocks are highlighted with highlight.js by default. Import a highlight.js CSS theme for colors to appear:

import "highlight.js/styles/github-dark.css";

To use a different highlighter, pass it through the rehypePlugins option:

import { InkwellEditor } from "@railway/inkwell";
import rehypeShiki from "@shikijs/rehype";
<InkwellEditor
content={content}
onChange={setContent}
rehypePlugins={[[rehypeShiki, { theme: "github-dark" }]]}
/>;

The same rehypePlugins option is available on InkwellRenderer.

The editor includes a floating formatting toolbar by default. Select text and the toolbar appears with bold, italic, and strikethrough buttons.

To disable it:

<InkwellEditor content={content} onChange={setContent} bubbleMenu={false} />;

To customize the toolbar items, see the Plugins guide.

Type: string | undefined — default: ""

The Markdown source content to display and edit.

Type: (content: string) => void

Called on every document change with the updated content.

Type: (state: InkwellEditorState) => void

Called with a full state snapshot when content, focus, editability, or character count changes. The state includes content, isEmpty, isFocused, isEditable, characterCount, characterLimit, and overLimit.

Type: string

Text shown when the editor is empty.

Type: string

CSS class applied to the outer wrapper element. This is an alias for classNames.root.

Type: { root?: string; editor?: string }

CSS classes for editor slots. root applies to the outer wrapper; editor applies to the editable surface.

Type: { root?: React.CSSProperties; editor?: React.CSSProperties }

Inline styles for editor slots. Use styles.editor for sizing the editable surface, such as height, minHeight, or width.

Type: boolean — default: true

Whether users can edit the document. Pass false for read-only editor states.

Type: InkwellFeatures

Controls which block-level formatting the editor recognizes. All enabled by default. See Disabling formatting.

Type: InkwellPlugin[]

Additional plugins to load. See Plugins.

Type: boolean — default: true

Whether to include the built-in floating toolbar. Pass false to disable it entirely, or use a custom bubble menu via plugins instead.

Type: RehypePluginConfig[]

Custom rehype plugins for code block highlighting. Accepts a plugin function or a tuple such as [plugin, ...options]. See Syntax highlighting.

Type: number

Sets a soft character budget for the editor. Typing past the limit is allowed — the editor doesn’t block input or truncate content.

When the limit is set, the editor renders a small count / limit readout overlaying the top-right of the editor wrapper, but only once the count reaches 80% of the limit (inclusive — at limit 50 the readout appears at 40, not 39). Under that threshold the readout stays hidden so it doesn’t add visual noise to a near-empty editor. The readout sits on a solid surface background and is absolutely positioned, so it visually layers above wrapped text without ever shifting content. Below the limit the readout is muted; above it the readout turns red, the wrapper picks up the inkwell-editor-over-limit class, and the editor surface gains a soft red halo (intentionally muted — not a hard ring). The overLimit field on InkwellEditorState reflects the same condition so consumers can disable submit buttons, etc.

Style the readout and the over-limit state with the .inkwell-editor-character-count classes.

Type: (count: number, limit?: number) => void

Called whenever the editor recalculates character count.

Type: boolean — default: false

When true, pressing Enter calls onSubmit(content) instead of inserting a new line. Shift+Enter still inserts a line break. Useful for chat-style surfaces.

Type: (content: string) => void

Called when submitOnEnter handles Enter.