Skip to content

Styling

Inkwell ships default editor, plugin, and renderer styles. Import them once in your app entry point:

import "@railway/inkwell/styles.css";

The defaults are intentionally easy to override: every element also gets a stable CSS class, so you still have full control over the look and feel of both the editor and the rendered output.

Inkwell ships no min-height, max-height, or height default on the editor surface. The right container size depends on where the editor lives — a chat composer wants to size to its content, a full-page editor wants to fill the viewport, a panel just wants to fill its container. Set the size you want on styles.editor or via your own class:

// Fixed minimum height
<InkwellEditor styles={{ editor: { minHeight: 200 } }} />
// Chat-style composer that grows with content
<InkwellEditor
classNames={{ editor: "chat-composer" }}
styles={{ editor: { maxHeight: 160, overflowY: "auto" } }}
/>

Every visual-chrome default Inkwell ships — colors, backgrounds, borders, padding, typography on the editor, plugins, and renderer — is wrapped in :where() so it carries zero specificity. Any single-class consumer rule wins automatically by specificity tie-break, no !important or descendant scoping required.

That covers .inkwell-editor and its inline marks (strong, em, del, code), the heading and blockquote block classes, every .inkwell-renderer <tag> rule (including links, headings, lists, code, images, hr), the bubble menu chrome, and the shared plugin picker chrome. Three concrete patterns:

// Tailwind utility classes on the editor surface
<InkwellEditor classNames={{ editor: "border-0 bg-transparent px-3 py-2" }} />
// Restyle rendered links with a single class
<InkwellRenderer
content={content}
components={{
a: ({ children, href }) => (
<a className="text-pink-500 underline" href={href}>
{children}
</a>
),
}}
/>
// Or with global CSS — a single-class rule wins
// .my-renderer-link { color: hotpink; }

What does NOT live in :where() is layout-critical geometry: the editor wrapper’s position: relative, the bubble menu and picker popup positioning, the picker list’s structural max-height, the renderer copy button’s absolute placement, and a handful of structural overflow rules. Those keep their normal specificity so a consumer utility class can’t silently break positioning or break the picker’s flip math. Override them with descendant selectors or an explicit !important when you actually mean to.

For sweeping color changes without per-element overrides, set the CSS custom properties on the wrapper instead:

.my-editor {
--inkwell-accent: hotpink;
--inkwell-text: #111;
--inkwell-border: #eee;
}

Font sizes, line heights, heading weights, paragraph margins, list spacing, and so on are defined once and consumed by both the editor and the renderer. Override one token and both surfaces follow — the editor stays WYSIWYG with the rendered output.

TokenDefaultWhat it controls
--inkwell-font-size0.95remBody text size on both surfaces
--inkwell-line-height1.6Body line-height on both surfaces
--inkwell-heading-weight600Heading font-weight
--inkwell-heading-line-height1.3Heading line-height
--inkwell-h1-size1.75emh1 font-size
--inkwell-h2-size1.4emh2 font-size
--inkwell-h3-size1.2emh3 font-size
--inkwell-h4-size1emh4 font-size
--inkwell-h5-size0.9emh5 font-size
--inkwell-h6-size0.8emh6 font-size
--inkwell-code-font-size0.85emInline and block code font-size
--inkwell-space-paragraph0.5emTop/bottom margin on renderer paragraphs. The editor’s paragraph margin stays at 0 regardless — see the note below.
--inkwell-space-heading0.75emTop/bottom margin on headings
--inkwell-space-blockquote1emTop/bottom margin on blockquotes
--inkwell-space-list1emTop/bottom margin on ul / ol
--inkwell-space-list-item0.25emTop/bottom margin on li
--inkwell-list-indent1.5emLeft padding on ul / ol
--inkwell-space-code-block1emTop/bottom margin on code blocks
--inkwell-space-image1emTop/bottom margin on images
--inkwell-space-hr2emTop/bottom margin on hr

Apply tokens on either the editor wrapper, the renderer wrapper, or both — the surfaces share the token namespace:

/* Retune everywhere */
.inkwell-editor,
.inkwell-renderer {
--inkwell-font-size: 1rem;
--inkwell-line-height: 1.7;
--inkwell-h1-size: 2em;
}

The editor’s content model stores one <p> node per source line, so a blank line in the Markdown source becomes an empty <p> between two paragraphs — a cursor target that keeps the source round-trip lossless. If the editor honored --inkwell-space-paragraph by default, those empty paragraphs would add their own top/bottom margin on top of the real paragraphs’, visually multiplying the gap and breaking WYSIWYG in the opposite direction. The editor opts out of the token and keeps paragraph margins at 0. If you want non-zero spacing in the editor, set it explicitly with a higher-specificity rule:

.my-editor.inkwell-editor p { margin: 0.5em 0; }

The token defaults themselves — including the dark-mode set inside @media (prefers-color-scheme: dark) — also live in :where(). That means apps that drive light/dark via a class on the root element (not the OS preference) can override Inkwell’s tokens with a single-class rule:

:root.cs-light .inkwell-renderer { --inkwell-text: #1a1a1a; }
:root.cs-dark .inkwell-renderer { --inkwell-text: #f4f4f4; }

Or map Inkwell’s tokens onto your design-system tokens in one block:

.inkwell-editor,
.inkwell-editor-wrapper,
.inkwell-renderer,
.inkwell-plugin-bubble-menu-container,
.inkwell-plugin-picker-popup {
--inkwell-bg: var(--background);
--inkwell-text: var(--foreground);
--inkwell-border: var(--border);
}

No doubled-class selectors needed. The prefers-color-scheme: dark defaults still apply whenever a consumer hasn’t overridden a token.

Both <InkwellEditor /> and <InkwellRenderer /> accept props that let you attach classes and inline styles without writing a global stylesheet.

PropTypeApplied to
classNamestringThe root wrapper. Alias for classNames.root.
classNames.rootstringThe root wrapper (.inkwell-editor-wrapper).
classNames.editorstringThe editable surface (.inkwell-editor).
styles.rootCSSPropertiesInline styles on the root wrapper.
styles.editorCSSPropertiesInline styles on the editable surface.

There is no top-level style prop — use styles.root or styles.editor to be explicit about which slot the inline styles target.

<InkwellEditor
content={content}
onChange={setContent}
className="my-editor"
classNames={{ editor: "my-editor-surface" }}
styles={{ editor: { minHeight: 320, padding: "1.5rem" } }}
/>
PropTypeApplied to
classNamestringThe renderer wrapper (.inkwell-renderer).
<InkwellRenderer content={content} className="prose" />
SelectorElement
.inkwell-editor-wrapperOuter wrapper (contains editor and plugin UI)
.inkwell-editorThe contenteditable editing area

Each block-level element renders with a CSS class:

SelectorElement
.inkwell-editor-headingAll headings (always combined with a level class below)
.inkwell-editor-heading-1 through -heading-6Specific heading level
.inkwell-editor-blockquoteBlockquotes
.inkwell-editor-imageBlock image wrapper (data-selected when selected)
.inkwell-editor-code-fenceCode fence delimiter lines
.inkwell-editor-code-lineLines inside a fenced code block

Standard HTML elements are used for inline marks:

ElementFormatting
strongBold text
emItalic text
delStrikethrough text
codeInline code

Target these within the editor: .inkwell-editor strong, .inkwell-editor code, etc.

The raw Markdown characters (**, _, `, ~~, [, ], (, )) are wrapped in spans you can style separately — useful for dimming or hiding them:

SelectorCharacters
.inkwell-editor-markerGeneral syntax markers (also applied to link brackets and the URL inside [text](url))
.inkwell-editor-backtickBacktick characters

[text](url) and bare URLs (https://..., www....) decorate inline. The label text and bare-URL text get .inkwell-editor-link; the URL inside (...) gets both .inkwell-editor-marker (for the dim color) and .inkwell-editor-link-url (so consumers can restyle just the URL without touching every other dimmed marker).

SelectorElement
.inkwell-editor-linkVisible link text — the label inside [text](url), and the entire text of a bare URL autolink
.inkwell-editor-link-urlURL token inside (...) of a markdown link
SelectorElement

The cursor color is applied inline from user.color, so your CSS only needs to handle positioning and opacity.

characterLimit is a soft budget — typing past it is allowed. When a limit is configured the editor renders a small count / limit readout overlaying the top-right of the wrapper, but only once the count reaches 80% of the limit (inclusive — at limit 50, the readout shows at 40). The readout sits on a solid surface background and is absolutely positioned, so it visually layers above wrapped text without shifting content. The wrapper picks up a class while the limit is configured and a separate class while the count exceeds the limit.

SelectorElement
.inkwell-editor-character-countThe count / limit readout. Muted gray text on the editor surface background, only rendered once the count reaches 80% of characterLimit.
.inkwell-editor-character-count.inkwell-editor-character-count-overThe readout when the count exceeds the limit. Red by default.
.inkwell-editor-wrapper.inkwell-editor-has-character-limitThe wrapper while any characterLimit is configured. Acts purely as a styling hook — the bundled stylesheet ships no rules against it.
.inkwell-editor-wrapper.inkwell-editor-over-limitThe wrapper while characterCount > characterLimit. The bundled stylesheet paints a soft red halo (--inkwell-danger-soft) around the editor surface; override or extend as you like.

The renderer wraps output in <div class="inkwell-renderer">. Inside, standard HTML elements are used: h1h6, p, blockquote, ul, ol, li, pre, code, a, strong, em, del, hr, img. GFM table syntax is rendered as source text rather than <table> elements.

Target them with descendant selectors:

.inkwell-renderer h1 { }
.inkwell-renderer blockquote { }
.inkwell-renderer pre code { }

Fenced code blocks are wrapped with a container and a copy button:

SelectorElement
.inkwell-renderer-code-blockWrapper around each <pre>
.inkwell-renderer-copy-btnThe copy button (appears on hover)
SelectorElement
.inkwell-plugin-bubble-menu-containerPositioned container
.inkwell-plugin-bubble-menu-innerInner flex wrapper
.inkwell-plugin-bubble-menu-btnButton
.inkwell-plugin-bubble-menu-item-boldBold button label
.inkwell-plugin-bubble-menu-item-italicItalic button label
.inkwell-plugin-bubble-menu-item-strikeStrikethrough button label

Completions use the editor’s native placeholder. Style completion text with your existing placeholder styles on .inkwell-editor [data-slate-placeholder="true"]. The accept hint is part of the placeholder text itself, for example [tab ↹] Suggested text; there is no separate completion hint element.

Plugin picker (snippets, emoji, mentions, etc.)

Section titled “Plugin picker (snippets, emoji, mentions, etc.)”

All picker-based plugins share a single set of classes so the menu UI is consistent regardless of which plugin opened it.

SelectorElement
.inkwell-plugin-picker-popupPositioned container
.inkwell-plugin-picker-popup-flippedAdded when the popup was flipped above the caret (not enough room below in the editor wrapper or the viewport)
.inkwell-plugin-pickerPicker wrapper
.inkwell-plugin-picker-searchInline query display (characters typed after the trigger)
.inkwell-plugin-picker-listScrollable item list (capped at a fixed height with a themed scrollbar)
.inkwell-plugin-picker-itemItem row
.inkwell-plugin-picker-item-activeHighlighted row
.inkwell-plugin-picker-titleItem title
.inkwell-plugin-picker-subtitleItem subtitle
.inkwell-plugin-picker-previewItem preview text
.inkwell-plugin-picker-emptyEmpty state message

Syntax highlighting in code blocks uses highlight.js by default. Import a highlight.js theme for colors to appear:

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

If you use a different highlighter via rehypePlugins, import that highlighter’s CSS instead.

A complete stylesheet to get started with. Adjust the values to match your design system.

/* ── Editor ── */
.inkwell-editor {
min-height: 200px;
padding: 1.5rem;
outline: none;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fafafa;
color: #1a1a1a;
font-size: 1rem;
line-height: 1.7;
transition: border-color 0.15s ease;
}
.inkwell-editor:focus-within {
border-color: #6366f1;
}
/* Inline formatting */
.inkwell-editor strong { font-weight: 700; }
.inkwell-editor em { font-style: italic; }
.inkwell-editor del { text-decoration: line-through; color: #9ca3af; }
.inkwell-editor code {
background: #f3f4f6;
padding: 0.1em 0.35em;
border-radius: 4px;
font-size: 0.85em;
}
/* Block elements */
.inkwell-editor-heading { font-weight: 700; line-height: 1.3; }
.inkwell-editor-heading-1 { font-size: 2em; }
.inkwell-editor-heading-2 { font-size: 1.5em; }
.inkwell-editor-heading-3 { font-size: 1.25em; }
.inkwell-editor-blockquote {
border-left: 3px solid #d1d5db;
padding-left: 1em;
color: #6b7280;
}
.inkwell-editor-image {
margin: 0.75em 0;
border-radius: 8px;
overflow: hidden;
}
.inkwell-editor-image[data-selected] { outline: 2px solid #6366f1; }
.inkwell-editor-image img { display: block; max-width: 100%; height: auto; }
.inkwell-editor-code-fence { color: #9ca3af; }
.inkwell-editor-code-line {
font-family: ui-monospace, monospace;
font-size: 14px;
white-space: pre-wrap;
}
/* Dim Markdown syntax characters */
.inkwell-editor-marker,
.inkwell-editor-backtick {
color: #d1d5db;
}
/* ── Renderer ── */
.inkwell-renderer { font-size: 1rem; line-height: 1.7; }
.inkwell-renderer :first-child { margin-top: 0; }
.inkwell-renderer h1 { font-size: 2em; font-weight: 700; margin: 0.67em 0; }
.inkwell-renderer h2 { font-size: 1.5em; font-weight: 600; margin: 0.75em 0; }
.inkwell-renderer h3 { font-size: 1.25em; font-weight: 600; margin: 0.8em 0; }
.inkwell-renderer p { margin: 0.5em 0; }
.inkwell-renderer blockquote {
border-left: 3px solid #d1d5db;
padding-left: 1em;
margin: 1em 0;
color: #6b7280;
}
.inkwell-renderer ul,
.inkwell-renderer ol { padding-left: 1.5em; margin: 1em 0; }
.inkwell-renderer ul { list-style: disc; }
.inkwell-renderer ol { list-style: decimal; }
.inkwell-renderer li { margin: 0.25em 0; }
.inkwell-renderer img { max-width: 100%; height: auto; border-radius: 8px; }
.inkwell-renderer code {
background: #f3f4f6;
padding: 0.1em 0.35em;
border-radius: 4px;
font-size: 0.85em;
}
.inkwell-renderer pre {
margin: 1em 0;
border-radius: 8px;
overflow: auto;
}
.inkwell-renderer pre code {
display: block;
padding: 1em;
background: #1a1a2e;
color: #e2e8f0;
font-size: 14px;
}
.inkwell-renderer a { color: #6366f1; text-decoration: underline; }
.inkwell-renderer strong { font-weight: 700; }
.inkwell-renderer em { font-style: italic; }
.inkwell-renderer del { text-decoration: line-through; }
/* ── Renderer copy button ── */
.inkwell-renderer-code-block {
position: relative;
}
.inkwell-renderer-copy-btn {
position: absolute;
top: 8px;
right: 8px;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 4px;
background: rgba(255, 255, 255, 0.1);
color: #a1a1aa;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s ease;
}
.inkwell-renderer-code-block:hover .inkwell-renderer-copy-btn {
opacity: 1;
}
.inkwell-renderer-copy-btn:hover {
background: rgba(255, 255, 255, 0.2);
color: #f4f4f5;
}
/* ── Bubble menu plugin ── */
.inkwell-plugin-bubble-menu-inner {
display: flex;
gap: 2px;
background: #18181b;
border: 1px solid #3f3f46;
border-radius: 8px;
padding: 4px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5);
}
.inkwell-plugin-bubble-menu-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: transparent;
color: #a1a1aa;
border-radius: 6px;
cursor: pointer;
}
.inkwell-plugin-bubble-menu-btn:hover {
background: #27272a;
color: #f4f4f5;
}
/* ── Snippets plugin ── */
.inkwell-plugin-picker {
background: #18181b;
border: 1px solid #3f3f46;
border-radius: 8px;
overflow: hidden;
min-width: 260px;
max-width: 320px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5);
}
.inkwell-plugin-picker-search {
width: 100%;
padding: 8px 12px;
background: #09090b;
border: none;
border-bottom: 1px solid #27272a;
color: #e4e4e7;
font-size: 0.85rem;
outline: none;
}
.inkwell-plugin-picker-item {
padding: 8px 12px;
cursor: pointer;
}
.inkwell-plugin-picker-item:hover,
.inkwell-plugin-picker-item-active {
background: #27272a;
}