@dotvoid/textbit
v1.8.0
Published
Textbit is an editable component created as part of the Elephant project, based on Slate.
Downloads
202
Readme
Textbit
An unstyled, plugin-based rich text editor component for React applications. Built on Slate with support for collaborative editing via Yjs.
Installation
npm install @dotvoid/textbitDevelopment
npm install
npm run devBuild ESM and CJS modules:
npm run buildThis produces ESM and CJS modules along with TypeScript definitions in dist/.
Quick Start
import { Textbit } from '@dotvoid/textbit'
import type { TBElement } from '@dotvoid/textbit'
const initialValue: TBElement[] = [
{
type: 'core/text',
id: crypto.randomUUID(),
class: 'text',
children: [{ text: 'Hello world!' }]
}
]
function MyEditor() {
const [value, setValue] = useState(initialValue)
return (
<Textbit.Root
value={value}
onChange={setValue}
placeholder="Start typing..."
>
<Textbit.Editable className="editor" />
</Textbit.Root>
)
}Table of Contents
- Core Components
- Menu Components
- Toolbar Components
- Context Menu Components
- Hooks
- Styling
- Collaborative Editing
- Plugin Development
- Utilities
- TypeScript
Core Components
Textbit.Root
The root component that provides context for the editor. All other Textbit components must be descendants of Textbit.Root.
Props
| Name | Type | Default | Description |
|------|------|---------|-------------|
| value | string \| Descendant[] \| Y.XmlText | - | Required. Editor content. Can be a string, Slate Descendant array, or Yjs XmlText for collaboration. |
| onChange | (value: string \| Descendant[]) => void | - | Called when content changes. Will serve Descendant[] when used with Y.XmlText. |
| awareness | Awareness \| null | - | Yjs awareness instance for collaborative cursors. Only valid when value is Y.XmlText. |
| cursor | CursorConfig | - | Cursor configuration for collaboration. See Collaborative Editing. |
| plugins | TBPluginDefinition[] | - | Array of plugin definitions. |
| placeholder | string | '' | Placeholder text when editor is empty. |
| placeholders | 'none' \| 'single' \| 'multiple' | 'none' | Controls placeholder display mode. When using multiple text plugins displays their own placeholders per text object. |
| readOnly | boolean | false | Makes editor read-only. |
| debounce | number | 1250 | Debounce time for onChange in milliseconds. |
| spellcheckDebounce | number | 1250 | Debounce time for spellcheck in milliseconds. |
| onSpellcheck | SpellcheckFunction | - | Async function to handle spellchecking. |
| verbose | boolean | false | Enables console logging for debugging. |
| className | string | - | CSS class for root container. |
| style | React.CSSProperties | - | Inline styles for root container. |
| dir | 'ltr' \| 'rtl' | 'ltr' | Text direction. |
| lang | string | 'en' | Language code (e.g., 'en', 'sv'). |
Spellcheck Function Type
A spellcheck function will receive an array of texts (with language code and the actual text). The function is expected to resolve with one array of spelling issues per text, in the same order. Each spelling issue defines the misspelled string, an array of suggested substitutions and a severity level. It does not carry a position - textbit finds every occurrence of the string in the text itself, which is also why an issue applies to all occurrences rather than to one spot. The id is assigned by textbit, so it is omitted from what you return.
When loading the editor the first time the whole text will be spellchecked. After that only the text object changed will be checked.
type SpellcheckFunction = (
texts: Array<{ lang: string; text: string }>
) => Promise<Array<Array<Omit<SpellingError, 'id'>>>>
type SpellingError = {
id: string // Assigned by textbit, not by the spellcheck function
text: string // The misspelled text
level?: 'error' | 'suggestion' // Severity level
suggestions: Array<{
text: string // Suggested replacement
description?: string // Optional note shown alongside the suggestion
}>
}Examples
String Mode
function SimpleEditor() {
const [text, setText] = useState('')
return (
<Textbit.Root value={text} onChange={setText}>
<Textbit.Editable />
</Textbit.Root>
)
}Structured Mode
import { Bold, Italic, Heading } from './plugins'
const initialValue = Descendant[] = [
{
type: 'core/text',
id: '538345e5-bacc-48f9-8ef1-a219891b6011',
class: 'text',
properties: {
role: 'heading-1'
},
children: [
{ text: 'The Baltic Sea' }
]
},
{
type: 'core/text',
id: '538345e5-bacc-48f9-8ef0-1219891b6024',
class: 'text',
children: [
{ text: 'This text editor was built on an island in the ' },
{
text: 'Baltic Sea',
'core/bold': true
},
{
text: '.'
}
]
}
]
function RichTextEditor() {
const [value, setValue] = useState(initialValue)
return (
<Textbit.Root
value={value}
onChange={setValue}
plugins={[Bold(), Italic(), Heading()]}
>
<Textbit.Editable />
</Textbit.Root>
)
}With Spellcheck
function EditorWithSpellcheck() {
const [value, setValue] = useState(initialValue)
const handleSpellcheck = async (texts: { lang: string, text: string }[]) => {
// One array of errors per text, in the same order. Omit `id` - textbit
// assigns it.
return texts.map(({ text, lang }) => {
return [
{
text: 'teh',
level: 'error' as const,
suggestions: [{ text: 'the', description: 'article' }, { text: 'tea' }]
},
{
text: 'recieve',
level: 'error' as const,
suggestions: [{ text: 'receive' }]
}
]
})
}
return (
<Textbit.Root
value={value}
onChange={setValue}
onSpellcheck={handleSpellcheck}
>
<Textbit.Editable />
</Textbit.Root>
)
}Textbit.Editable
The editable content area. Must be a child of Textbit.Root.
Props
| Name | Type | Default | Description |
|------|------|---------|-------------|
| autoFocus | boolean \| 'start' \| 'end' | false | Auto-focus behavior. true/'start' focuses at start, 'end' focuses at end. |
| onFocus | React.FocusEventHandler<HTMLDivElement> | - | Called when editor receives focus. |
| onBlur | React.FocusEventHandler<HTMLDivElement> | - | Called when editor loses focus. |
| isSpellingAccepted | (error: TBSpellingError) => boolean | - | Answer whether a reported spelling error is accepted in this document. Must be memoized. See Accepting Spelling Errors. |
| constraints | { allowEdgeWhitespace?: boolean } | - | Set allowEdgeWhitespace to false to trim leading and trailing whitespace on blur. Whitespace is kept by default. |
| aria-label | string | - | Accessible label for the editable area. |
| className | string | - | CSS class for editable container. |
| style | React.CSSProperties | - | Inline styles for editable container. |
| children | React.ReactNode | - | Additional components (Toolbar, Gutter, etc.). |
Data Attributes
| Attribute | Values | Description |
|-----------|--------|-------------|
| data-state | "focused" \| "" | Indicates whether editor has focus. |
Example
<Textbit.Editable
autoFocus="end"
className="prose dark:prose-invert"
onFocus={() => console.log('Editor focused')}
onBlur={() => console.log('Editor blurred')}
>
<Textbit.Gutter>
<Menu.Root>{/* ... */}</Menu.Root>
</Textbit.Gutter>
<Toolbar.Root>{/* ... */}</Toolbar.Root>
</Textbit.Editable>Accepting Spelling Errors
A spellchecker flags a word, but the word is right here - a name, usually. The user needs to say so, and the mark needs to change accordingly, without the spellchecker having to agree and without the decision leaking into a shared dictionary.
isSpellingAccepted is called for every error the spellchecker reported. A
true answer marks the word data-spelling-accepted instead of letting it read
as a correction. Textbit does not know why a word is accepted - it knows
nothing about documents, users or storage. Where the decision lives, who made it
and when, all belongs to you.
const [accepted, setAccepted] = useState<Set<string>>(() => loadForDocument(id))
// Must be memoized. It takes part in the decorate callback's identity, so a
// fresh function on every render re-decorates continuously.
const isSpellingAccepted = useCallback(
(error: TBSpellingError) => accepted.has(error.text),
[accepted]
)
<Textbit.Editable isSpellingAccepted={isSpellingAccepted} />The predicate receives the whole TBSpellingError, so you can decide on level
or on the suggestions offered as well as on the text.
Four things worth knowing:
- Accepted words are marked, not dropped. The leaf keeps its
data-spelling-errorand gainsdata-spelling-acceptedon top, so the acceptance stays visible and there is something to right-click to undo. See Spelling Errors for styling, and useContextMenuHints() for offering the undo. - Filtering accepted words out of your
onSpellcheckresults instead does not work. Results are cached per node and only refreshed when that node's text changes, so a word accepted while reading would keep its old mark until the paragraph was edited - andeditor.spellcheck.force()does not help, as it runs the check immediately, finds nothing changed, and checks nothing. Deciding here applies the change on the next render, with no re-check and no network call, to every occurrence in the document at once. - Case sensitivity is yours. Textbit passes the reported text through unchanged.
- Acceptance is document-wide by text. Decoration matches by searching the node text rather than by offsets, so a word cannot currently be accepted at one position and left marked at another, even though some spell services return spans that would allow it.
Textbit.Gutter
Provides a gutter area for content tools (like a menu). Automatically positions itself relative to the active block.
Props
| Name | Type | Description |
|------|------|-------------|
| children | React.ReactNode | Content to display in gutter (typically Menu.Root). |
Example
<div style={{ display: 'grid', gridTemplateColumns: '50px 1fr' }}>
<Textbit.Gutter>
<Menu.Root>
<Menu.Trigger>⋮</Menu.Trigger>
<Menu.Content>
{/* Menu items */}
</Menu.Content>
</Menu.Root>
</Textbit.Gutter>
<Textbit.Editable />
</div>Textbit.DropMarker
Visual indicator for drag-and-drop operations. Automatically handles positioning and visibility.
Props
| Name | Type | Description |
|------|------|-------------|
| className | string | CSS class for styling the drop marker. |
Data Attributes
| Attribute | Values | Description |
|-----------|--------|-------------|
| data-dragover | "none" \| "between" \| "around" | Indicates drag state. "between" shows line between elements, "around" encompasses droppable element. |
Example
<Textbit.Editable>
<Textbit.DropMarker className="drop-marker" />
{/* Other children */}
</Textbit.Editable>CSS Styling
.drop-marker[data-dragover="between"] {
height: 2px;
background: #3b82f6;
}
.drop-marker[data-dragover="around"] {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}Textbit.UploadMarker
Visual placeholder rendered at each drop position while a plugin consumer is running (await consume() still pending). Reserves flow space above the target block so surrounding content shifts down rather than being overlaid, and removes the reserved space automatically when the consumer resolves, fails, or is cancelled.
The marker is ephemeral, it lives in local React state (via PendingDropsProvider, mounted internally by Textbit.Editable), and never syncs to the shared Yjs document. In a collaborative session, each peer's pending drops are mirrored to Yjs awareness so other peers can see intent markers at the same positions.
Props
| Name | Type | Default | Description |
|------|------|---------|-------------|
| className | string | - | CSS class applied to each marker element. |
| style | CSSProperties | - | Inline style merged onto each marker. |
| height | number | 60 | Reserved flow height in pixels. The target block gets an inline margin-top of this value so the marker sits in real space rather than overlaying content. |
| children | (drop: PendingDrop) => ReactNode | - | Per-drop render override. Receives the pending drop and returns any React node. When omitted, a minimal default is rendered. |
Data Attributes
| Attribute | Values | Description |
|-----------|--------|-------------|
| data-source | "own" \| "peer" | Whether this marker was initiated by this session ("own") or received from another peer via awareness ("peer"). |
| data-kind | string | The produces type declared by the plugin consumer (e.g. "core/image") — free-form. |
PendingDrop
interface PendingDrop {
id: string
source: 'own' | 'peer'
path: Path
kind?: string
peerId?: number // when source === 'peer'
peerData?: unknown // peer's cursor data (typically { name, color })
}Example — default marker
<Textbit.Editable>
<Textbit.DropMarker className="drop-marker" />
<Textbit.UploadMarker className="upload-marker" />
</Textbit.Editable>.upload-marker {
background: rgba(59, 130, 246, 0.08);
border: 1px dashed rgba(59, 130, 246, 0.6);
border-radius: 4px;
}
.upload-marker[data-source="peer"] {
background: rgba(150, 150, 150, 0.08);
border-color: rgba(150, 150, 150, 0.6);
}Example — render-prop children
Use the render prop when you want richer per-drop UI (spinner, peer name badge, cancel button) or different rendering for own vs peer drops:
<Textbit.UploadMarker height={80}>
{(drop) => (
<div className={`upload-chip upload-chip--${drop.source}`}>
{drop.source === 'own'
? <MySpinner label={`Uploading ${drop.kind}...`} />
: <span style={{ color: (drop.peerData as {color?: string})?.color }}>
{(drop.peerData as {name?: string})?.name ?? 'Peer'} is uploading
</span>}
</div>
)}
</Textbit.UploadMarker>Programmatic access
usePendingDrops() returns the same array of PendingDrop objects the marker reads from. Useful for status bars or counters:
import { usePendingDrops } from '@dotvoid/textbit'
function UploadStatusBar() {
const drops = usePendingDrops()
const own = drops.filter((d) => d.source === 'own').length
if (own === 0) return null
return <div className="status">{own} uploads pending</div>
}Textbit.Plugins
Array of standard plugins included with Textbit.
import { Textbit } from '@dotvoid/textbit'
// Use default plugins
<Textbit.Root plugins={Textbit.Plugins}>
<Textbit.Editable />
</Textbit.Root>
// Use custom plugins
<Textbit.Root plugins={[...Textbit.Plugins, MyCustomPlugin()]}>
<Textbit.Editable />
</Textbit.Root>Menu Components
Components for building a content menu (block-level tools). Typically used in the gutter.
Menu.Root
Root component for the menu structure.
Props
| Name | Type | Description |
|------|------|-------------|
| className | string | CSS class for menu root. |
| children | React.ReactNode | Menu content. |
Data Attributes
| Attribute | Values | Description |
|-----------|--------|-------------|
| data-state | "open" \| "closed" | Indicates menu open state. |
Menu.Trigger
Button that toggles the menu.
Props
| Name | Type | Description |
|------|------|-------------|
| className | string | CSS class for trigger button. |
| children | React.ReactNode | Trigger content (text, icon). |
Menu.Content
Container for menu items.
Props
| Name | Type | Description |
|------|------|-------------|
| className | string | CSS class for menu content. |
| children | React.ReactNode | Menu groups and items. |
Menu.Group
Groups related menu items.
Props
| Name | Type | Description |
|------|------|-------------|
| className | string | CSS class for group. |
| children | React.ReactNode | Menu items. |
Menu.Item
Individual menu item that triggers a plugin action.
Props
| Name | Type | Description |
|------|------|-------------|
| action | string \| TBPluginRegistryAction | Required. Action name or action object from plugin registry. |
| className | string | CSS class for item. |
| children | React.ReactNode | Item content (icon, label, hotkey). |
Data Attributes
| Attribute | Values | Description |
|-----------|--------|-------------|
| data-state | "active" \| "inactive" | Indicates if item's plugin is active in current selection. |
Example
import { usePluginRegistry } from '@dotvoid/textbit'
function ContentMenu() {
const { actions } = usePluginRegistry()
return (
<Menu.Root className="menu">
<Menu.Trigger className="menu-trigger">⋮</Menu.Trigger>
<Menu.Content className="menu-content">
<Menu.Group className="menu-group">
{actions
.filter(a => a.plugin.class === 'text')
.map(action => (
<Menu.Item
key={action.name}
action={action.name}
className="menu-item"
>
<Menu.Icon className="menu-icon" />
<Menu.Label className="menu-label" />
<Menu.Hotkey className="menu-hotkey" />
</Menu.Item>
))
}
</Menu.Group>
</Menu.Content>
</Menu.Root>
)
}Menu.Icon
Displays the action's icon. Auto-populated from plugin or can be overridden.
Props
| Name | Type | Description |
|------|------|-------------|
| className | string | CSS class for icon. |
| children | React.ReactNode | Optional. Override default icon. |
Menu.Label
Displays the action's label. Auto-populated from plugin or can be overridden.
Props
| Name | Type | Description |
|------|------|-------------|
| className | string | CSS class for label. |
| children | React.ReactNode | Optional. Override default label. |
Menu.Hotkey
Displays the action's keyboard shortcut. Automatically formats platform-specific shortcuts (e.g., mod+b becomes ⌘B on Mac, Ctrl+B on Windows).
Props
| Name | Type | Description |
|------|------|-------------|
| className | string | CSS class for hotkey. |
| children | React.ReactNode | Optional. Override default hotkey. |
Toolbar Components
Components for building a context toolbar (inline tools like bold, italic). The toolbar automatically positions itself near the current selection.
Toolbar.Root
Root component for context toolbar.
Props
| Name | Type | Description |
|------|------|-------------|
| className | string | CSS class for toolbar. |
| children | React.ReactNode | Toolbar groups and items. |
Toolbar.Group
Groups related toolbar items.
Props
| Name | Type | Description |
|------|------|-------------|
| className | string | CSS class for group. |
| children | React.ReactNode | Toolbar items. |
Toolbar.Item
Individual toolbar button that triggers a plugin action.
Props
| Name | Type | Description |
|------|------|-------------|
| action | string \| TBPluginRegistryAction | Required. Action name or action object from plugin registry. |
| className | string | CSS class for item. |
Data Attributes
| Attribute | Values | Description |
|-----------|--------|-------------|
| data-state | "active" \| "inactive" | Indicates if item's plugin is active in current selection. |
Example
import { usePluginRegistry } from '@dotvoid/textbit'
function ContextToolbar() {
const { actions } = usePluginRegistry()
return (
<Toolbar.Root className="toolbar">
<Toolbar.Group className="toolbar-group">
{actions
.filter(a => a.plugin.class === 'leaf')
.map(action => (
<Toolbar.Item
key={action.name}
action={action}
className="toolbar-item"
/>
))
}
</Toolbar.Group>
<Toolbar.Group className="toolbar-group">
{actions
.filter(a => a.plugin.class === 'inline')
.map(action => (
<Toolbar.Item
key={action.name}
action={action}
className="toolbar-item"
/>
))
}
</Toolbar.Group>
</Toolbar.Root>
)
}Context Menu Components
Components for building a context menu (right-click menu), primarily for spelling suggestions and custom actions.
Textbit.ContextMenu.Root
Root component for context menu. Automatically positions based on right-click location.
Props
| Name | Type | Description |
|------|------|-------------|
| className | string | CSS class for context menu. |
| children | React.ReactNode | Menu groups and items. |
Textbit.ContextMenu.Group
Groups related context menu items.
Props
| Name | Type | Description |
|------|------|-------------|
| className | string | CSS class for group. |
| children | React.ReactNode | Context menu items. |
Textbit.ContextMenu.Item
Individual context menu item.
Props
| Name | Type | Description |
|------|------|-------------|
| func | () => void | Callback function executed on click. Optional if only displaying static content. |
| className | string | CSS class for item. |
| children | React.ReactNode | Item content. |
Example
import { useContextMenuHints } from '@dotvoid/textbit'
function SpellingContextMenu() {
const { spelling } = useContextMenuHints()
return (
<Textbit.ContextMenu.Root className="context-menu">
<Textbit.ContextMenu.Group className="context-menu-group">
{spelling?.suggestions.length === 0 && (
<Textbit.ContextMenu.Item className="context-menu-item">
No spelling suggestions
</Textbit.ContextMenu.Item>
)}
{spelling?.suggestions.map(({ text, description }) => (
<Textbit.ContextMenu.Item
key={text}
className="context-menu-item"
func={() => spelling.apply(text)}
>
{text}
{description && <em> - {description}</em>}
</Textbit.ContextMenu.Item>
))}
</Textbit.ContextMenu.Group>
</Textbit.ContextMenu.Root>
)
}
// Use it in Textbit.Editable
<Textbit.Editable>
<SpellingContextMenu />
</Textbit.Editable>Hooks
useTextbit()
Access Textbit context and editor state.
const {
stats, // TextbitStats
verbose, // boolean
readOnly, // boolean
collaborative, // boolean
placeholders, // PlaceholdersVisibility
placeholder, // string
dir, // 'ltr' | 'rtl'
lang, // string
dispatch // Dispatch<PluginRegistryReducerAction>
} = useTextbit()
interface TextbitStats {
full: { words: number; characters: number }
short: { words: number; characters: number }
}Statistics
Full statistics includes all nodes of class 'text' regardless of level. Short statistics only include top nodes of type 'core/text'.
Example
function EditorStats() {
const { stats } = useTextbit()
return (
<div>
<div>Words: {stats.full.words}</div>
<div>Characters: {stats.full.characters}</div>
{stats.short.words > 0 && (
<div>Short: {stats.short.words} words</div>
)}
</div>
)
}usePluginRegistry()
Access registered plugins and actions.
const {
plugins, // TBPluginDefinition[]
components, // Map<string, PluginRegistryComponent>
actions // TBPluginRegistryAction[]
} = usePluginRegistry()Example
function PluginList() {
const { plugins, actions } = usePluginRegistry()
return (
<div>
<h3>Registered Plugins: {plugins.length}</h3>
<ul>
{actions.map(action => (
<li key={action.name}>{action.title}</li>
))}
</ul>
</div>
)
}useAction(pluginName, actionName)
Get a specific action function from a plugin. Useful for programmatic control.
const myAction = useAction('core/image', 'upload-image')
// Call it with optional arguments
myAction({ file: imageFile, url: 'https://...' })Example
function ImageUploader() {
const uploadImage = useAction('core/image', 'insert-image')
const handleFileSelect = async (file: File) => {
const url = await uploadToServer(file)
uploadImage({ url, alt: file.name })
}
return <input type="file" onChange={e => handleFileSelect(e.target.files[0])} />
}useContextMenuHints()
Access context menu state and spelling information.
const {
isOpen, // boolean
position, // { x: number; y: number } | undefined
target, // HTMLElement | undefined
event, // MouseEvent | undefined
nodeEntry, // NodeEntry | undefined
spelling // SpellingInfo | undefined
} = useContextMenuHints()
interface SpellingInfo {
id: string
text: string
level?: 'error' | 'suggestion'
suggestions: Array<{
text: string
description?: string
}>
range?: Range
apply: (replacement: string) => void
accepted: boolean
}accepted is read off the leaf that was clicked rather than by asking
isSpellingAccepted again, so it always describes
the mark the user actually sees. Use it to offer "undo" on a word that is already
accepted, instead of offering to accept it a second time.
Example
function ContextMenu() {
const { isOpen, spelling, position } = useContextMenuHints()
if (!isOpen || !spelling) {
return null
}
return (
<div style={{ position: 'fixed', left: position?.x, top: position?.y }}>
{spelling.accepted
? (
// Already accepted in this document - offer to take it back. Storing
// and forgetting the word is yours; textbit only reports the mark.
<button onClick={() => forget(spelling.text)}>
Stop accepting “{spelling.text}”
</button>
)
: (
<>
{spelling.suggestions.map(({ text }) => (
<button key={text} onClick={() => spelling.apply(text)}>
{text}
</button>
))}
<button onClick={() => accept(spelling.text)}>
Accept “{spelling.text}” in this document
</button>
</>
)}
</div>
)
}useBlockSelection()
Access the current block-level selection state. Returns null when no block selection is active.
const blockSelection = useBlockSelection()
// Returns: { anchorIndex: number, focusIndex: number } | nullThe selected range spans from Math.min(anchorIndex, focusIndex) to Math.max(anchorIndex, focusIndex) inclusive, referencing top-level block indices in the editor. See Block Selection for details on how block selection is initiated and controlled.
useSelectionBounds()
Get the current selection's bounding rectangle.
const bounds = useSelectionBounds()
// Returns: DOMRect | nullExample
function SelectionHighlight() {
const bounds = useSelectionBounds()
if (!bounds) return null
return (
<div
style={{
position: 'fixed',
left: bounds.left,
top: bounds.top,
width: bounds.width,
height: bounds.height,
border: '2px solid blue',
pointerEvents: 'none'
}}
/>
)
}useSelectionStats()
Word and character counts for the current selection. Returns zeros when the selection is null or collapsed.
const stats = useSelectionStats()
// Returns: { words: number, characters: number, charactersNoSpaces: number }Counts everything inside the range regardless of node class — captions, factbox text, and text inside void containers all contribute. Words are matched against the Unicode letter class (\p{L}+), so non-ASCII text is counted correctly. Whitespace counts toward characters but not charactersNoSpaces.
The hook subscribes via useSlateSelector with a structural equality check, so it only re-renders when one of the counts actually changes.
Example
function SelectionCounter() {
const { words, characters, charactersNoSpaces } = useSelectionStats()
if (characters === 0) return null
return (
<div className="selection-counter">
{words} words · {characters} chars ({charactersNoSpaces} without spaces)
</div>
)
}Styling
Textbit provides minimal default styling, allowing you to fully customize the appearance.
CSS Custom Properties
Textbit exposes CSS custom properties for theming injected styles.
| Property | Default | Description |
|----------|---------|-------------|
| --tb-focus-ring-radius | 2px | Border radius of the focus ring shown around block and void elements when the cursor is inside them. |
Set the property on the editor root or any ancestor:
/* Globally */
:root {
--tb-focus-ring-radius: 6px;
}
/* Scoped to a specific editor */
.my-editor {
--tb-focus-ring-radius: 0px;
}Data Attributes for Styling
Editor State
/* When editor has focus */
[data-state="focused"] {
outline: 2px solid #3b82f6;
}Menu and Toolbar Items
/* Active plugin */
[data-state="active"] {
background: #dbeafe;
color: #1e40af;
}
/* Inactive plugin */
[data-state="inactive"] {
opacity: 0.6;
}Placeholder
The empty-leaf placeholder (shown when placeholder is set and the editor/text node is empty) renders with data-slate-placeholder="true", matching slate-react's convention.
| Attribute | Values | Description |
|-----------|--------|-------------|
| data-slate-placeholder | "true" | Present on the placeholder element when an empty editor/text node displays placeholder text. |
[data-slate-placeholder] {
font-style: italic;
color: #94a3b8;
}Drag and Drop
/* Line between elements */
[data-dragover="between"] {
height: 2px;
background: #3b82f6;
margin: 4px 0;
}
/* Highlight around droppable element */
[data-dragover="around"] {
outline: 2px dashed #3b82f6;
outline-offset: 2px;
}Element Type and Role
Every rendered element wrapper carries the element's type as a data attribute, enabling consumers to target a specific block or any of its named child slots from CSS without parsing classnames. Top-level text blocks additionally carry their role (when set) so variants like heading-1, preamble, etc. can be styled directly.
| Attribute | Values | Description |
|-----------|--------|-------------|
| data-type | string | The element's type (e.g. core/image, core/image/caption). Present on both top-level block wrappers and child element wrappers. |
| data-role | string | Optional. The element's properties.role (e.g. heading-1, preamble). Emitted on text-class element wrappers (both top-level blocks and named child slots) when properties.role is a non-empty string. An absent data-role means the element is regular text. asOwnElement plugin components receive it via spreadable attributes under the same condition. |
/* Style the byline field of a TTVisual block */
[data-type="tt/visual/byline"] {
font-style: italic;
}
/* Combine with slate-react's marker to target only the editable region */
[data-type="tt/visual/byline"] [data-slate-leaf] {
background: #fafafa;
}
/* Style a text block by its role */
[data-type="core/text"][data-role="heading-1"] {
font-size: 2rem;
font-weight: 700;
}Spelling Errors
Spelling errors are rendered with data attributes for custom styling:
| Attribute | Values | Description |
|-----------|--------|-------------|
| data-spelling-error | string | Unique ID of spelling error. |
| data-spelling-level | "error" \| "suggestion" | Severity level. |
| data-spelling-accepted | "" | Present when the word has been accepted via isSpellingAccepted. Absent otherwise - a presence attribute, so match it as [data-spelling-accepted]. |
CSS Example
[data-spelling-error] {
text-decoration: underline dotted;
}
[data-spelling-level="error"] {
text-decoration-color: #ef4444;
}
[data-spelling-level="suggestion"] {
text-decoration-color: #3b82f6;
}
/* An accepted word keeps its error attributes, so this rule sits on top of the
styling above and has to override it. */
[data-spelling-accepted] {
text-decoration: none;
}Tailwind Example
<Textbit.Editable
className="
[&_[data-spelling-error]]:underline
[&_[data-spelling-error]]:decoration-dotted
[&_[data-spelling-level='error']]:decoration-red-500
[&_[data-spelling-level='suggestion']]:decoration-blue-500
[&_[data-spelling-accepted]]:no-underline
"
/>Collaborative Editing
Textbit supports real-time collaboration using Yjs.
Basic Setup
import * as Y from 'yjs'
import { WebrtcProvider } from 'y-webrtc'
import { Textbit } from '@dotvoid/textbit'
function CollaborativeEditor() {
const ydoc = useMemo(() => new Y.Doc(), [])
const provider = useMemo(
() => new WebrtcProvider('my-room-name', ydoc),
[ydoc]
)
const sharedContent = useMemo(
() => ydoc.get('content', Y.XmlText),
[ydoc]
)
return (
<Textbit.Root
value={sharedContent}
awareness={provider.awareness}
cursor={{
data: {
name: 'John Doe',
color: 'rgb(59, 130, 246)',
initials: 'JD'
}
}}
>
<Textbit.Editable />
</Textbit.Root>
)
}Cursor Configuration
When using collaborative editing, configure how cursors are displayed:
interface CursorConfig {
stateField?: string // Awareness field name for cursor state
dataField?: string // Awareness field name for cursor data
autoSend?: boolean // Auto-send cursor updates (default: true)
data: {
name: string // User's display name
color: string // User's cursor color (rgb/hex)
initials: string // User's initials
avatar?: string // Optional avatar URL
[key: string]: unknown // Additional custom data
}
}Full Collaborative Example
import { useMemo, useEffect } from 'react'
import * as Y from 'yjs'
import { WebrtcProvider } from 'y-webrtc'
import { Textbit } from '@dotvoid/textbit'
import { slateNodesToInsertDelta } from '@slate-yjs/core'
function CollaborativeEditor() {
const ydoc = useMemo(() => new Y.Doc(), [])
const provider = useMemo(
() => new WebrtcProvider('room-' + roomId, ydoc),
[ydoc, roomId]
)
const content = useMemo(() => ydoc.get('content', Y.XmlText), [ydoc])
// Initialize with existing content
useEffect(() => {
if (content.length === 0 && initialContent.length > 0) {
content.applyDelta(slateNodesToInsertDelta(initialContent))
}
}, [content, initialContent])
return (
<Textbit.Root
value={content}
awareness={provider.awareness}
cursor={{
autoSend: true,
data: {
name: currentUser.name,
color: currentUser.color,
initials: currentUser.initials,
avatar: currentUser.avatarUrl
}
}}
plugins={[/* your plugins */]}
>
<Textbit.Editable>
{/* Other components */}
</Textbit.Editable>
</Textbit.Root>
)
}Plugin Development
Plugins extend Textbit with custom content types and behaviors.
Plugin Types
| Class | Description | Examples |
|-------|-------------|----------|
| leaf | Inline formatting | Bold, italic, underline |
| inline | Inline blocks | Links, mentions |
| text | Text blocks | Paragraphs, headings, blockquotes |
| block | Block elements | Images, videos, embeds |
| void | Non-editable elements | Loaders, a child image element in a block element |
| generic | Non-visual plugins | Input transformers, validators |
Drag'n drop
Block and void class elements are automatically draggable in all parts not occupied by a child text element.
Any DOM element with the attribute draggable set to true will act as a "drag handle" for the entire top level ancestor block. This is useful if one need to make a text element draggable. Usually these DOM elements also need to have contentEditable set to false as well.
Plugin Structure
import type { TBPluginInitFunction } from '@dotvoid/textbit'
const MyPlugin: TBPluginInitFunction = (options) => {
return {
class: 'block',
name: 'namespace/image',
actions: [{
name: 'toggle-image',
title: 'Image',
hotkey: 'mod+i',
tool: () => <ImageIcon />,
handler: ({ editor, options }) => {
// Custom logic here
// Return true to also use default behavior
// Return false if you handled everything
return true
}
}],
componentEntry: {
class: 'block',
component: Figure,
children: [
{ type: 'image', class: 'void', component: ImageContent,
constraints: { min: 1, max: 1 } },
{ type: 'caption', class: 'text', component: Caption,
constraints: { allowBreak: false, min: 1, max: 1 } }
]
},
// Optional: Plugin options
options: options || {}
}
}Component Props
Plugin components receive these props:
interface TBComponentProps {
element: TBElement // Current element being rendered
children: React.ReactNode // Child elements to render
rootNode?: TBElement // Root element if this is a descendant component
options?: Record<string, unknown> // Plugin options
}Constraints
Constraints control editing behavior within a component. They are set in the constraints property of a componentEntry and can be applied to both the top-level component entry and child component entries.
| Name | Type | Default | Description |
|------|------|---------|-------------|
| allowBreak | boolean | true | When false, prevents Enter from creating new nodes inside the component |
| allowSoftBreak | boolean | true | When false, prevents soft breaks (Shift+Enter) within a text element |
| allowExitBreak | boolean | true | When false, prevents Enter at the last position from exiting the top-level block to create a new text block after it |
| allowEdgeWhitespace | boolean | true | When false, prevents Space from adding a whitespace character at the first position. If one or more whitespace characters are left trailing at the last position or after, these will be removed on the following blur event |
| normalizeNode | (editor, nodeEntry) => boolean \| void | — | Custom normalization function; return true to signal the normalization was handled |
Child count constraints (min / max)
A child component entry (an item in a parent's children array) can declare how many instances of its type are allowed inside the parent. These fields sit inside constraints alongside the behavior-level fields above:
| Name | Type | Default | Description |
|------|------|---------|-------------|
| min | number | — | Minimum number of instances required in the parent. If the count ever drops below min, Textbit inserts a minimal placeholder to restore the invariant. |
| max | number | — | Maximum number of instances allowed in the parent. Enter is blocked when the count is already at max; excess siblings are removed by the normalizer. |
min and max are only valid on child entries. TypeScript will reject them on a top-level componentEntry — the ComponentEntry type forbids them, while entries inside a children array are typed as ChildComponentEntry and accept them.
Form-field semantics: set both to 1 to model a permanent sub-element that always exists exactly once (e.g., an image caption). The user cannot delete it — if they try, it is re-inserted — and Enter cannot split it into a second one. Parent blocks are never removed because of child constraints.
Textbit enforces these automatically:
- Normalization: on every change,
min <= count <= maxis restored by inserting placeholders or removing excess siblings, in the order defined by the parent'schildrenarray. - Enter: blocked inside a child whose type is already at
maxin the parent. - Paste: pasting a block-level fragment inside any child text element (e.g., a caption — whether or not it has
min/maxset) is flattened to plain text so it cannot corrupt the parent block's structure. - Delete / Backspace: handled reactively by the normalizer — a
min: 1child is re-inserted synchronously if removed.
This replaces the boilerplate normalizer that plugins used to need for "exactly one of X, at most one of Y" rules. Plugins can still define a custom normalizeNode alongside these for anything more exotic; it runs after the declarative enforcement.
See the Image Block Plugin example below for a practical demonstration of allowBreak, min, and max.
Example: Bold Plugin
import { BoldIcon } from 'lucide-react'
import type { TBPluginInitFunction, TBComponentProps } from '@dotvoid/textbit'
const Bold: TBPluginInitFunction = () => {
return {
class: 'leaf',
name: 'core/bold',
actions: [{
name: 'toggle-bold',
title: 'Bold',
hotkey: 'mod+b',
tool: () => <BoldIcon size={16} />,
handler: () => true // Use default toggle behavior
}],
getStyle: () => {
// Leaf CSS styling
return {
fontWeight: 'bold'
}
}
}
}
export { Bold }Example: Link Plugin
import { LinkIcon } from 'lucide-react'
import type { TBPluginInitFunction, TBComponentProps } from '@dotvoid/textbit'
import { Editor, Transforms } from 'slate'
const Link: TBPluginInitFunction = () => {
return {
class: 'inline',
name: 'core/link',
actions: [{
name: 'insert-link',
title: 'Link',
hotkey: 'mod+k',
tool: () => <LinkIcon size={16} />,
handler: ({ editor }) => {
const url = prompt('Enter URL:')
if (!url) return false
const link = {
type: 'core/link',
url,
children: [{ text: url }]
}
if (editor.selection) {
Transforms.wrapNodes(editor, link, { split: true })
} else {
Transforms.insertNodes(editor, link)
}
return false // We handled everything
}
}],
componentEntry: {
class: 'inline',
component: LinkComponent
}
}
}
function LinkComponent({ element, children }: TBComponentProps) {
return (
<a
href={element.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 underline"
>
{children}
</a>
)
}
export { Link }Example: Image Block Plugin
import { ImageIcon } from 'lucide-react'
import type { TBPluginInitFunction, TBComponentProps } from '@dotvoid/textbit'
import { Transforms } from '@dotvoid/textbit'
const Image: TBPluginInitFunction = () => {
return {
class: 'block',
name: 'core/image',
actions: [{
name: 'insert-image',
title: 'Image',
hotkey: 'mod+shift+i',
tool: () => <ImageIcon size={16} />,
handler: ({ editor }) => {
const url = prompt('Enter image URL:')
if (!url) return false
const image = {
type: 'core/image',
class: 'block',
id: crypto.randomUUID(),
properties: { src: url },
children: [
{
type: 'core/image/caption',
class: 'text',
children: [{ text: '' }]
}
]
}
Transforms.insertNodes(editor, image)
return false
}
}],
componentEntry: {
class: 'block',
component: FigureComponent,
children: [
{
type: 'image',
class: 'void',
component: ImageComponent,
constraints: { min: 1, max: 1 }
},
{
type: 'text',
class: 'text',
component: CaptionComponent,
constraints: {
allowBreak: false,
min: 1,
max: 1
}
}
]
}
}
}
function FigureComponent({ element, children }: TBComponentProps) {
return (
<figure className="my-4">
{children}
</figure>
)
}
function ImageComponent({ element, children }: TBComponentProps) {
return (
<img
src={element.properties.src}
alt=""
className="w-full rounded"
/>
)
}
function CaptionComponent({ children }: TBComponentProps) {
return (
<div className="p-2 flex rounded rounded-xs text-sm bg-slate-200 dark:bg-slate-800">
<label className="grow-0 w-16 opacity-70" contentEditable={false}>Text:</label >
<figcaption className="grow">
{children}
</figcaption>
</div >
)
}
export { Image }Using Actions in Components
Use the useAction hook to call plugin actions from within components:
import { useAction } from '@dotvoid/textbit'
import type { TBComponentProps } from '@dotvoid/textbit'
function ImageComponent({ element }: TBComponentProps) {
const deleteImage = useAction('core/image', 'delete-image')
return (
<figure contentEditable={false}>
<img src={element.properties.src} alt="" />
<button onClick={() => deleteImage({ id: element.id })}>
Delete
</button>
</figure>
)
}Custom Root Elements (asOwnElement)
By default, Textbit wraps each plugin component in a <div> so framework attributes (data-id, data-type, slate-react's data-slate-node, etc.) reliably reach the DOM. When the structural HTML tag matters — e.g. a <tr> inside a <table>, or an <li> inside a <ul> — set asOwnElement: true on the componentEntry and spread attributes onto the component's root node.
import type { TBComponentProps } from '@dotvoid/textbit'
export const TableRow = ({ children, attributes }: TBComponentProps<HTMLTableRowElement>) => (
<tr {...attributes}>{children}</tr>
)
TableRow.displayName = 'TableRow'
// In the plugin definition:
{
type: 'row',
class: 'block',
component: TableRow,
asOwnElement: true
}When asOwnElement is true, the component is responsible for spreading attributes onto its root DOM element. attributes carries the framework's data-id, data-type, lang, an optional data-role (text-class elements with properties.role set; absent means regular text), plus slate-react's data-slate-node and ref. Failing to spread them will break selection mapping for that element.
Utilities
File Handling
Process dropped files or file input changes:
import {
consumeFileDropEvent,
consumeFileInputChangeEvent
} from '@dotvoid/textbit'
// Handle drop events
const handleDrop = async (event: DragEvent) => {
const files = await consumeFileDropEvent(event)
files.forEach(file => {
console.log(file.name, file.type, file.size)
})
}
// Handle file input
const handleChange = async (event: ChangeEvent<HTMLInputElement>) => {
const files = await consumeFileInputChangeEvent(event)
files.forEach(file => {
console.log(file.name, file.type, file.size)
})
}Calculate Statistics
Calculate word and character counts:
import { useTextbit } from '@dotvoid/textbit'
function EditorStats() {
const { stats } = useTextbit()
return (
<div>
<div>Words: {stats.full.words}</div>
<div>Characters: {stats.full.characters}</div>
{stats.short.words > 0 && (
<div>Selected: {stats.short.words} words</div>
)}
</div>
)
}Editor Utilities
Helper utilities for working with the editor:
import { TextbitEditor, TextbitElement, TextbitPlugin } from '@dotvoid/textbit'
// Check if element is a certain type
if (TextbitElement.isOfType(element, 'core/text')) {
console.log('This is a core text element')
}
// Get plugin by type
const plugin = TextbitPlugin.get(plugins, 'core/bold')
TypeScript
Textbit is written in TypeScript and provides comprehensive type definitions.
Exported Types
import type {
// Element and editor types
TBElement, // Textbit element
TBText, // Text node
TBEditor, // Extended Slate editor
TBRange, // Range type
// Plugin types
TBPluginDefinition, // Plugin definition
TBPluginInitFunction, // Plugin initialization function
TBComponentProps, // Component props
TBAction, // Action definition
TBPluginOptions, // Plugin options
TBPluginRegistryAction, // Registry action
// Resource and component types
TBResource, // Resource definition
TBComponentEntry, // Component entry
TBComponent, // Component type
TBToolComponent, // Tool component
TBToolComponentProps, // Tool component props
// Other types
TBSpellingError, // Spelling error structure
TBConsumeFunction, // Consume function type
TBConsumesFunction // Consumes function type
} from '@dotvoid/textbit'Re-exported Slate Types
Textbit re-exports Slate types with the correct type augmentation:
import {
// Utilities
Editor, // Slate Editor utilities
Element, // Slate Element utilities
Text, // Slate Text utilities
Transforms, // Slate transform operations
Node, // Slate Node utilities
Range // Slate Range utilities
} from '@dotvoid/textbit'
import type {
// Base types
Descendant, // Slate content node
Ancestor, // Slate ancestor node
BaseEditor, // Base editor type
BaseElement, // Base element type
BaseText, // Base text type
BaseRange // Base range type
} from '@dotvoid/textbit'Type Augmentation
Textbit uses TypeScript declaration merging to extend Slate's types. When you import from @dotvoid/textbit, you get the augmented types automatically:
import { Editor, Element } from '@dotvoid/textbit'
// These now use Textbit's augmented types
const editor: Editor // Actually TBEditor
const element: Element // Actually TBElementCustom Plugin Types
When creating plugins, use the type helpers:
import type {
TBPluginInitFunction,
TBComponentProps,
TBElement
} from '@dotvoid/textbit'
// Plugin initialization
const MyPlugin: TBPluginInitFunction = (options) => {
return {
// Plugin definition
}
}
// Component
function MyComponent({ element, children }: TBComponentProps) {
// Component implementation
}
// Type guard
function isMyPlugin(element: TBElement): element is TBElement & {
type: 'namespace/my-plugin'
} {
return element.type === 'namespace/my-plugin'
}Block Navigation
Textbit handles keyboard navigation in and around non-text blocks (class: 'block' / class: 'void') using a virtual block-level caret. The real Slate selection stays inside the block while a visual indicator appears beside it, showing whether the caret is logically before or after the block.
Horizontal (Left/Right)
Arrowing into a non-text block from a neighbouring text block activates the indicator on the near side (before when arriving from the left, after from the right). Continuing in the same direction either enters the block's editable content (non-void) or advances the indicator to the far side (void), then exits into the next block. The same steps apply in reverse.
Vertical (Up/Down)
Up/Down moves the indicator to the neighbouring top-level block, preserving the current indicator direction (before stays left, after stays right). If the neighbour is a text block the indicator is cleared and normal text editing resumes.
Editing Keys
| Key | Behavior | |-----|----------| | Enter | Inserts a new empty text block adjacent to the indicator | | Backspace | Removes the block or character behind the caret position | | Delete | Removes the block ahead of the caret position | | Printable character | Inserts a new text block and types the character into it |
Block Selection (Shift+Arrow)
While the block caret is active, holding Shift and pressing any arrow key initiates block-level selection. Entire top-level blocks are selected rather than individual text ranges.
Entry from Block Caret
| Caret position | Key | Selects | |----------------|-----|---------| | Before block X | Shift+Up/Left | Block above (X−1) | | Before block X | Shift+Down/Right | Current block (X) | | After block X | Shift+Up/Left | Current block (X) | | After block X | Shift+Down/Right | Block below (X+1) |
While Block Selection Is Active
| Key | Behavior | |-----|----------| | Shift+Arrow | Extends or contracts the selection by one block | | Arrow (no shift) | Collapses selection and places cursor at the edge of the first/last selected block | | Backspace / Delete | Removes all selected blocks | | Cmd/Ctrl+C | Copies selected blocks | | Cmd/Ctrl+X | Cuts selected blocks | | Mouse click | Clears block selection |
Selected blocks receive a data-block-selected attribute and a .tb-block-selected overlay element for styling. Text content within the selection also shows the native browser highlight. The useBlockSelection() hook exposes the current selection state (anchorIndex / focusIndex) to consumer components.
Element Structure
Textbit elements are based on Slate elements with additional conventions:
Text Element
{
type: 'core/text',
id: '538345e5-bacc-48f9-8ef1-a219891b60eb',
class: 'text',
properties: {
role: 'heading-1' // Optional sub-type
// Additional properties can be defined
},
children: [
{ text: 'Better music?' }
]
}Formatted Text
{
type: 'core/text',
id: '538345e5-bacc-48f9-8ef0-1219891b60ef',
class: 'text',
children: [
{ text: 'An example paragraph with ' },
{
text: 'stronger',
'core/bold': true,
'core/italic': true
},
{ text: ' text.' }
]
}Block Element (Image Example)
{
id: '538345e5-bacc-48f9-8ef0-1219891b60ef',
class: 'block',
type: 'core/image',
properties: {
src: 'https://example.com/image.png',
alt: 'Description',
width: 1024,
height: 768
},
children: [
{
type: 'core/image/caption',
class: 'text',
children: [{ text: 'An image of people taken 2001' }]
}
]
}Complete Example
See ./src for several complete examples.
License
MIT
