Skip to main content

MarkdownInput

Rich text editor that reads and writes markdown, wrapping react-native-enriched-markdown's EnrichedMarkdownTextInput in the same field design as a multiline Input — label, animated border, and error text included.

Formatting is driven by a MarkdownToolbar that floats above the keyboard while the editor has focus: bold, italic, underline, bullet list, and numbered list.

Installation​

MarkdownInput requires react-native-enriched-markdown as a dependency:

npm install react-native-enriched-markdown

Basic Usage​

import { MarkdownInput } from '@multinaire/expo-ui';

const [markdown, setMarkdown] = useState('');

<MarkdownInput
title="Notes"
placeholder="Write something…"
onChangeMarkdown={setMarkdown}
/>

The editor is uncontrolled — it keeps its own content natively, which is what makes it fast. defaultValue seeds it on mount; later changes to that prop are ignored. Read the content back through onChangeMarkdown, or from the ref.

Props​

PropTypeDefaultDescription
placeholderstring—Placeholder text shown when the editor is empty
defaultValuestring—Initial markdown content. Seeds the editor on mount only
titlestring—Field label displayed above the editor
requiredbooleanfalseMark the field as required. Displays a visual indicator
extraReactElement—Extra element rendered at the trailing end of the label row
errorTextstring—Error message displayed below the editor
editablebooleantrueSet to false to make the editor read-only. Also hides the toolbar
autoFocusbooleanfalseFocus the editor as soon as it mounts
minHeightnumberheight.largeMinimum height, in pixels, of the editor. Ignored when flex is set
maxHeightnumberheight.large × 3Maximum height the editor grows to. Past it the content scrolls internally. Ignored when flex is set
hideToolbarbooleanfalseHide the formatting toolbar. The native selection format menu still works
toolbarPosition'left' | 'center' | 'right''right'Horizontal alignment of the toolbar above the keyboard
markdownStyleMarkdownTextInputStyletheme defaultPer-element style overrides merged on top of the theme-derived default
flexnumber—Flex grow value. Makes the field fill its parent rather than size to its content
marginSpacingProps—Outer margin
testIDstringplaceholderTest identifier for UI automation
refRefObject<EnrichedMarkdownTextInputInstance>—Ref to the underlying editor, exposing its imperative API
onChangeMarkdown(markdown: string) => void—Invoked with the serialized markdown on every change. Omit it to skip serialization entirely, which is cheaper
onChangeText(text: string) => void—Invoked with the plain text (markdown syntax stripped) on every change
onFocus() => void—Invoked when the editor gains focus
onBlur() => void—Invoked when the editor loses focus

Supported formatting​

ActionToolbar iconMarkdown
BoldTextBold**bold**
ItalicTextItalic_italic_
UnderlineTextUnderline<u>underline</u>
Bullet listCustomBulletList- item
Numbered listCustomNumberedList1. item

Each button fills with the primary color while its formatting is active at the cursor. Anything the toolbar doesn't cover is left out of the native selection menu too, so the two never disagree: blocks the editor can't represent (code blocks, tables, blockquotes) belong in MarkdownTypography, and strikethrough, spoilers, and links are off.

Links typed into the editor are still auto-detected and linked as you type, and existing [text](url) links in defaultValue keep working — there is just no button for creating one by hand.

The editor also handles the usual list keyboard behavior on its own: Return continues a list, Return on an empty item leaves it, Backspace at the start of an item outdents it, and Tab / Shift+Tab nest and un-nest items on a hardware keyboard.

The floating toolbar​

The toolbar sits a gap above the keyboard, pinned to it as it animates in and out, wherever the field itself happens to be — at the top of a form, or filling the screen. It appears on focus and disappears on blur. With no soft keyboard on screen (a hardware keyboard, for instance) it rests at the bottom of the screen, above the safe area.

To manage that, the toolbar is rendered into an overlay layer at the app root rather than inline beside the field. An inline toolbar can only be positioned relative to its own parent, which means anchoring it to the keyboard requires knowing where that parent currently is — a figure that goes stale on every scroll, including the automatic scroll that lifts a focused field above the keyboard — and paint order still buries it under any sibling rendered after the field. Rendered at the root, it has neither problem: it positions against the window and always draws on top, and it sits outside any surrounding scroll view, so it needs no keyboardShouldPersistTaps handling to receive taps.

MultinaireUI hosts that layer, so the tree has to be wrapped in it — as it already must be for fonts and theming. Without it the toolbar simply doesn't render; the editor and the native selection menu still work.

Examples​

Full-height editor​

Pass flex and the field fills its parent instead of sizing to its content — minHeight and maxHeight no longer apply, and the content scrolls inside the field once it runs out of room.

<Container flex={1} padding={variables.padding.medium}>
<MarkdownInput
flex={1}
autoFocus
placeholder="Write something…"
onChangeMarkdown={setMarkdown}
/>
</Container>

Read-only​

<MarkdownInput
editable={false}
title="Notes"
placeholder="Nothing yet"
defaultValue={markdown}
/>

For rendering markdown rather than editing it, prefer MarkdownTypography — it supports every block element.

Imperative control​

The ref exposes the underlying editor, so content can be replaced, read, or formatted from outside the toolbar:

import { useRef } from 'react';
import type { EnrichedMarkdownTextInputInstance } from 'react-native-enriched-markdown';

const ref = useRef<EnrichedMarkdownTextInputInstance>(null);

<MarkdownInput
ref={ref}
placeholder="Write something…"
onChangeMarkdown={setMarkdown}
/>

<Button title="Insert heading" onPress={() => ref.current?.toggleHeading(1)} />
<Button title="Clear" onPress={() => ref.current?.setValue('')} />

getMarkdown(), setValue(), focus(), setSelection(), insertText(), toggleHeading(), indentList(), and the rest of EnrichedMarkdownTextInputInstance are all available.

Customizing styling​

The editor styles formatted spans from the theme automatically (via useEnrichedMarkdownInputStyle). To tweak a few of them, pass a partial markdownStyle — it is merged over the default:

<MarkdownInput
placeholder="Write something…"
markdownStyle={{ link: { color: 'red', underline: false } }}
onChangeMarkdown={setMarkdown}
/>

Moving the toolbar​

The toolbar floats a gap above the keyboard, aligned to the right edge of the screen. toolbarPosition moves it along that edge — 'left' or 'center' instead:

<MarkdownInput
toolbarPosition="center"
title="Notes"
placeholder="Write something…"
onChangeMarkdown={setMarkdown}
/>

It only changes the horizontal alignment: the toolbar still tracks the keyboard vertically, and still keeps its padding.medium margin from the screen edge. Ignored when hideToolbar is set, and on web, where there is no toolbar.

MarkdownToolbar​

MarkdownInput renders its own toolbar; render MarkdownToolbar yourself only when you need it somewhere else — docked at the bottom of a screen, for example. It is a Card pill of ToggleIconButtons and holds no state of its own: pass the editor's live StyleState and handle each action against a MarkdownInput ref.

PropTypeDefaultDescription
stateStyleState | null—Live formatting state from the editor's onChangeState. Drives which buttons render in their active (filled) style
actionsMarkdownToolbarAction[]all of themActions to render, in order
marginSpacingProps—Outer margin
testIDstring—Test identifier for UI automation
onPress(action: MarkdownToolbarAction) => void—Invoked with the pressed action

MarkdownToolbarAction is 'bold' | 'italic' | 'underline' | 'bulletList' | 'numberedList'.

const ref = useRef<EnrichedMarkdownTextInputInstance>(null);
const [state, setState] = useState<StyleState | null>(null);

<MarkdownInput
hideToolbar
ref={ref}
placeholder="Write something…"
onChangeMarkdown={setMarkdown}
/>

<MarkdownToolbar
state={state}
actions={['bold', 'italic', 'underline']}
onPress={action => {
if (action === 'bold') ref.current?.toggleBold();
if (action === 'italic') ref.current?.toggleItalic();
if (action === 'underline') ref.current?.toggleUnderline();
}}
/>
note

state comes from the editor's onChangeState, which MarkdownInput consumes internally. When driving your own toolbar, pass hideToolbar and read the state from an EnrichedMarkdownTextInput you render yourself if you need the active highlights.

Web​

react-native-enriched-markdown ships its editor for native only, so on web MarkdownInput falls back to a multiline Input over the raw markdown — same field design, no toolbar. What the user types is the markdown, so content written on web renders identically through MarkdownTypography and stays editable in the native editor. onChangeMarkdown and onChangeText both fire with the raw text; the ref and the formatting props are ignored.