Skip to content

Toolbar Setup

Jayden Smith edited this page May 5, 2026 · 4 revisions

Overview

The package supports two toolbar integration modes:

  • Use the built-in toolbar through NativeRichTextEditor (recommended).
  • Render EditorToolbar yourself for custom layout or placement.

Built-In Toolbar

This is the recommended starting point. The toolbar is hosted natively as a keyboard accessory (iOS) or above-keyboard view (Android).

<NativeRichTextEditor
  showToolbar
  toolbarItems={DEFAULT_EDITOR_TOOLBAR_ITEMS}
/>;

This gives you the default button set, platform-specific hosting, and minimal setup.

If the editor lives inside a larger React Native screen that scrolls, keep app-level keyboard avoidance in place. The built-in keyboard toolbar handles native hosting, but it does not replace KeyboardAvoidingView or another parent-level keyboard inset strategy for the rest of your layout.

Toolbar Placement

Control where the toolbar renders with toolbarPlacement:

// Native keyboard accessory (default)
<NativeRichTextEditor showToolbar toolbarPlacement="keyboard" />

// Inline React view above the editor
<NativeRichTextEditor showToolbar toolbarPlacement="inline" />
Value Behavior
keyboard Attached as a native keyboard accessory (iOS) or above-keyboard view (Android). Default.
inline Rendered in React above the editor. Visible even when the keyboard is closed.

Notes:

  • keyboard is the preferred mode when you want platform-native toolbar behavior.
  • On Android, the editor accounts for the built-in above-keyboard toolbar inside the editor viewport.
  • You should still make the surrounding screen keyboard-aware if the editor sits inside a parent ScrollView or a longer form.
  • If you use KeyboardAvoidingView on Android, choose a keyboardVerticalOffset that matches your screen layout. The right value depends on your header, safe area handling, and any extra space you keep above the keyboard.

Example screen wrapper fragment:

<KeyboardAvoidingView
  style={{ flex: 1 }}
  behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
  keyboardVerticalOffset={androidKeyboardOffset}
>
  <ScrollView keyboardShouldPersistTaps="always">
    <NativeRichTextEditor
      showToolbar
      toolbarPlacement="keyboard"
    />
  </ScrollView>
</KeyboardAvoidingView>

androidKeyboardOffset is app-specific. Derive it from the layout you already use on that screen.

Custom Toolbar Items

These examples are component fragments. Import the hooks and types they use in your screen or component.

Use toolbarItems to define your own button list while still using the built-in host:

import { useState } from 'react';
import {
  NativeRichTextEditor,
  type LinkRequestContext,
} from '@apollohg/react-native-prose-editor';

export function Composer() {
  const [pendingLink, setPendingLink] = useState<LinkRequestContext | null>(null);
  const [linkDraft, setLinkDraft] = useState('');

  const toolbarItems = [
    { type: 'mark', mark: 'bold', label: 'Bold', icon: { type: 'default', id: 'bold' } },
    { type: 'mark', mark: 'italic', label: 'Italic', icon: { type: 'default', id: 'italic' } },
    { type: 'link', label: 'Link', icon: { type: 'default', id: 'link' } },
    { type: 'separator' },
    { type: 'node', nodeType: 'hardBreak', label: 'Line Break', icon: { type: 'default', id: 'lineBreak' } },
    { type: 'node', nodeType: 'horizontalRule', label: 'Horizontal Rule', icon: { type: 'default', id: 'horizontalRule' } },
  ] as const;

  return (
    <>
      <NativeRichTextEditor
        showToolbar
        toolbarItems={toolbarItems}
        onRequestLink={(context) => {
          setPendingLink(context);
          setLinkDraft(context.href ?? 'https://');
        }}
      />
      <LinkEditorModal
        visible={pendingLink != null}
        value={linkDraft}
        onChangeText={setLinkDraft}
        onCancel={() => setPendingLink(null)}
        onSubmit={() => {
          if (!pendingLink) return;
          const nextHref = linkDraft.trim();
          if (nextHref === '') {
            pendingLink.unsetLink();
          } else {
            pendingLink.setLink(nextHref);
          }
          setPendingLink(null);
        }}
      />
    </>
  );
}

LinkEditorModal is your app's UI. The package only tells you when a link should be created, edited, or removed.

Image buttons are host-driven too. Handle your picker or upload flow in onRequestImage, then call insertImage(...).

App-Defined Action Buttons

Use an action item plus onToolbarAction to add buttons with custom behavior:

const toolbarItems = [
  { type: 'mark', mark: 'bold', label: 'Bold', icon: { type: 'default', id: 'bold' } },
  {
    type: 'action',
    key: 'insertMention',
    label: 'Mention',
    icon: {
      type: 'platform',
      ios: { type: 'sfSymbol', name: 'at' },
      android: { type: 'material', name: 'alternate-email' },
      fallbackText: '@',
    },
  },
] as const;

<NativeRichTextEditor
  showToolbar
  toolbarItems={toolbarItems}
  onToolbarAction={(key) => {
    if (key === 'insertMention') {
      // your app logic here
    }
  }}
/>;

Custom Mark and Node Buttons

Toolbar items address schema names directly, so custom marks and nodes work by name:

const toolbarItems = [
  {
    type: 'mark',
    mark: 'highlight',
    label: 'Highlight',
    icon: {
      type: 'platform',
      ios: { type: 'sfSymbol', name: 'highlighter' },
      android: { type: 'material', name: 'ink-highlighter' },
      fallbackText: 'H',
    },
  },
] as const;

This only works if your schema includes the highlight mark.

Heading Buttons

Heading toolbar items target schema node names indirectly by level, so the built-in schemas can toggle between paragraph and h1 through h6:

const toolbarItems = [
  { type: 'heading', level: 1, label: 'Heading 1', icon: { type: 'default', id: 'h1' } },
  { type: 'heading', level: 2, label: 'Heading 2', icon: { type: 'default', id: 'h2' } },
] as const;

<NativeRichTextEditor
  showToolbar
  toolbarItems={toolbarItems}
/>;

If you need an imperative path, the editor ref also exposes toggleHeading(level).

Grouped Buttons

Use a group item when several buttons should collapse into one slot. This is useful for heading levels, insert menus, or app-specific button clusters.

const toolbarItems = [
  {
    type: 'group',
    key: 'headings',
    label: 'Headings',
    icon: { type: 'glyph', text: 'H' },
    presentation: 'menu',
    items: [
      { type: 'heading', level: 1, label: 'Heading 1', icon: { type: 'default', id: 'h1' } },
      { type: 'heading', level: 2, label: 'Heading 2', icon: { type: 'default', id: 'h2' } },
      { type: 'heading', level: 3, label: 'Heading 3', icon: { type: 'default', id: 'h3' } },
    ],
  },
] as const;

<NativeRichTextEditor
  showToolbar
  toolbarItems={toolbarItems}
/>;

Set presentation: 'expand' when you want the child buttons inserted inline after tapping the group button instead of opening a menu.

Groups can only contain actionable items. They do not support nested groups or separators.

Hyperlink Controls

Hyperlinks are slightly different from ordinary mark buttons because they need a URL. Use a link toolbar item and handle onRequestLink:

const toolbarItems = [
  { type: 'mark', mark: 'bold', label: 'Bold', icon: { type: 'default', id: 'bold' } },
  { type: 'link', label: 'Link', icon: { type: 'default', id: 'link' } },
] as const;

<NativeRichTextEditor
  showToolbar
  toolbarItems={toolbarItems}
  onRequestLink={({ href, isActive, setLink, unsetLink }) => {
    if (isActive && href) {
      // show your edit/remove UI
    }
    setLink('https://example.com');
  }}
/>;

If you need an imperative path, the editor ref also exposes setLink(href) and unsetLink().

Image Controls

Image toolbar items call back into your app. Choose the image however you want, then finish by calling insertImage(...):

const toolbarItems = [
  { type: 'mark', mark: 'bold', label: 'Bold', icon: { type: 'default', id: 'bold' } },
  { type: 'image', label: 'Image', icon: { type: 'default', id: 'image' } },
] as const;

<NativeRichTextEditor
  showToolbar
  toolbarItems={toolbarItems}
  onRequestImage={({ allowBase64, insertImage }) => {
    const source = allowBase64
      ? 'data:image/png;base64,AAAA'
      : 'https://cdn.example.com/cat.png';
    insertImage(source, { alt: 'Cat', width: 320, height: 180 });
  }}
/>;

If you need imperative insertion, the editor ref also exposes insertImage(src, attrs?). Base64 data URIs require allowBase64Images={true}. Optional width and height attrs seed the initial size and are updated when the user resizes the image natively.

Set allowImageResizing={false} if inserted images should stay fixed.

Standalone Toolbar

Use EditorToolbar directly when you need full layout control. This requires wiring up the editor ref and state callbacks manually.

const editorRef = useRef<NativeRichTextEditorRef>(null);
const [activeState, setActiveState] = useState<ActiveState>({
  marks: {},
  markAttrs: {},
  nodes: {},
  commands: {},
  allowedMarks: [],
  insertableNodes: [],
});
const [historyState, setHistoryState] = useState<HistoryState>({
  canUndo: false,
  canRedo: false,
});

<>
  <NativeRichTextEditor
    ref={editorRef}
    showToolbar={false}
    onActiveStateChange={setActiveState}
    onHistoryStateChange={setHistoryState}
  />
  <EditorToolbar
    activeState={activeState}
    historyState={historyState}
    onToggleMark={(mark) => editorRef.current?.toggleMark(mark)}
    onToggleHeading={(level) => editorRef.current?.toggleHeading(level)}
    onToggleListType={(listType) => editorRef.current?.toggleList(listType)}
    onInsertNodeType={(nodeType) => editorRef.current?.insertNode(nodeType)}
    onRequestImage={() => editorRef.current?.insertImage('https://cdn.example.com/cat.png')}
    onRunCommand={(command) => {
      if (command === 'indentList') editorRef.current?.indentListItem();
      if (command === 'outdentList') editorRef.current?.outdentListItem();
    }}
    onUndo={() => editorRef.current?.undo()}
    onRedo={() => editorRef.current?.redo()}
    onToggleBold={() => editorRef.current?.toggleMark('bold')}
    onToggleItalic={() => editorRef.current?.toggleMark('italic')}
    onToggleUnderline={() => editorRef.current?.toggleMark('underline')}
    onToggleStrike={() => editorRef.current?.toggleMark('strike')}
  />
</>;

When using the generic handlers (onToggleMark, onToggleListType, onInsertNodeType, onRunCommand), they take priority over the specific built-in handlers. The specific handlers (onToggleBold, etc.) serve as fallbacks for built-in items when generic handlers are not provided.

Standalone toolbars preserve editor focus by default. EditorToolbar reports its screen frame to the focused editor, so tapping a standalone toolbar button is treated like toolbar interaction instead of an outside tap that blurs the editor. It also marks active press interactions; if native focus still drops during a toolbar press, the editor suppresses the blur callback and refocuses itself. Pass preserveEditorFocus={false} only when the toolbar is not associated with a NativeRichTextEditor or when you intentionally manage focus yourself.

If the standalone toolbar sits inside a parent ScrollView, FlatList, section list, or keyboard-aware scroll view, set that parent to keep taps while the keyboard is open. Prefer keyboardShouldPersistTaps="always" for composer-style screens. Otherwise React Native may dismiss the keyboard before the toolbar press reaches the editor, which appears as keyboard flicker.

Which Path Should You Use?

Need Recommended Path
Simplest integration Built-in toolbar via showToolbar
Custom button list Built-in toolbar with toolbarItems
Toolbar visible without keyboard toolbarPlacement="inline"
Custom layout or placement Standalone EditorToolbar
App-defined buttons Either path, using action items

Related Docs

Clone this wiki locally