Note editor
StableA note’s title, a Markdown block body, tags, word count and an honest save status; it saves 800 ms after typing stops.
NoteEditor edits one note you own: a title, a body of blocks, and a footer with tags, counts and the save status. Every block is a real <textarea>, so the browser keeps caret, spellcheck, IME and undo working, and screen readers hear editable text. Typing “# ”, “- ”, “1. ”, “[] ” or “> ” at the start of a line turns it into a heading, list, checklist or quote. The body is stored as Markdown. BlockEditor, TagPicker, TagChips and MarkdownHint are its parts.
One note
import { useEffect, useState } from 'react'import type { Note } from '@rchursin/product/data/sample'import { NoteEditor } from '@rchursin/product/notes'import { SampleBadge } from '@rchursin/product/shell/layout'
const EDITED = new Date(2026, 9, 13, 9, 41).getTime()
const sample: Note = { id: 'release', title: 'Release checklist', body: 'Ship the skill cleanup on Thursday.\n\n- [x] Changelog entry\n- [ ] Rerun the scan after merging skills\n- [ ] Post the before / after cost\n\n> Every description loads into every turn.', pinned: false, tags: ['release', 'skills'], updatedAt: EDITED,}
/** * The editor on one note you own. Type and it saves 800 ms after you stop (the status in the * footer says so). Try "- " or "[] " at the start of a line, Ctrl+Enter (⌘↵) to tick an item. */Usage
import { BlockEditor, MarkdownHint, NoteEditor, TagChips, TagPicker } from '@rchursin/product/notes'<NoteEditor note={note} now={Date.now()} allTags={['release', 'skills']} onSave={(id, { title, body }) => saveNote(id, title, body)} onUpdate={(id, patch) => updateNote(id, patch)} checkedToBottom={false} onCheckedToBottomChange={setCheckedToBottom}/>Examples
The block editor on its own
Every block kind, and the Markdown it stores, updated as you type.
import { useState } from 'react'import { BlockEditor, serializeBlocks, type Block, type BlockKind } from '@rchursin/product/notes'
/** Fixed ids, so the prerendered page and the browser agree (makeBlock() would mint new ones) */const block = (id: string, kind: BlockKind, text: string, extra: Partial<Block> = {}): Block => ({ id, kind, text, indent: 0, checked: false, ...extra })
const start: Block[] = [ block('b1', 'h2', 'Skill cleanup'), block('b2', 'p', 'Five skills do 90% of the work. **Bold** stays as you type it.'), block('b3', 'todo', 'Export runs for the last 30 days', { checked: true }), block('b4', 'todo', 'Merge openapi-writer into api-docs'), block('b5', 'bullet', 'Cut skills with no runs in 45 days'), block('b6', 'bullet', 'Note each cut in the changelog', { indent: 1 }), block('b7', 'quote', 'Every description loads into every turn.'),]
/** * BlockEditor on its own: you hold the blocks, it draws one textarea per block. What it stores is * plain Markdown (serializeBlocks), shown live on the right. */export default function BlockEditorBlocks() { const [blocks, setBlocks] = useState(start) return ( <div className="grid w-full grid-cols-1 gap-8 md:grid-cols-2"> <BlockEditor blocks={blocks} onChange={setBlocks} label="Example note body" size="sm" className="min-w-0 pl-4" /> <figure className="flex min-w-0 flex-col gap-2"> <figcaption className="font-mono text-[11px] tracking-[0.12em] text-fg-3 uppercase">Saved as Markdown</figcaption> <pre className="rounded-xl bg-fg/[0.04] p-4 font-mono text-xs/5 break-words whitespace-pre-wrap text-fg-2">{serializeBlocks(blocks)}</pre> </figure> </div> )}Tags and the Markdown hint
The footer pieces: tag chips, the tag picker (find or create) and the shortcuts popover.
import { useState } from 'react'import { MarkdownHint, TagChips, TagPicker } from '@rchursin/product/notes'
/** * The editor's footer pieces on their own. The picker finds or creates a tag (typed text becomes * lowercase-with-dashes); the chips remove one; the Markdown button lists what the editor understands. */export default function NoteEditorTags() { const [tags, setTags] = useState(['skills', 'cost']) return ( <div className="flex flex-col items-center gap-5"> <div className="flex flex-wrap items-center justify-center gap-2"> <TagChips tags={tags} size="sm" onRemove={(tag) => setTags((all) => all.filter((t) => t !== tag))} /> <TagPicker tags={tags} allTags={['cost', 'prompts', 'release', 'skills']} onChange={setTags} /> </div> {/* A <div>, not a <p>: the hint's popover panel renders next to its button */} <div className="flex items-center gap-2 text-xs text-fg-3"> Formatting help <MarkdownHint /> </div> </div> )}API
NoteEditor props
| Prop | Type | Default | Description |
|---|---|---|---|
noterequired | Note | – | { id, title, body (Markdown), pinned, tags, updatedAt }. Changes made elsewhere are taken in, unless there are unsaved edits here. |
onSaverequired | (id: string, patch: Pick<Note, 'title' | 'body'>) => void | – | Title and body, 800 ms after typing stops, and at once when the editor closes or the tab is hidden. |
onUpdaterequired | (id: string, patch: Partial<Pick<Note, 'pinned' | 'tags'>>) => void | – | Pin and tags, applied at once. |
allTagsrequired | string[] | – | Every tag in use, offered in the tag picker. |
checkedToBottomrequired | boolean | – | Keep checked checklist items below the unchecked ones. |
onCheckedToBottomChangerequired | (on: boolean) => void | – | The “Move checked items to the bottom” item in the note’s menu. |
nowrequired | number | – | The clock for “Edited today at 9:41 AM” and “Saved · 2 min ago”. Update it every 30 seconds or so. |
onDelete | (id: string) => void | – | Adds “Delete note” to the menu. Unsaved typing is saved first. |
onDuplicate | (id: string) => void | – | Adds “Duplicate” to the menu. |
onBack | () => void | – | Shows a “Notes” back button, for layouts where the list and the editor take turns. It hides inside a container named notes that is 672px or wider, as in NotesApp. |
onEscape | () => void | – | Esc in the title or the body. |
titleRef | Ref<HTMLTextAreaElement> | – | The title field, so the app can focus it (after “New note”). |
BlockEditor props
| Prop | Type | Default | Description |
|---|---|---|---|
blocksrequired | Block[] | – | { id, kind, text, indent, checked }. parseMarkdown(body) makes them; serializeBlocks(blocks) turns them back into Markdown. |
onChangerequired | (blocks: Block[]) => void | – | Every edit, split, merge or conversion. It is never empty: the last block can’t be removed. |
labelrequired | string | – | Accessible name of the body, e.g. “Note body”. |
checkedToBottom | boolean | false | Checking an item moves it below the unchecked ones (and says so). |
onExitTop | () => void | – | ↑ on the first line of the first block, e.g. back to the title. |
onEscape | () => void | – | Esc anywhere in the body. |
placeholder | string | – | Shown while the body is empty. Defaults to a hint about #, - and []. |
size | 'md' | 'sm' | 'md' | Type scale: 15px body for a page, 13px for a panel. |
className | string | – | Classes on the group. |
ref | Ref<BlockEditorHandle> | – | focus("start" | "end") puts the caret in the first or last block. |
TagPicker props
| Prop | Type | Default | Description |
|---|---|---|---|
tagsrequired | string[] | – | The note’s tags, without “#”. |
allTagsrequired | string[] | – | Tags to offer. Typed text that matches none can be created; it is lowercased, with spaces turned into dashes. |
onChangerequired | (tags: string[]) => void | – | The new set of tags. |
className | string | – | Classes on the button. |
TagChips props
| Prop | Type | Default | Description |
|---|---|---|---|
tagsrequired | string[] | – | Drawn as “#tag” chips. |
onRemove | (tag: string) => void | – | Adds a remove button to each chip, named “Remove tag skills”. After a removal, focus moves to the next chip’s ×, the previous one’s, or the control after the chips. |
size | 'xs' | 'sm' | 'xs' | Chip height, 20 or 24 pixels. |
Accessibility
Keyboard
| Key | What it does |
|---|---|
| Enter | In the title: move to the body. In a block: split it here; a list continues, and Enter on an empty item ends the list. |
| ShiftEnter | A new line inside the block (not in headings). |
| CtrlEnter | Check or uncheck a checklist item; on any other line, turn it into one. |
| TabShiftTab | Indent or outdent a list item (three levels). When it can’t move, Tab moves focus on as usual. |
| Backspace | At the start of a block: a list item, heading or quote becomes a paragraph; a paragraph joins the block above. |
| Del | At the end of a block: pull the next block up into this one. |
| Up arrowDown arrow | On the first or last line of a block: move to the block above or below. ↑ in the first block goes back to the title. |
| Left arrowRight arrow | At the very start or end of a block: move to the block before or after. |
| Esc | Leave the editor (calls onEscape). |
- The note is an <article> named by its title. The body is a group named by label; each block is a textarea named by its kind, like “Heading 2”, “Numbered item 2” or “Checklist item, checked, level 2”.
- Typing a marker that converts a block is announced (“Checklist item”), and so are indent levels (“Level 2”) and ending a list.
- Checklist boxes are real checkboxes named by the item’s text. Ticking one with ⌘↵ / Ctrl+Enter in the text is announced; with “checked to the bottom” on, so is the move.
- The save status is plain text, not a live region, so it never talks over typing. It moves from “Unsaved changes” to “Saving…” to “Saved · just now” and stays.
- Pin is a toggle button (aria-pressed). The tag button says the current tags (“Tags: release, skills. Add or remove tags”) and opens a searchable list.
- Removing a tag with its × moves focus to the next chip’s ×, else the previous one’s, else the tag button after the chips, so it never falls to the page body.
Guidelines
Do
- Give the editor a parent with a height: it fills it, and the body scrolls inside.
- Pass a clock that ticks (every 30 seconds is enough), so “Saved · 2 min ago” stays true.
- Save the Markdown as it is: **bold** and _italic_ stay literal in the editor and in storage.
Don’t
- Don’t key the editor by anything but the note id; a new key starts a new draft and flushes the old one.
- Don’t show a toast for every autosave; the footer status is enough.
In use
Related
- NotesNotes and reminders in one window: a searchable list beside a block editor, and reminders typed in plain words.
- Markdown blocksTurns a note’s Markdown into flat blocks (headings, lists, checklists, quotes) and back, so notes stay plain Markdown in storage.
- Autosave statusA quiet line under an editor: “Unsaved changes”, “Saving…”, “Saved · 2 min ago”, and what happens next when a save can’t go through.
- Property pickersThe Tasks tool’s pickers for status, priority, assignee, labels and estimate, as a chip, a row or an icon.
- TextareaA multi-line field that grows with what is typed, up to a maximum, then scrolls.
- ChipA small label on a thing, in four shapes (outline, solid, muted, dashed), with an optional icon and remove button.
Source
The component’s own files, exactly as they run on this site.
/** * Note editor: title, block body and a footer with tags, counts and the save status. * * Autosave: a save runs 800 ms after typing stops, and at once when the note closes or the page * is hidden. The status walks "Unsaved changes" → "Saving…" → "Saved · just now" and stays there * (the relative time keeps counting); it never flashes and disappears. It is plain text, not a * live region, so it doesn't talk over typing. */import { useCallback, useEffect, useId, useRef, useState, type Ref } from "react"import { cx } from "../../components/cx"import { Icon } from "../icons/Icon"import { formatTime, relativeDay } from "../lib/dates"import type { Note } from "../data/sample"import { useHydrated } from "../lib/hydration"import { IconButton, Spinner } from "../ui/button"import { Menu } from "../ui/menu"import { Chip } from "../ui/misc"import { PickerButton } from "../ui/picker"import { Popover, usePopover } from "../ui/popover"import { BlockEditor, growTextarea, type BlockEditorHandle } from "./BlockEditor"