A modern, lightweight WYSIWYG rich text editor for React & Next.js
Feature-flagged · Fully typed · Zero runtime dependencies · <400KB
| Feature | Description |
|---|---|
| Visual editor | contenteditable WYSIWYG — no iframe, no Flash |
| Block mode | Drag-and-drop block editor with per-block type selector |
| Text / HTML mode | Raw HTML editing with syntax highlighting |
| HTML & Markdown | onChange fires in whichever format you choose |
| Feature flags | Enable or disable every toolbar button individually |
| Media upload | Wire any S3 / MinIO / custom API — just pass callbacks |
| Plugins | Link checker, tables, image editor, history, YouTube embed, subscript/superscript |
| Fully typed | End-to-end TypeScript with imperative ref handle |
| Zero deps | No runtime dependencies beyond React |
npm install mandoo-editor
# or
yarn add mandoo-editor
# or
pnpm add mandoo-editor
⚠️ Required — add this import wherever you use the editor:
import 'mandoo-editor/styles';Add it in your layout, page, or component — wherever MandooEditor is rendered. Without it the editor has no styling.
"use client";
import MandooEditor from "mandoo-editor";
export default function MyPage() {
return (
<MandooEditor
defaultValue="<p>Start writing...</p>"
onChange={(html) => console.log(html)}
height={400}
/>
);
}| Prop | Type | Default | Description |
|---|---|---|---|
value |
string |
— | Controlled HTML value |
defaultValue |
string |
'' |
Uncontrolled initial HTML value |
onChange |
(value: string) => void |
— | Fires on every change with current value |
outputFormat |
'html' | 'markdown' |
'html' |
Format for onChange and getValue() |
placeholder |
string |
'Start writing…' |
Placeholder shown when empty |
tabs |
TabId[] |
['visual','text','block'] |
Which tabs to display |
defaultTab |
TabId |
'visual' |
Initially active tab |
features |
Features |
all enabled | Granular toolbar feature flags |
plugins |
Plugins |
none | Optional plugin flags |
media |
MediaConfig |
— | File upload / library config |
theme |
'classic' | 'modern' |
'classic' |
Visual theme |
colorScheme |
'light' | 'dark' |
'light' |
Color scheme |
defaultDir |
'rtl' | 'ltr' |
— | Default text direction for the editor |
height |
number |
400 |
Min height of editor content area (px) |
className |
string |
— | Extra CSS class on root element |
apiToken |
string |
— | Token for future paid pro features |
import { useRef } from "react";
import MandooEditor, { MandooEditorHandle } from "mandoo-editor";
const ref = useRef<MandooEditorHandle>(null);
// Methods:
ref.current?.getValue(); // → string (respects outputFormat)
ref.current?.getHTML(); // → raw HTML string
ref.current?.getMarkdown(); // → Markdown string
ref.current?.setValue(html); // set content programmatically
ref.current?.focus(); // focus the editor
ref.current?.clear(); // clear contentMandooEditor outputs HTML or Markdown. There are two ways to use it in a form:
Add a name prop and a hidden <input> is automatically rendered. Works with any form library or native HTML form submission.
// Native HTML form
<form action="/api/save" method="POST">
<MandooEditor name="content" outputFormat="html" />
<button type="submit">Save</button>
</form>
// Next.js Server Action
async function save(formData: FormData) {
'use server';
const content = formData.get('content'); // ← HTML or Markdown
}
<form action={save}>
<MandooEditor name="content" outputFormat="markdown" />
<button type="submit">Save</button>
</form>// useState
const [content, setContent] = useState('');
<MandooEditor onChange={setContent} outputFormat="html" />
// react-hook-form
const { setValue } = useForm();
<MandooEditor onChange={(v) => setValue('content', v)} outputFormat="markdown" />
// Zustand / Redux
<MandooEditor onChange={(v) => dispatch(setContent(v))} />Disable any toolbar button by setting its flag to false:
<MandooEditor
features={{
// Disable specific buttons
strikethrough: false,
align: false,
charMap: false,
help: false,
// All others remain enabled
}}
/>Full list of flags: bold, italic, strikethrough, lists, blockquote, hr, align, link, code, direction, fullscreen, kitchenSink, underline, justify, foreColor, pasteAsText, removeFormat, charMap, indent, undo, help, media, subscript, superscript
The code feature adds a Code button to the toolbar. It has two modes depending on the selection:
| Context | Result |
|---|---|
| Text selected | Wraps in inline <code> |
| No selection / cursor in a block | Converts block to <pre> (code block) |
Click again inside <code> or <pre> |
Removes the formatting |
Both <code> and <pre> share the same visual style — monospace font, subtle background from --me-textarea-bg, and a matching border — so inline and block code look like a family.
// Disable the code button
<MandooEditor features={{ code: false }} />The direction feature adds RTL and LTR toggle buttons to the toolbar. Direction is applied per block — each paragraph or heading can have its own direction independently.
| Action | Result |
|---|---|
| Click RTL | Sets dir="rtl" style="direction:rtl; text-align:right" on the current block |
| Click LTR | Sets dir="ltr" style="direction:ltr; text-align:left" on the current block |
| Click the active button again | Removes direction from the block (toggle off) |
One button is always highlighted: the active block's direction, or defaultDir if set, or LTR by default.
// RTL-first editor (e.g. Persian / Arabic content)
<MandooEditor defaultDir="rtl" />
// Disable the direction buttons entirely
<MandooEditor features={{ direction: false }} />Direction is stored inline in the HTML output so it renders correctly anywhere, without requiring the editor's stylesheet:
<p dir="rtl" style="direction: rtl; text-align: right;">متن فارسی</p>
<p dir="ltr" style="direction: ltr; text-align: left;">English paragraph</p><MandooEditor
plugins={{
linkChecker: true, // Validate URLs when inserting links
spellChecker: true, // Browser-native spell check
tables: true, // Insert & edit tables
imageEditor: true, // Crop/resize images before upload
history: true, // Edit history with restore
youtube: true, // Embed YouTube videos by URL
}}
/>Wire any storage backend — S3, MinIO, Cloudflare R2, or your own API:
<MandooEditor
media={{
accept: "image/*,video/*",
maxSize: 10 * 1024 * 1024, // 10 MB
async onUpload(file) {
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/upload", { method: "POST", body: fd });
return res.json(); // { url: string, name?: string, alt?: string }
},
async onListFiles() {
const res = await fetch("/api/media");
return res.json(); // MediaFile[]
},
}}
/>// app/api/upload/route.ts
import { Client } from "minio"; // npm install minio
import { NextRequest, NextResponse } from "next/server";
const minio = new Client({
endPoint: process.env.MINIO_ENDPOINT!,
useSSL: true,
accessKey: process.env.MINIO_ACCESS_KEY!,
secretKey: process.env.MINIO_SECRET_KEY!,
});
export async function POST(req: NextRequest) {
const form = await req.formData();
const file = form.get("file") as File;
const buf = Buffer.from(await file.arrayBuffer());
const name = `uploads/${Date.now()}-${file.name}`;
await minio.putObject(process.env.MINIO_BUCKET!, name, buf, buf.length, {
"Content-Type": file.type,
});
const url = await minio.presignedGetObject(
process.env.MINIO_BUCKET!,
name,
604800
);
return NextResponse.json({ url, name: file.name });
}// HTML output (default)
<MandooEditor
outputFormat="html"
onChange={(html) => {
// "<p>Hello <strong>world</strong></p>"
console.log(html);
}}
/>
// Markdown output
<MandooEditor
outputFormat="markdown"
onChange={(md) => {
// "Hello **world**"
console.log(md);
}}
/>// Only show Visual and Text tabs (no Block editor)
<MandooEditor tabs={['visual', 'text']} />
// Start on Block tab
<MandooEditor defaultTab="block" />
// Only Block editor
<MandooEditor tabs={['block']} />MandooEditor ships with two visual themes and two color schemes — mix and match any combination:
// Classic theme (default) — dense toolbar, serif content font
<MandooEditor theme="classic" colorScheme="light" />
// Classic dark
<MandooEditor theme="classic" colorScheme="dark" />
// Modern theme — minimal toolbar, rounded corners, sans-serif content font
<MandooEditor theme="modern" colorScheme="light" />
// Modern dark
<MandooEditor theme="modern" colorScheme="dark" />Every color, radius, and font in MandooEditor is driven by CSS custom properties set on the root container. You can override any of them from your own CSS:
/* globals.css or any stylesheet loaded after mandoo-editor/styles */
.mandoo-editor-container {
--me-accent: #e11d48; /* links, active buttons, focus rings */
--me-container-radius: 4px; /* outer border radius */
--me-content-font: 'Vazirmatn', sans-serif; /* content area font */
}You can also scope overrides to a specific theme or color scheme:
/* Only affect the modern theme */
.mandoo-editor-container[data-mandoo-theme="modern"] {
--me-accent: #7c3aed;
--me-toolbar-bg: #fafafa;
}
/* Only affect dark mode */
.mandoo-editor-container[data-mandoo-scheme="dark"] {
--me-bg: #18181b;
--me-border: #27272a;
}| Variable | Controls | Classic light default |
|---|---|---|
--me-bg |
Editor & modal background | #ffffff |
--me-border |
All borders | #dddddd |
--me-color |
UI text | #444444 |
--me-color-strong |
Headings, modal titles | #23282d |
--me-toolbar-bg |
Toolbar row background | #ebebeb |
--me-tools-bg |
Media/tabs bar background | #f1f1f1 |
--me-btn-hover |
Button hover background | #d5d5d5 |
--me-btn-active |
Active/pressed button background | #b8b8b8 |
--me-btn-bg |
Inactive button background | #f3f5f6 |
--me-statusbar-bg |
Status bar background | #ebebeb |
--me-textarea-bg |
HTML textarea background | #f9f9f9 |
--me-modal-bg |
Modal body background | #ffffff |
--me-modal-header-bg |
Modal header background | #f1f1f1 |
--me-accent |
Links, focus rings, active state | #0073aa |
--me-muted |
Placeholder, counts, labels | #888888 |
--me-sep |
Toolbar separators | #cccccc |
--me-content-color |
Content area text | #333333 |
--me-content-font |
Content area font family | Georgia, serif |
--me-btn-size |
Toolbar button width & height | 26px |
--me-btn-radius |
Toolbar button border radius | 2px |
--me-container-radius |
Outer container border radius | 0px |
The following features require an apiToken and will be available in a future paid tier:
- Export to PDF — one-click export via Mandoo cloud API
- Word Import/Export — read and write
.docxfiles - AI Assistant — chat with AI to rewrite, summarise, or extend content
// Reserve your token now — setting it has no effect until pro plugins are released
<MandooEditor apiToken="mk_live_..." />import { mandooFetch, validateToken } from "mandoo-editor";
// Validate a token format
const valid = validateToken("mk_live_abc123...");
// Call Mandoo API (for pro features)
const result = await mandooFetch(
"/export/pdf",
{ method: "POST", body: fd },
{
token: "mk_live_...",
baseUrl: "https://api.mandooeditor.com/v1", // optional override
}
);import type {
MandooEditorProps,
MandooEditorHandle,
Features,
Plugins,
MediaConfig,
MediaFile,
MediaUploadResult,
TabId,
OutputFormat,
TokenConfig,
} from "mandoo-editor";| 🌍 Website | mandooeditor.markrahimi.com |
| 📦 npm | npmjs.com/package/mandoo-editor |
| 🐙 GitHub | github.com/markrahimi/mandoo-editor |
| 🐛 Issues | github.com/markrahimi/mandoo-editor/issues |
| ☕ Support | ko-fi.com/markrahimi |
| 👤 Author | markrahimi.com |
MIT © Mohammad Ali Rahimi