@gotocva/react-markdown
v1.0.0
Published
React + Vite library: render Markdown into styled HTML and edit it with a Notion-style block editor (slash menu, bubble menu, syntax-highlighted code blocks, resizable tables with auto S.No, GFM round-trip).
Maintainers
Readme
@gotocva/react-markdown
A batteries-included React + Vite Markdown toolkit with two components:
<MarkdownRenderer />— render Markdown to safe, styled HTML.<MarkdownEditor />— a Notion-style block editor that emits Markdown.
Highlights:
- GitHub Flavored Markdown everywhere (tables, task lists, strikethrough, autolinks)
- Syntax highlighting in both the editor (lowlight) and the renderer (rehype-highlight)
- Slash command menu, inline bubble menu, language picker, resizable tables
- Tables auto-numbered through an
S.Nocolumn when present - Clean default CSS with light + dark scheme, easily themable via CSS variables
- First-class TypeScript types
- Ships ESM and UMD bundles plus declaration files
Built on top of react-markdown,
remark-gfm,
rehype-highlight,
highlight.js,
TipTap (ProseMirror),
lowlight,
marked and
turndown.
Table of contents
- Install
- Quick start — renderer
- Quick start — editor
- Renderer API
- Editor API
- Slash command menu
- Inline bubble menu
- Code blocks & language picker
- Tables (with auto S.No)
- Markdown ↔ HTML utilities
- Styling and theming
- GFM support
- SSR (Next.js)
- Local development
- Publishing
- Troubleshooting
- License
Install
# npm
npm install @gotocva/react-markdown
# pnpm
pnpm add @gotocva/react-markdown
# yarn
yarn add @gotocva/react-markdownreact and react-dom (≥ 17) are peer dependencies. Install them if you
don't have them yet:
npm install react react-domQuick start — renderer
import { MarkdownRenderer } from '@gotocva/react-markdown';
import '@gotocva/react-markdown/styles.css';
export default function Page() {
return <MarkdownRenderer content="# Hello, world!" />;
}That's the whole API for the common case. Pass a Markdown string in via
content, or fetch from a URL via src:
<MarkdownRenderer src="/docs/getting-started.md" />Source loaded through Vite? Use the ?raw import suffix:
import readme from './README.md?raw';
<MarkdownRenderer content={readme} />;Quick start — editor
import { useState } from 'react';
import { MarkdownEditor } from '@gotocva/react-markdown';
import '@gotocva/react-markdown/styles.css';
export default function NotePage() {
const [markdown, setMarkdown] = useState('# Untitled');
return (
<MarkdownEditor
initialMarkdown={markdown}
onChange={({ markdown }) => setMarkdown(markdown)}
autoFocus
minHeight="60vh"
/>
);
}The editor is uncontrolled internally (TipTap owns the document) — pass
initialMarkdown once on mount, then sync your own state from
onChange. Don't re-seed initialMarkdown on every render.
onChange receives both formats on every keystroke:
onChange?: (value: {
markdown: string;
html: string;
editor: import('@tiptap/react').Editor;
}) => void;Renderer API
<MarkdownRenderer
content="..." // markdown string (wins over `src`)
src="..." // url to fetch markdown from
className="prose prose-slate" // extra classes on the wrapping div
style={{ maxWidth: 720 }} // inline styles on the wrapping div
gfm // default: true
allowHtml={false} // default: false; only allow if trusted
syntaxHighlight // default: true
highlightTheme="github" // 'github' | 'github-dark' | 'atom-one-light' | 'atom-one-dark' | 'none'
linkTarget="_blank" // adds rel="noopener noreferrer" automatically
components={{ a: MyLink }} // override any element renderer (react-markdown Components)
loadingFallback={<p>Loading…</p>} // shown while `src` is being fetched
errorFallback={(e) => <p>{e.message}</p>}
/>Renderer props
| Prop | Type | Default | Description |
| ----------------- | ----------------------------------------------------------------------------- | ----------------- | ----------- |
| content | string | undefined | Inline Markdown source. Wins over src if both are set. |
| src | string | undefined | URL of a Markdown file to fetch and render. |
| className | string | undefined | Extra classes on the wrapping <div>. |
| style | React.CSSProperties | undefined | Inline style for the wrapping <div>. |
| gfm | boolean | true | Enable GitHub Flavored Markdown. |
| allowHtml | boolean | false | Allow raw HTML in the source. Only enable for trusted input. |
| syntaxHighlight | boolean | true | Enable syntax highlighting for fenced code blocks. |
| highlightTheme | 'github' \| 'github-dark' \| 'atom-one-dark' \| 'atom-one-light' \| 'none' | 'github' | highlight.js theme loaded on demand from jsDelivr. Use 'none' to bring your own. |
| linkTarget | '_self' \| '_blank' \| '_parent' \| '_top' | undefined | target for rendered links. _blank auto-adds rel="noopener noreferrer". |
| components | import('react-markdown').Components | undefined | Override or extend the underlying element renderer map. |
| loadingFallback | ReactNode | Loading… | Shown while src is being fetched. |
| errorFallback | (err: Error) => ReactNode | inline message | Shown when src fetch fails. |
import type { MarkdownRendererProps, HighlightTheme } from '@gotocva/react-markdown';Editor API
<MarkdownEditor
initialMarkdown="..." // initial document as markdown
initialHtml="..." // alternative: initial document as html
onChange={({ markdown, html, editor }) => { /* … */ }}
placeholder="Type '/' for commands…"
editable // default: true
bubbleMenu // default: true
slashCommand // default: true
autoFocus // default: false
minHeight="60vh" // default: '200px'
highlightTheme="github" // default: 'github'
className="my-editor"
/>Editor props
| Prop | Type | Default | Description |
| ----------------- | --------------------------------------------------------------------------------- | -------------------------------- | ----------- |
| initialMarkdown | string | undefined | Initial document as Markdown. Converted to HTML on mount. |
| initialHtml | string | undefined | Initial document as HTML (alternative to initialMarkdown). |
| onChange | ({ markdown, html, editor }) => void | undefined | Fired on every document change. |
| placeholder | string | "Type '/' for commands…" | Empty-line placeholder. |
| editable | boolean | true | Read-only mode when false. |
| bubbleMenu | boolean | true | Show the inline formatting bubble menu on selection. |
| slashCommand | boolean | true | Enable the / slash command menu. |
| autoFocus | boolean | false | Focus the editor on mount. |
| className | string | undefined | Extra class applied to the editor wrapper. |
| style | React.CSSProperties | undefined | Inline style for the wrapper. |
| minHeight | string | '200px' | Min height of the editing surface. Any CSS length. |
| highlightTheme | 'github' \| 'github-dark' \| 'atom-one-dark' \| 'atom-one-light' \| 'none' | 'github' | highlight.js theme for in-editor code highlighting. |
import type { MarkdownEditorProps } from '@gotocva/react-markdown';Slash command menu
Press / at the start of a line (or anywhere in a paragraph) to open a Notion-style block picker. Type to filter, arrow-key to navigate, Enter to confirm, Esc to dismiss.
| Item | Shortcut to type | Result |
| --------------- | ---------------- | ------ |
| Text | text | Plain paragraph |
| Heading 1 / 2 / 3 | h1 / h2 / h3 | Section headings |
| Bullet list | bullet, ul | Unordered list |
| Numbered list | numbered, ol | Ordered list |
| To-do list | todo, task | List with checkboxes |
| Quote | quote | Block quote |
| Code block | code | Fenced code — opens a language picker (see below) |
| Table | table | 4-column table with an auto-numbered S.No column |
| Divider | divider, hr | Horizontal rule |
| Image | image | Insert an image by URL |
Markdown input shortcuts
These trigger as you type — no slash menu required:
| Type this | Becomes |
| ------------------------ | -------------------- |
| # … ### | Heading 1 / 2 / 3 |
| - / * | Bullet list item |
| 1. | Numbered list item |
| [ ] | Task list item |
| > | Block quote |
| ``` then Enter | Code block (paste a language right after the fence) |
| --- | Horizontal rule |
| **bold** | bold |
| *italic* / _italic_ | italic |
| ~~strike~~ | ~~strike~~ |
| `code` | code |
Inline bubble menu
Select any text and a small toolbar appears above the selection:
- B — bold (⌘ / Ctrl+B)
- I — italic (⌘ / Ctrl+I)
- ~~S~~ — strikethrough
</>— inline code (⌘ / Ctrl+E)- ↗ — link (opens a
prompt()for the URL)
The bubble menu hides when an image or horizontal-rule node is selected, and yields to the table toolbar (below) when the caret is in a table without an active text selection.
Code blocks & language picker
When you pick Code block from the slash menu, a searchable language picker opens next to the cursor:
- Type to filter (
ts,python,rust, …) - ↑ / ↓ to navigate
- Enter to confirm — Esc inserts a plain block
What you get:
- Live in-editor highlighting via
lowlight(preloaded with thecommonhighlight.js language set — ~30 languages). - A small language label in the top-right corner of the code block
(Notion-style, driven by a
data-languageattribute). - GFM fence in the Markdown output (e.g.
```typescript), so re-rendering through<MarkdownRenderer />re-highlights the same block usingrehype-highlight.
Supported language IDs include: typescript, javascript, python,
java, c, cpp, csharp, go, rust, ruby, php, swift,
kotlin, bash, shell, sql, json, yaml, xml, css, scss,
less, markdown, graphql, diff, r, lua, perl, objectivec,
makefile, plaintext.
To use a custom highlight theme, set highlightTheme="none" on the
editor (and/or renderer) and import a stylesheet of your own:
import 'highlight.js/styles/atom-one-dark.css';Tables (with auto S.No)
Type /table to insert a 4-column table whose first column is
S.No, pre-filled with 1, 2, 3 ….
Floating toolbar (appears above the table when the caret is inside it):
| Button | Action |
| ----------- | ----------------------------------- |
| ↑+ / ↓+ | Add row above / below the current cell |
| ↕− | Delete the current row |
| ←+ / →+ | Add column before / after the current cell |
| ↔− | Delete the current column |
| Hdr | Toggle header row |
| ✕ | Delete the table |
Other niceties:
- Resizable columns — drag the right edge of any column.
- Keyboard navigation — Tab / Shift+Tab move between cells. Tab in the last cell adds a new row.
- GFM round-trip — emitted as a standard
| ... |markdown table, so it renders identically through<MarkdownRenderer />and on GitHub.
Auto-numbering rules
Any table whose first header cell reads S.No, Sno, Serial,
Serial No, Serial Number, or # (case- and whitespace-insensitive)
is treated as a serial column. The auto-fill plugin:
- Re-numbers data rows on every row add / delete / reorder.
- Skips cells with custom text — only cells that are empty or already
a plain integer (
/^\d+$/) are rewritten. So"1.","first", or formatted serials are left alone. - Skips cells with non-default shape — cells with lists, multiple paragraphs, or block content are untouched.
- Opt out by renaming or clearing the
S.Noheader.
Markdown ↔ HTML utilities
The conversion functions used internally by the editor are exported too:
import { markdownToHtml, htmlToMarkdown } from '@gotocva/react-markdown';
const html = markdownToHtml('# Hello **world**');
const md = htmlToMarkdown(html); // round-trips cleanly, including GFM tableshtmlToMarkdown is hardened against the TipTap-specific HTML quirks that
otherwise break GFM table conversion:
- Strips
<colgroup>children from<table>(otherwise turndown'sisFirstTbodycheck fails and the table falls through as raw HTML). - Unwraps
<p>elements that are direct children of<th>/<td>so cell text stays on one line.
Styling and theming
Everything is scoped under .gotocva-markdown (renderer) and
.gotocva-md-editor (editor) so importing the stylesheet cannot leak
into the host application.
CSS variables (renderer)
.gotocva-markdown {
--gmd-fg: #111827;
--gmd-link: #6366f1;
--gmd-border: #e5e7eb;
--gmd-bg-subtle: #f9fafb;
--gmd-bg-code: #f3f4f6;
--gmd-radius: 8px;
--gmd-font-sans: 'Inter', system-ui, sans-serif;
--gmd-font-mono: 'JetBrains Mono', monospace;
}The library auto-switches a dark palette through
@media (prefers-color-scheme: dark). Force a scheme by setting
data-theme="light" on the wrapping element.
Custom element renderers (renderer)
import { MarkdownRenderer, type Components } from '@gotocva/react-markdown';
const components: Components = {
h1: ({ children }) => <h1 className="my-fancy-h1">{children}</h1>,
a: ({ href, children }) => (
<a href={href} className="my-link" target="_blank" rel="noopener noreferrer">
{children}
</a>
),
};
<MarkdownRenderer content={md} components={components} />;Skipping the default stylesheet
Don't import @gotocva/react-markdown/styles.css and bring your own CSS
for the .gotocva-markdown / .gotocva-md-editor scopes.
GFM support
GFM is enabled by default in both the renderer (via remark-gfm) and the
editor's Markdown I/O (via marked's GFM mode + turndown-plugin-gfm),
so all of the following round-trip cleanly:
- Tables (
| a | b |) - Strikethrough (
~~text~~) - Task lists (
- [x] done,- [ ] todo) - Autolinks (bare URLs like
https://example.com) - Fenced code with language hints
Disable in the renderer with gfm={false} for strict CommonMark
semantics. The editor always supports GFM.
SSR (Next.js)
The components are client-only — they touch document to inject the
highlight.js theme stylesheet, and TipTap depends on DOM APIs. In Next.js
App Router:
// app/note/editor.tsx
'use client';
import { MarkdownEditor } from '@gotocva/react-markdown';
// …In Pages Router or anywhere you need a hard SSR boundary:
import dynamic from 'next/dynamic';
const MarkdownEditor = dynamic(
() => import('@gotocva/react-markdown').then((m) => m.MarkdownEditor),
{ ssr: false },
);The renderer also injects its highlight.js theme through a client effect,
so an SSR-rendered page that uses only <MarkdownRenderer /> works
without the 'use client' directive — the theme stylesheet appears once
the page hydrates.
Local development
git clone https://github.com/gotocva/react-markdown
cd react-markdown
npm install
# Launch the demo / playground (Vite dev server)
npm run dev
# → http://localhost:5173
# Type-check
npm run lint
# Production build of the library (dist/)
npm run build
# Production build of the demo (demo-dist/)
npm run build:demo
# Preview the production demo build
npm run previewProject layout
@gotocva/react-markdown/
├── src/ # Library source (published)
│ ├── index.ts # Public exports
│ ├── components/
│ │ ├── MarkdownRenderer.tsx
│ │ └── MarkdownEditor/
│ │ ├── MarkdownEditor.tsx
│ │ ├── EditorBubbleMenu.tsx
│ │ ├── TableMenu.tsx
│ │ ├── slash-command/
│ │ ├── language-picker/
│ │ └── serial-number/
│ ├── styles/
│ │ ├── markdown.css
│ │ └── editor.css
│ └── utils/
│ ├── markdown.ts # markdown ↔ html
│ └── useHighlightTheme.ts
├── demo/ # Playground app (not published)
├── index.html
├── vite.config.ts
├── tsconfig.json / .build.json / .node.json
└── package.jsonPublishing
prepublishOnly runs the production build automatically, so a typical
release is just:
npm version patch | minor | major
npm publishpublishConfig.access = "public" is set in package.json so the scoped
package publishes publicly without needing --access public.
Verify what will go to the registry before publishing:
npm pack --dry-runOnly dist/, README.md, and LICENSE are packaged (controlled by
the files field).
Troubleshooting
Nothing renders. Pass either content or src to MarkdownRenderer,
or initialMarkdown / initialHtml to MarkdownEditor.
My styles look unstyled. Import the stylesheet once at the top of your app:
import '@gotocva/react-markdown/styles.css';Syntax highlighting doesn't appear. Confirm syntaxHighlight isn't
false, and highlightTheme isn't 'none'. The theme stylesheet is
fetched from https://cdn.jsdelivr.net/npm/highlight.js. If you're
offline or behind a strict CSP, set highlightTheme="none" and import a
highlight.js theme locally.
Raw HTML in my Markdown is not rendered. For security, raw HTML is
stripped from the renderer by default. Pass allowHtml if (and only if)
the source is trusted.
A table shows up as code in the live preview. Already fixed — but
worth knowing why: TipTap emits <colgroup> and wraps cell text in
<p>, both of which break turndown's GFM table rule. htmlToMarkdown
pre-processes the HTML to remove <colgroup> and unwrap cell paragraphs.
TypeScript can't find the types. Make sure you imported from
@gotocva/react-markdown (not a deep path) and that
"moduleResolution": "bundler" (or "node16" / "nodenext") is set in
your tsconfig.json.
SSR errors about document not defined. See the SSR section.
License
MIT © gotocva
Powered by the unified / remark / rehype and ProseMirror / TipTap ecosystems.
