Skip to main content

EnrichedMarkdownText

EnrichedMarkdownText renders Markdown content as fully native text - no WebView required. It parses Markdown with md4c and paints it with the platform's native text stack (TextKit on iOS, TextView on Android), so selection, accessibility, and font scaling all behave like first-class native text.

import { EnrichedMarkdownText } from 'react-native-enriched-markdown';
import { Linking } from 'react-native';

export default function App() {
return (
<EnrichedMarkdownText
markdown={
'# Hello\n\nA paragraph with **bold** and a [link](https://reactnative.dev).'
}
onLinkPress={({ url }) => Linking.openURL(url)}
/>
);
}

Props​

EnrichedMarkdownText accepts every prop below. On iOS and Android it also forwards the standard React Native View props - such as testID, onLayout, pointerEvents, hitSlop, and the accessibility* props - to the underlying native view. The one exception is style: use containerStyle instead. It maps to the wrapper view's style, and is renamed so it is not mistaken for styling the Markdown text - that is markdownStyle.

caution

The web build does not implement React Native View props. Its props type is HTMLAttributes<HTMLDivElement> plus testID (which becomes data-testid), and any unrecognized prop is spread onto the root <div>, so passing pointerEvents or hitSlop there produces React unknown-prop warnings. Use id, aria-* and CSS instead. See Web support.

All the shapes printed inline below (Md4cFlags, StreamingConfig, AccessibilityLabels and the rest) are exported types you can import from the package root. Note the Text prefix on three of them: TextContextMenuItem, TextSelectionMenuConfig and TextSelectionMenuPluralLabels are this component's types, because the unprefixed names belong to EnrichedMarkdownTextInput.

note

Each prop has a live playground below - edit the code and try it. Props marked with a iOS only, Android only, or Web only badge only take effect on that platform - they are flagged with a matching colored dot in the table of contents.

markdown​

The Markdown content to render. Which syntax elements are recognized depends on the flavor and the md4cFlags you set. To learn more, see Feature support.

TypeDefault
stringRequired
Loading…

markdownStyle​

Style configuration for Markdown elements. See the Style properties reference for the full list of styleable properties.

TypeDefault
MarkdownStyle{}
Loading…

containerStyle​

Style for the view that wraps the rendered Markdown. ViewStyle and TextStyle are React Native's own style types: in practice you use the ViewStyle layout and appearance properties here: padding, margin, backgroundColor, borderRadius, borderWidth, and the flexbox props. TextStyle is accepted for parity, but to style the text itself (headings, links, code, and other elements) use markdownStyle instead.

note

containerStyle is React Native's regular style prop, renamed. It is handed straight to the wrapper <View>, so ViewStyle values (padding, margin, backgroundColor, the flexbox props) behave exactly as on any React Native view. It is renamed because the renderer is text-like: a prop called style would imply it styles the Markdown text, but the text is styled per element through markdownStyle. containerStyle styles only the box around it.

caution

On web, containerStyle is typed as React's CSSProperties and is spread straight onto the root <div>, so React Native shorthands do not apply: write paddingLeft/paddingRight rather than paddingHorizontal, marginTop/marginBottom rather than marginVertical, and fontWeight: 'bold' rather than fontWeight: 700. The web type also omits style entirely - containerStyle is the only way to style the container there.

TypeDefault
ViewStyle | TextStyle—
Loading…

flavor​

Markdown flavor. 'commonmark' (default) renders the whole document as a single text view. 'github' splits the AST into segments, which changes both what is parsed and how it is rendered.

Markdown flavors is the canonical list of what the switch changes - parsing, rendering, and the two things you give up under github (numberOfLines / ellipsizeMode are ignored, and text selection cannot span segments). Extra inline syntax (underline, highlight, super/subscript, math) is enabled separately through md4cFlags, independent of the flavor.

TypeDefault
'commonmark' | 'github''commonmark'
import { EnrichedMarkdownText } from 'react-native-enriched-markdown';

const markdown = `| Feature | Supported |
| ------- | :-------: |
| Tables | Yes |
| Code | Yes |

\`\`\`ts
const x = 1;
\`\`\`
`;

export default function App() {
return (
<EnrichedMarkdownText
// 'github' splits the document into segments and enables GFM tables and
// block-style fenced code blocks. 'commonmark' (default) renders a single
// text view.
flavor="github"
markdown={markdown}
/>
);
}

md4cFlags​

Toggles for md4c's parser extensions; each opts a piece of extra inline syntax in or out. Pass only the flags you want to change; the rest keep their defaults below. Where a flag enables a new inline element, tune its appearance through the matching style property.

TypeDefault
Md4cFlags{ underline: false, superscript: false, subscript: false, highlight: false, latexMath: true, hardSoftBreaks: false, preserveBlankLines: false, admonitions: true }

underline​

When true, the _ character stops meaning emphasis entirely and starts meaning underline: _text_ is underlined, and __text__ becomes doubly underlined rather than bold. Only *text* and **text** still produce italic and bold, which is why the * forms are the safer ones to author with.

TypeDefault
booleanfalse
Loading…

superscript​

When true, parses ^text^ as superscript.

TypeDefault
booleanfalse
Loading…

subscript​

When true, parses ~text~ as subscript. When disabled, ~ means nothing on its own; ~~text~~ is strikethrough, but only under flavor="github" - under commonmark the tildes render literally.

TypeDefault
booleanfalse
Loading…

highlight​

When true, parses ==text== as highlighted spans. When disabled, double equals signs are treated as plain text.

TypeDefault
booleanfalse
Loading…

latexMath​

When true, parses $...$ as inline math and $$...$$ as display (block) math. Rendering on web uses KaTeX and natively RaTeX. Unlike the other flags on this page, latexMath is enabled by default - set it to false to treat dollar signs as plain text.

TypeDefault
booleantrue
Loading…

hardSoftBreaks​

When true, treats single newlines (soft breaks) as hard breaks, rendering them as visible line breaks instead of collapsing them into spaces. See Line breaks for details.

TypeDefault
booleanfalse
Loading…

preserveBlankLines​

When true, preserves runs of consecutive blank lines from the source instead of collapsing them into a single paragraph break (per CommonMark). Each blank line renders as one empty line, so the output keeps the exact line count that was typed. Pair it with hardSoftBreaks and zeroed paragraph margins to reproduce content authored in EnrichedMarkdownTextInput line for line - see the Editor-style text guide, or Blank lines for the element detail.

TypeDefault
booleanfalse
Loading…

admonitions​

Renders GitHub-style admonitions (also called alerts): a blockquote whose first line is > [!NOTE], > [!TIP], > [!IMPORTANT], > [!WARNING], or > [!CAUTION] becomes a themed callout with an icon and a title header instead of a plain quote. Unlike the other flags, this one is on by default.

Admonitions inherit the blockquote's geometry and override only its colors, so restyle them through markdownStyle.blockquote.admonitions - see that section for the per-type palette.

note

Only takes effect with flavor="github". Under flavor="commonmark" the flag is forced off and > [!NOTE] renders as an ordinary blockquote with the literal text.

TypeDefault
booleantrue
Loading…

enableTaskListItemToggle​

Controls whether tapping a task list checkbox toggles its checked state. When false, the checkbox renders its Markdown state read-only and the tap is fully inert; no visual toggle and onTaskListItemPress does not fire. Text selection and links in the same row are unaffected. Task lists themselves need flavor="github", so this prop has nothing to act on under commonmark.

TypeDefault
booleantrue
Loading…

enableBlockContextMenu​

Controls the long-press context menu on block views - fenced code blocks, tables, and block math. When false, long-pressing a block no longer opens the copy popup. It does not affect the code-block header copy button, the VoiceOver / TalkBack copy action, or the system text-selection menu. To hide individual built-in actions while keeping the menu, use selectionMenuConfig instead - see Copy options.

TypeDefault
booleantrue
import { EnrichedMarkdownText } from 'react-native-enriched-markdown';

const markdown = '```ts\nconst greeting = "hello";\n```';

export default function App() {
return (
<EnrichedMarkdownText
flavor="github"
markdown={markdown}
// Long-pressing the code block (or a table / block math) no longer opens
// the copy popup. The header copy button, accessibility copy action, and
// text-selection menu are unaffected.
enableBlockContextMenu={false}
/>
);
}

enableCodeBlockWrapToggle​

Shows a line wrap toggle next to the copy button in each fenced code block header; set it to false to hide the toggle. Blocks start unwrapped and scroll horizontally; tapping the toggle wraps that block's lines at the block width, and tapping again goes back to scrolling. The state belongs to the block's position in the document: it survives streaming appends and resets when the Markdown is replaced rather than appended to. Needs flavor="github"; the toggle's spoken labels come from accessibilityLabels.codeBlock. iOS and Android only; macOS accepts the prop but shows no toggle.

TypeDefault
booleantrue

enableLinkPreview iOS only​

Controls the native link preview on long press. It is on by default, and supplying onLinkLongPress turns it off - your handler owns the long press instead. That override cannot be undone from this prop: with onLinkLongPress set, even an explicit enableLinkPreview={true} is discarded. Setting selectable={false} also suppresses the preview.

TypeDefault
booleantrue
import { EnrichedMarkdownText } from 'react-native-enriched-markdown';

const markdown = 'Long-press [this link](https://reactnative.dev) on iOS.';

export default function App() {
return (
<EnrichedMarkdownText
markdown={markdown}
// Disables the native iOS link preview on long press without providing
// an onLinkLongPress handler.
enableLinkPreview={false}
/>
);
}

selectable​

Whether text can be selected. On web, false applies user-select: none. On iOS it also suppresses the long-press link preview (see enableLinkPreview).

TypeDefault
booleantrue
Loading…

selectionColor​

Color of the text selection highlight. On iOS this also tints the caret and selection handles (they share one tint). On macOS, only the selection background is affected. On Android, use selectionHandleColor to override the handle color independently.

TypeDefault
ColorValue—
Loading…

selectionHandleColor Android only​

Color of the selection handles (drag anchors). No-op on Android API levels below 29.

TypeDefault
ColorValue—
import { EnrichedMarkdownText } from 'react-native-enriched-markdown';
import { useColorScheme } from 'react-native';

const markdown = 'Select this text on Android to see the tinted drag handles.';

export default function App() {
const isDark = useColorScheme() === 'dark';

return (
<EnrichedMarkdownText
markdown={markdown}
// Colors the selection drag anchors independently of selectionColor.
// No-op on Android API levels below 29.
selectionHandleColor={isDark ? '#57b495' : '#3f9e82'}
/>
);
}

allowFontScaling​

Whether fonts should scale to respect the OS Text Size accessibility setting.

TypeDefault
booleantrue
import { EnrichedMarkdownText } from 'react-native-enriched-markdown';

const markdown = 'This text ignores the OS **Text Size** accessibility setting.';

export default function App() {
return (
<EnrichedMarkdownText
markdown={markdown}
// When false, text stays at its designed size regardless of the user's
// Text Size / Font Size accessibility setting.
allowFontScaling={false}
/>
);
}

maxFontSizeMultiplier​

Maximum font scale multiplier when allowFontScaling is enabled. undefined or 0 means no limit; a value >= 1 caps the multiplier.

TypeDefault
numberundefined
import { EnrichedMarkdownText } from 'react-native-enriched-markdown';

const markdown = 'This text scales with accessibility settings, but never past 1.5x.';

export default function App() {
return (
<EnrichedMarkdownText
markdown={markdown}
allowFontScaling
// Caps how far the OS Text Size setting can enlarge the text.
maxFontSizeMultiplier={1.5}
/>
);
}

allowTrailingMargin​

Whether to preserve the bottom margin of the last block element. When false (default), the trailing margin is removed to eliminate bottom spacing.

TypeDefault
booleanfalse
Loading…

streamingAnimation​

When true, newly appended content fades in during streaming updates. Only the tail is animated, and the tail is found by length, not by diffing: everything past the previous content's length fades in. Streaming therefore assumes append-only updates - replacing markdown wholesale (a retry or a regeneration) will not fade correctly. Recommended for LLM streaming use cases.

TypeDefault
booleanfalse
import { EnrichedMarkdownText } from 'react-native-enriched-markdown';
import { useEffect, useState } from 'react';

const full = 'Streaming responses fade their new characters in as they arrive.';

export default function App() {
const [markdown, setMarkdown] = useState('');

// Reveal the text one word at a time to imitate an LLM stream.
useEffect(() => {
const words = full.split(' ');
let i = 0;
const id = setInterval(() => {
i += 1;
setMarkdown(words.slice(0, i).join(' '));
if (i >= words.length) clearInterval(id);
}, 250);
return () => clearInterval(id);
}, []);

return <EnrichedMarkdownText markdown={markdown} streamingAnimation />;
}

streamingConfig​

Fine-grained control over how incomplete tables, fenced code blocks and media blocks are handled while streaming. Two conditions must both hold for it to do anything: streamingAnimation must be true, and the flavor must be github - the filter runs only on the segmented renderer, so under flavor="commonmark" you get the tail fade and nothing else.

TypeDefault
StreamingConfig{ tableMode: 'progressive', codeBlockMode: 'progressive', mediaMode: 'progressive' }
interface StreamingConfig {
tableMode?: 'hidden' | 'progressive';
codeBlockMode?: 'hidden' | 'progressive';
mediaMode?: 'hidden' | 'progressive';
}
import { EnrichedMarkdownText } from 'react-native-enriched-markdown';
import { useEffect, useState } from 'react';

const full = `Here are the results:

| Model | Score |
| ----- | ----: |
| A | 91 |
| B | 87 |

\`\`\`ts
const score = results.at(0);
\`\`\`
`;

export default function App() {
const [markdown, setMarkdown] = useState('');

// Reveal the document a few characters at a time to imitate an LLM stream.
useEffect(() => {
let i = 0;
const id = setInterval(() => {
i += 4;
setMarkdown(full.slice(0, i));
if (i >= full.length) clearInterval(id);
}, 60);
return () => clearInterval(id);
}, []);

return (
<EnrichedMarkdownText
markdown={markdown}
flavor="github"
streamingAnimation
streamingConfig={{
// Hold incomplete tables back until they are complete...
tableMode: 'hidden',
// ...but stream code line-by-line as it arrives.
codeBlockMode: 'progressive',
}}
/>
);
}
  • tableMode
    • 'progressive' (default) renders the table row-by-row as content arrives (incomplete trailing rows are trimmed).
    • 'hidden' withholds the table until it is complete.
  • codeBlockMode
    • 'progressive' (default) streams the code in with a visible but non-interactive header and defers syntax highlighting until the closing fence arrives.
    • 'hidden' withholds the block until it is complete.
  • mediaMode
    • 'progressive' (default) shows a trailing image paragraph, link paragraph or <video> block as soon as its syntax is complete (the closing ) or >); until then the partial line is withheld instead of rendering as text. A bare URL on its own line can always grow, so it is withheld until the next line arrives.
    • 'hidden' withholds the block until a blank line or another block follows it, like tableMode: 'hidden', so it never appears before the content after it. Applies to native images and to blocks drawn by renderers alike.

An unterminated block-math ($$) block is also withheld until its closing $$ arrives. That behavior is unconditional and has no key here.

spoilerOverlay​

Controls how spoiler text (||hidden text||) is displayed before being revealed. Both modes support tap-to-reveal.

TypeDefault
'particles' | 'solid''particles'
danger

The web build has no spoiler renderer at all, and an unhandled node is dropped together with its subtree - so a ||secret|| b renders as a b with the concealed text missing, not concealed. Do not place content behind a spoiler if the same Markdown is also rendered on web.

import { EnrichedMarkdownText } from 'react-native-enriched-markdown';
import { View, StyleSheet } from 'react-native';

const markdown =
'The killer was ||the butler|| all along. Tap the spoiler to reveal it.';

export default function App() {
return (
<View style={styles.container}>
<EnrichedMarkdownText
markdown={markdown}
// 'particles' (default) animates a particle overlay; 'solid' paints an
// opaque rectangle (Discord-style). Both support tap-to-reveal.
spoilerOverlay="solid"
/>
</View>
);
}

const styles = StyleSheet.create({
container: { gap: 12 },
});
  • 'particles': animated particle overlay (CAEmitterLayer on iOS, Choreographer-driven Canvas particles on Android).
  • 'solid': opaque rectangle covering the text (Discord-style).

imageRequestHeaders​

HTTP headers attached to remote image requests, e.g. a Referer required by CDN hotlink protection or an Authorization token. Headers participate in image cache identity, so the same URL requested with different headers is fetched and cached separately - see Image caching for the details and the disk-cache caveat.

TypeDefault
Record<string, string>—
import { EnrichedMarkdownText } from 'react-native-enriched-markdown';

const markdown = '![A protected image](https://example.com/image.png)';

export default function App() {
return (
<EnrichedMarkdownText
markdown={markdown}
// Attached to remote image requests - e.g. a Referer for CDN hotlink
// protection or an Authorization token. Headers are part of the image
// cache key, so the same URL with different headers is cached separately.
imageRequestHeaders={{
Referer: 'https://example.com',
}}
/>
);
}

renderers​

Functions that return React elements to stand in for whole blocks, keyed by kind. The guide Custom block renderers explains layout, streaming and examples.

  • Which blocks: a paragraph that is only an image (image), a paragraph that is only a link, autolink or bare URL (link), and a <video src="..."> block (video). The block must sit directly in the document, a blockquote or an admonition, not in a list. A list item that holds only a video is an exception: the parser moves that video out of the list, so it is replaced in the list's container. Images and links elsewhere stay native.
  • Return value: an element replaces the block; undefined (or no return) keeps the native block. A renderer that throws, or returns text, keeps the native block and warns in development.
  • Size: the element needs an intrinsic height; the host sets the width. See Sizing your element.
  • Calls: once per block, and again only when that block or a renderer function changes. Keep the functions stable with useCallback. A renderer is a plain function, not a component, so keep hooks inside the component it returns.
  • Presses: the element handles its own. onImagePress, onLinkPress, onLinkLongPress and the link menu do not fire for a replaced block.
  • Flavor: flavor="github" only.
TypeDefault
MarkdownRenderers—
interface MarkdownRenderers {
image?: (block: MarkdownImageBlock) => ReactElement | undefined;
video?: (block: MarkdownVideoBlock) => ReactElement | undefined;
link?: (block: MarkdownLinkBlock) => ReactElement | undefined;
}

// The container the block sits in. Inside a blockquote or an admonition the
// block is narrower and indented.
type MarkdownBlockPlacement = 'root' | 'blockquote' | 'admonition';

interface MarkdownImageBlock {
kind: 'image';
id: string; // opaque, stable while the block is unchanged; safe as a React key
url: string;
altText: string; // "" when there is none
title: string; // "" when there is none
placement: MarkdownBlockPlacement;
occurrenceId: string; // this image's MarkdownImageOccurrence id (resolveImageSource)
}

interface MarkdownVideoBlock {
kind: 'video';
id: string;
url: string;
placement: MarkdownBlockPlacement;
}

interface MarkdownLinkBlock {
kind: 'link';
id: string;
url: string;
label: string; // the link text; "" for a bare URL or autolink
title: string; // the Markdown title, e.g. a MIME type or size; "" when there is none
placement: MarkdownBlockPlacement;
}

// Any of the three, for code that handles every kind.
type MarkdownBlock = MarkdownImageBlock | MarkdownVideoBlock | MarkdownLinkBlock;
Loading…

resolveImageSource​

Chooses the request made for an image instead of its Markdown URL, such as a signed URL or per-image headers. See Signed and authenticated images for an example.

  • Which images: every image, including those in lists, tables, blockquotes and links.
  • Return value: a source, a promise of one, or undefined (or no return) to load the Markdown URL. Until it answers, and when a promise rejects or resolves to an empty uri, the image keeps its placeholder and the Markdown URL is not requested.
  • Calls: once per image while it stays in the document, after rendering, never during it. A streaming append only asks about new images; an edited image, or one whose placement changed, is asked about again and keeps its current source until the answer lands. Passing a new function does not resolve again, so an inline arrow is fine. Remount with a key to start over, for example after a token refresh.
  • Headers: merge over imageRequestHeaders (case-insensitive names) and take part in the image cache identity.
  • Unchanged elsewhere: events, copy actions and renderers keep reporting the Markdown URL.
  • Flavor: flavor="github" only.
TypeDefault
(image: MarkdownImageOccurrence) => MarkdownImageSource | undefined | Promise<MarkdownImageSource | undefined>—
interface MarkdownImageOccurrence {
id: string; // opaque, stable while the image is unchanged; never equal to a block id
url: string;
altText: string;
title: string;
placement: MarkdownImagePlacement;
}

// root, blockquote, admonition: an image paragraph (a block `renderers` can replace).
// inline: inside running text. list: inside a list item. table: in a table cell.
// link: the content of a link, as in [![thumb](a)](b).
type MarkdownImagePlacement = MarkdownBlockPlacement | 'inline' | 'list' | 'table' | 'link';

interface MarkdownImageSource {
uri: string;
headers?: Record<string, string>;
}
Loading…

linkPillContent​

Per-link content for link pills: a label, icon and icon tint for one exact link URL. It only affects links whose markdownStyle.linkVariants entry enables pill, and wins over that variant's pill.label, pill.iconUri and pill.iconTintColor. This is content, not style: keep markdownStyle stable and change this map as links appear, and keep the object reference stable between renders when its content has not changed.

TypeDefault
Record<string, LinkPillContent>—
interface LinkPillContent {
/** Replaces the pill's label. Falls back to the variant's `pill.label`, then the link text. */
label?: string;
/** Icon shown before the label - a bundled asset name, file path, `data:` or `http(s)` URI. */
iconUri?: string;
/** Tints this link's icon and keeps its alpha. Omit to keep the icon's own colors. */
iconTintColor?: string;
}
import { EnrichedMarkdownText } from 'react-native-enriched-markdown';
import { useColorScheme } from 'react-native';
import { useMemo } from 'react';
import { defaultMarkdownStyle } from './theme';

const markdown =
'Open [src/components/Button.tsx](https://example.com/files/src/components/Button.tsx).';

export default function App() {
const isDark = useColorScheme() === 'dark';
const markdownStyle = useMemo(
() => ({
...defaultMarkdownStyle(isDark),
// The variant enables pills; the content below fills one in.
linkVariants: {
'^https://example\\.com/files/': {
color: '#3730A3',
backgroundColor: '#EEF2FF',
underline: false,
pill: { iconUri: 'file_icon' },
},
},
}),
[isDark],
);

return (
<EnrichedMarkdownText
markdown={markdown}
markdownStyle={markdownStyle}
// Keyed by exact URL. Keep the reference stable when nothing changed.
linkPillContent={{
'https://example.com/files/src/components/Button.tsx': {
label: 'Button.tsx',
},
}}
/>
);
}

linkRecognition​

Opt-in link recognition for text that is not written as a Markdown link, such as #1234, @name, $skill or a file path. Off by default. Works in both flavors on every platform.

KeyTypeMatches
textRegExpEvery nonempty, non-overlapping match inside a plain-text run
inlineCodeRegExpAn inline-code span whose whole content matches becomes one link

A recognized link's URL is the matched text itself, so linkVariants, linkPillContent, linkContextMenuItems, onLinkPress and onLinkLongPress apply to it like to any other link. Existing links, autolinks, code blocks, images, video and math are never touched. The Markdown source is unchanged: copying, including Copy as Markdown, returns the original text without link syntax.

Matching happens per parsed text run, after Markdown parsing, so a pattern cannot match across formatting. __init__.py as plain text is split by emphasis parsing and will not match, while `__init__.py` as inline code matches whole. With the latexMath flag on (the default), two $ on one line form a math span, so $deploy ... $test is math, not two skills; turn the flag off or escape the $ in such content.

Only the regex source and the i and s flags are used. Patterns must be valid for NSRegularExpression and java.util.regex.Pattern as well as JavaScript; an invalid pattern turns that recognizer off. This is different from the input's linkRegex, which detects URLs and is on by default. Pass the object inline or hoisted as you like: an equal config does not re-send the prop.

<EnrichedMarkdownText
markdown="Fixed in #1284, see `src/App.tsx`"
linkRecognition={{ text: /#\d+/, inlineCode: /[\w./-]+\.tsx?/ }}
markdownStyle={{ linkVariants: { '^#\\d+$': { pill: true } } }}
onLinkPress={({ url }) => openReference(url)}
/>

contextMenuItems​

Custom items to add to the text selection context menu. Items appear before the system actions and are hidden when visible: false. On iOS this requires iOS 16+; on earlier versions the prop is ignored.

TypeDefault
TextContextMenuItem[]—
note

The exported type is TextContextMenuItem. The unprefixed ContextMenuItem export is the editor's item type, whose onPress payload carries an extra styleState field.

interface TextContextMenuItem {
/** Label shown in the context menu. */
text: string;
/** SF Symbol name for the item icon (iOS/macOS only; ignored on Android). */
icon?: string;
onPress: (event: {
/** The selected text at the time of the press. */
text: string;
/** Absolute character range of the selection within the full content. */
selection: { start: number; end: number };
}) => void;
/** When false, the item is not shown. Defaults to true. */
visible?: boolean;
}
import { EnrichedMarkdownText } from 'react-native-enriched-markdown';

const markdown = 'Select any part of this sentence to open the custom menu.';

export default function App() {
return (
<EnrichedMarkdownText
markdown={markdown}
// Custom items appear before the system actions (Copy, etc.). On iOS this
// requires iOS 16+; the icon is an SF Symbol (ignored on Android).
contextMenuItems={[
{
text: 'Summarize with AI',
icon: 'sparkles',
onPress: ({ text, selection }) => {
console.log('Selected:', text, selection);
},
},
]}
/>
);
}

linkContextMenuItems iOS only​

Items of the native menu shown when a link is long-pressed, on iOS 17 and later. Keys are URL regex patterns matched like markdownStyle.linkVariants: the longest pattern that matches a link's URL supplies its menu, so one entry covers every link of a kind, and a pattern anchored to one URL gives that link its own menu. See Link menus for the full behavior.

A link with a menu shows it instead of onLinkLongPress and the system link preview. The menu holds only your items and is titled with the link's text, or its pill label. A link that matches no pattern, or whose pattern has no visible items, keeps its existing long-press behavior. Android, macOS and iOS below 17 ignore this prop; keep onLinkLongPress for them.

TypeDefault
Record<string, LinkContextMenuItem[]>—
interface LinkContextMenuItem {
/** Label shown in the menu. Identifies the item, so unique within one pattern. */
text: string;
/** SF Symbol name, as in `contextMenuItems`. */
icon?: string;
/** Receives the link's original URL, including relative paths. */
onPress: (event: { url: string }) => void;
/** When false, the item is not shown. Defaults to true. */
visible?: boolean;
/** Shown but not tappable. Defaults to false. */
disabled?: boolean;
/** Styled as a destructive action. Defaults to false. */
destructive?: boolean;
}
note

The library does not navigate or copy for these items; do that in onPress. A menu can stay open while the prop changes: a press on an item the current value no longer offers (removed, hidden or disabled) is ignored.

import { EnrichedMarkdownText } from 'react-native-enriched-markdown';

const markdown =
'Open [README.md](https://example.com/files/README.md) or ask [@gregory](user:gregory).';

export default function App() {
return (
<EnrichedMarkdownText
markdown={markdown}
// Keys are URL patterns, matched like `linkVariants`; the longest match
// supplies the menu. Icons are SF Symbols. Needs iOS 17+.
linkContextMenuItems={{
'^https://example\\.com/files/': [
{
text: 'Copy path',
icon: 'doc.on.doc',
onPress: ({ url }) => console.log('copy', url),
},
{
text: 'Delete',
icon: 'trash',
destructive: true,
onPress: ({ url }) => console.log('delete', url),
},
],
'^user:': [
{
text: 'Mention',
icon: 'at',
onPress: ({ url }) => console.log('mention', url),
},
],
}}
// Android, macOS and iOS 16 ignore the menus and call this instead.
onLinkLongPress={({ url }) => console.log('long press', url)}
/>
);
}

selectionMenuConfig​

Controls the built-in actions in the native text selection menu (and the table/math copy menus) and lets you localize their labels. Custom app actions are controlled separately with contextMenuItems. Each item takes an object: { enabled } toggles visibility (the system copy item can't be hidden - only relabeled) and label overrides the English default. On iOS this goes through the same iOS 16+ edit-menu API as contextMenuItems, so it is ignored on earlier versions.

TypeDefault
TextSelectionMenuConfig{ copyAsMarkdown: { enabled: true }, copyImageUrl: { enabled: true } }
interface TextSelectionMenuConfig {
copy?: { label?: string }; // system Copy: relabel only, cannot be hidden
copyAsMarkdown?: { enabled?: boolean; label?: string };
copyImageUrl?: {
// shown when the selection contains images
enabled?: boolean;
label?: string; // single image
pluralLabels?: TextSelectionMenuPluralLabels; // 2 or more images
};
}

interface TextSelectionMenuPluralLabels {
other: string; // required; every other category falls back to this
zero?: string;
one?: string;
two?: string;
few?: string;
many?: string;
}

In each plural form, the {count} token is replaced with the number of selected images. A selection containing exactly one image always uses the singular label, never a plural form - pluralLabels starts at two.

import { EnrichedMarkdownText } from 'react-native-enriched-markdown';

const markdown = 'Select this text to see the customized selection menu.';

export default function App() {
return (
<EnrichedMarkdownText
markdown={markdown}
selectionMenuConfig={{
// Hide the built-in "Copy as Markdown" action...
copyAsMarkdown: { enabled: false },
// ...and relabel the system Copy item (it can't be hidden).
copy: { label: 'Copy text' },
}}
/>
);
}
note

With flavor="github", selection.start / selection.end in menu callbacks are relative to the text segment the selection is in, not the full Markdown string. With flavor="commonmark" they are absolute within the full rendered text.

accessibilityLabels​

Translations for every string spoken by VoiceOver (iOS) and TalkBack (Android): list items, table rows, math, and the iOS rotor. All fields are optional; omitted fields fall back to the English defaults. Placeholders ({n}, {content}, {latex}) are substituted natively at speak time and must be preserved in translations. See the Accessibility guide for the full defaults table.

TypeDefault
AccessibilityLabels—
interface AccessibilityLabels {
list?: {
bulletPoint?: string;
nestedBulletPoint?: string;
orderedItem?: string; // {n} is the 1-based item number
nestedOrderedItem?: string; // {n} is the 1-based item number
};
blockquote?: {
quote?: string;
nestedQuote?: string;
};
table?: {
row?: string; // {n} is the 1-based row index, {content} the joined cell text
};
math?: {
equation?: string; // {latex} is the equation source
};
codeBlock?: {
// the enableCodeBlockWrapToggle header button
wrap?: string;
unwrap?: string;
};
rotor?: {
// iOS only, Android has no rotor
headings?: string;
links?: string;
images?: string;
};
}

The package also exports the defaults themselves: DEFAULT_ACCESSIBILITY_LABELS (the full English object, the natural thing to seed a translation table from) and resolveAccessibilityLabels(labels), which merges a partial override onto them and returns a ResolvedAccessibilityLabels.

import { EnrichedMarkdownText } from 'react-native-enriched-markdown';

const markdown = `- First
- Second

> A quote

$E = mc^2$`;

export default function App() {
return (
<EnrichedMarkdownText
markdown={markdown}
// Translates the strings VoiceOver / TalkBack speak. Placeholders like
// {n} and {latex} are substituted natively and must be kept.
accessibilityLabels={{
list: { bulletPoint: 'Punkt', orderedItem: 'Element {n}' },
blockquote: { quote: 'Zitat' },
math: { equation: 'Formel: {latex}' },
}}
/>
);
}

numberOfLines​

Clamps the rendered Markdown to a maximum number of lines, truncating with an ellipsis (see ellipsizeMode) when it overflows. 0 (the default) means unlimited. Mirrors the prop of the same name on React Native's core Text, and is meant for previews such as chat-list rows or reply quotes. The clamp is applied to the measurement pass as well as the rendered view, so measured and rendered heights stay in sync.

note

CommonMark only. Under flavor="github" the content is laid out as independent block segments that cannot honor a document-wide line cap, so the prop is ignored.

caution

On Android a clamped view is neither selectable nor tappable. While numberOfLines > 0, text selection and link taps are off regardless of selectable. Android draws the truncation ellipsis only through StaticLayout, but enabling selection or a link movement method promotes the text to DynamicLayout, which has no maxLines support - React Native's own Text behaves the same way. Both are restored once the clamp is removed, and iOS keeps selection and links while clamped.

TypeDefault
number0
import { EnrichedMarkdownText } from 'react-native-enriched-markdown';
import { View, StyleSheet, useColorScheme } from 'react-native';
import { useMemo } from 'react';
import { defaultMarkdownStyle } from './theme';

const markdown =
'A **chat preview** needs only the first couple of lines. The rest of this paragraph is clamped away, and the truncation is applied to the measurement pass too, so the measured height matches what you see.';

export default function App() {
const isDark = useColorScheme() === 'dark';
const markdownStyle = useMemo(() => defaultMarkdownStyle(isDark), [isDark]);

return (
<View style={styles.container}>
<EnrichedMarkdownText
markdown={markdown}
markdownStyle={markdownStyle}
// CommonMark only - ignored under flavor="github". 0 means unlimited.
numberOfLines={2}
/>
</View>
);
}

const styles = StyleSheet.create({
container: { gap: 12 },
});

ellipsizeMode​

Where the ellipsis is placed when text is truncated by numberOfLines. Only takes effect when numberOfLines is set, and ignored under flavor="github" for the same reason. Mirrors the prop of the same name on React Native's core Text.

TypeDefault
'head' | 'middle' | 'tail' | 'clip''tail'
import { EnrichedMarkdownText } from 'react-native-enriched-markdown';
import { View, StyleSheet, useColorScheme } from 'react-native';
import { useMemo } from 'react';
import { defaultMarkdownStyle } from './theme';

const markdown =
'A single line that runs past the edge of the view and has to be truncated somewhere.';

export default function App() {
const isDark = useColorScheme() === 'dark';
const markdownStyle = useMemo(() => defaultMarkdownStyle(isDark), [isDark]);

return (
<View style={styles.container}>
<EnrichedMarkdownText
markdown={markdown}
markdownStyle={markdownStyle}
// 'head' and 'middle' only place the ellipsis as described at
// numberOfLines={1}; past one line they fall back to tail truncation.
numberOfLines={1}
ellipsizeMode="middle"
/>
</View>
);
}

const styles = StyleSheet.create({
container: { gap: 12 },
});
  • 'head': ellipsis at the start (...d of the text).
  • 'middle': ellipsis in the middle (start...end).
  • 'tail' (default): ellipsis at the end (start of the...).
  • 'clip': truncate at the line boundary with no ellipsis glyph.
caution

'head' and 'middle' are single-line truncation modes - they only place the ellipsis as described when numberOfLines is 1. Past one line Android falls back to tail-style truncation (only TruncateAt.END works there) and iOS is likewise unreliable, so use 'tail' or 'clip' for multi-line clamps. Same limitation as React Native's Text.

textBreakStrategy Android only​

Controls how Android breaks lines within paragraphs. Mirrors the prop of the same name on React Native's core Text. Requires API 23+.

TypeDefault
'simple' | 'highQuality' | 'balanced''highQuality'
import { EnrichedMarkdownText } from 'react-native-enriched-markdown';
import { View, StyleSheet } from 'react-native';

const markdown =
'Supercalifragilisticexpialidocious antidisestablishmentarianism ' +
'pneumonoultramicroscopicsilicovolcanoconiosis fill a narrow column to ' +
'show how Android breaks long lines.';

export default function App() {
return (
<View style={styles.container}>
<EnrichedMarkdownText
markdown={markdown}
// 'simple' | 'highQuality' (default) | 'balanced' - Android only.
textBreakStrategy="balanced"
/>
</View>
);
}

const styles = StyleSheet.create({
container: { maxWidth: 220 },
});
  • 'simple': greedy, no hyphenation; cheapest.
  • 'highQuality' (default): full paragraph optimization with hyphenation.
  • 'balanced': balances line lengths across the paragraph; no hyphenation.

lineBreakStrategyIOS iOS only​

Controls iOS line-breaking refinements. Mirrors the prop of the same name on React Native's core Text and maps to NSParagraphStyle.lineBreakStrategy. Requires iOS 14+.

TypeDefault
'none' | 'standard' | 'hangul-word' | 'push-out''none'
import { EnrichedMarkdownText } from 'react-native-enriched-markdown';

const markdown =
'A long paragraph whose final line will not be left with a single orphaned ' +
'word once push-out kicks in on iOS 14 and later.';

export default function App() {
return (
<EnrichedMarkdownText
markdown={markdown}
// 'none' (default) | 'standard' | 'hangul-word' | 'push-out'.
lineBreakStrategyIOS="push-out"
/>
);
}
  • 'none' (default): no additional strategy.
  • 'standard': the system's standard refinements.
  • 'hangul-word': prefers breaking at Korean word boundaries.
  • 'push-out': avoids orphaned short trailing lines by pushing words to the next line.

writingDirection iOS only​

Paragraph writing direction.

important

Android resolves direction per paragraph via the platform Bidi heuristic (TEXT_DIRECTION_FIRST_STRONG) and is unaffected by this prop.

TypeDefault
'auto' | 'ltr' | 'rtl' | 'first-strong''first-strong'
import { EnrichedMarkdownText } from 'react-native-enriched-markdown';

const markdown =
'هذه فقرة عربية\n\n' +
'This English paragraph stays LTR.\n\n' +
'123 456 789.';

export default function App() {
return (
<EnrichedMarkdownText
// 'first-strong' (default) resolves each paragraph from its first strong
// character. Force a side with 'ltr' / 'rtl', or match RN with 'auto'.
writingDirection="first-strong"
markdown={markdown}
/>
);
}
  • 'first-strong' (default): library extension. Each paragraph resolves its base direction from its first strong directional character, so mixed Arabic/Hebrew/English documents render correctly out of the box. Neutral-only paragraphs fall back to the view's Yoga-resolved layout direction.
  • 'auto': React Native parity. TextKit follows the app's userInterfaceLayoutDirection; mixed-direction paragraphs do not auto-resolve.
  • 'ltr' / 'rtl': force the base direction on every paragraph. Code blocks always render left-to-right regardless of this prop.
note

See RTL support for the full behavior information.

dir Web only​

Text direction on web. Unset or 'auto', each block resolves its own direction from its content, as on native; 'ltr' or 'rtl' forces that direction on the whole document. See RTL support for how each element follows it. It is the web counterpart to writingDirection (iOS) - on iOS and Android it is a no-op.

TypeDefault
'ltr' | 'rtl' | 'auto'—
Loading…

Events​

Callback props fired in response to native interactions. Each has a live playground like the props above.

onLinkPress​

Callback fired when a link is tapped.

TypeDefault
(event: LinkPressEvent) => void—
interface LinkPressEvent {
url: string; // the tapped link's URL
}
Loading…

onLinkLongPress​

Callback fired when a link is long-pressed. On iOS, providing this handler automatically disables the system link preview (see enableLinkPreview). On web, it maps to the contextmenu (right-click) event. On iOS 17+ a link that matches a linkContextMenuItems pattern shows that menu instead of calling this handler.

TypeDefault
(event: LinkLongPressEvent) => void—
interface LinkLongPressEvent {
url: string; // the long-pressed link's URL
}
Loading…

onImagePress​

Callback fired when a rendered image is tapped or clicked. Read the image URL from event.url and its Markdown alt text from event.altText ("" when the image has no alt text) - use it to open a lightbox or full-screen viewer.

Fires for block and inline images, including images inside headings, lists, blockquotes, and GFM table cells. An image that is also a link ([![alt](img)](dest)) keeps link behavior and fires onLinkPress instead, so a single tap never fires both.

Setting this callback makes images interactive; leaving it unset keeps the default tap, text-selection, and long-press behavior unchanged. On web the image becomes focusable, exposes a button role for screen readers, and can be activated with Enter/Space, while the browser's right-click menu is preserved.

TypeDefault
(event: ImagePressEvent) => void—
interface ImagePressEvent {
url: string; // the pressed image's URL
altText: string; // the image's Markdown alt text ("" if none)
}
Loading…

onTaskListItemPress​

Callback fired when a task list checkbox is tapped. The checkbox is toggled natively. Only fires when flavor="github".

TypeDefault
(event: TaskListItemPressEvent) => void—
interface TaskListItemPressEvent {
index: number; // 0-based item index
checked: boolean; // checked state after toggling
text: string; // the item's text
}
Loading…

onCopyPress​

Callback fired when code is copied from a fenced code block - via the header copy button, the long-press Copy action, or the VoiceOver copy action. Does not fire for Copy as Markdown. Only fires when flavor="github".

TypeDefault
(event: CopyPressEvent) => void—
interface CopyPressEvent {
code: string; // the copied code
language: string; // fence language ("" if none)
}
import { EnrichedMarkdownText } from 'react-native-enriched-markdown';

const markdown = '```ts\nconst greeting = "hello";\n```';

export default function App() {
return (
<EnrichedMarkdownText
flavor="github"
markdown={markdown}
// Fires from the code block header copy button, the long-press "Copy"
// action, or the VoiceOver copy action. Not for "Copy as Markdown".
onCopyPress={({ code, language }) => {
console.log(`Copied ${language} code:`, code);
}}
/>
);
}

onCodeBlockPress​

Callback fired when a fenced code block is tapped or clicked anywhere in its body. Use it for actions like opening the code in a viewer or copying it yourself.

Setting this prop is what arms the block for taps; leaving it unset keeps the block inert, with text selection, the header copy button, and the long-press menu unchanged. A tap never fires while text is being selected - selection still starts on long press. Works in both flavors: flavor="github" fires on the container-based code block, flavor="commonmark" on the code-block region inside the single text view. On web the block becomes a clickable, keyboard-activatable element with a button role.

TypeDefault
(event: CodeBlockPressEvent) => void—
interface CodeBlockPressEvent {
code: string; // the block's source
language: string; // fence language ("" if none)
}
Loading…

onLatexError​

Callback fired when a math expression cannot be parsed or rendered by the LaTeX engine. The expression still renders - it falls back to showing its raw source rather than crashing - so this is a reporting hook, not a recovery one. Requires md4cFlags.latexMath (on by default).

Fires at most once per distinct failing expression per component instance. The de-duplication is keyed by displayMode + source and its cache survives markdown changes, so streaming content reports each failure once instead of on every update.

caution

De-duplication is per component instance. A remount - navigation, a changed React key, or list recycling - produces a fresh instance with no memory of prior reports, which fires again for the same expressions. De-duplicate on your side (by source) if you aggregate app-wide. See Handling render errors.

TypeDefault
(event: LatexErrorEvent) => void—
interface LatexErrorEvent {
source: string; // raw LaTeX, without $ / $$ delimiters
message?: string; // engine error message, when it gives one
displayMode: boolean; // false for inline $...$, true for block $$...$$
}
import { EnrichedMarkdownText } from 'react-native-enriched-markdown';

const markdown =
'Valid: $E = mc^2$\n\nInvalid inline: $\\nosuchcommand$\n\n$$\\frac{1}{\\bogus}$$';

export default function App() {
return (
<EnrichedMarkdownText
markdown={markdown}
// Fires once per distinct failing expression per component instance.
// The failing formula still renders as its raw source rather than
// crashing - this callback is for reporting, not recovery.
onLatexError={({ source, message, displayMode }) => {
console.log('LaTeX failed', { source, message, displayMode });
}}
/>
);
}

See also​

  • Element structure - every supported element, its syntax, block vs. inline categorization, and nesting behavior.
  • Style properties - all styleable properties, including a Dark mode recipe with useColorScheme().
  • Copy options - smart copy, copy as Markdown, and copy image URL.
  • Link pills - presenting links as rounded chips with an icon and label.
  • Link menus - what a long press on a link does, and custom menus per URL pattern.
  • Accessibility - VoiceOver and TalkBack support, custom rotors, and semantic traits.
  • Testing with Jest - the shipped Jest mock for rendering and asserting on the components in tests.
  • RTL support - right-to-left languages and per-element RTL behavior.