A local-first note editor with a VSCode-style explorer and Notepad++-style line editing. Everything runs in the browser, and nothing is ever uploaded.
There is no backend, no account, no sync, and no telemetry. The app is a static
bundle. Your notes live in this browser's IndexedDB and, if you want them
somewhere sturdier, in a .json file on your own disk that the app keeps up to
date as you type.
Organising
- Groups and documents in a drag-and-drop tree; each group can have its own icon and colour, picked from the right-click menu
- Groups and loose notes share one order, so a group can sit between two notes
- Right-click menus for rename, duplicate, move between groups, and delete
- Double-click a group to maximise it; the state is saved with your notes
- Tabs with VSCode preview behaviour: browsing the tree reuses a single preview tab, while a note you create keeps its own. Pinning is always deliberate - nothing arrives pinned. Pinned tabs group to the left.
- Double-click renames, in the tree and on a tab alike
Editing
- Multi-cursor and column selection, with a setting that matches Notepad++
(
Ctrl+Clickfor cursors,Alt+dragfor a column) or VSCode (Alt+Click) - Duplicate, delete, and move lines; add text to the start or end of every selected line at once
- Markdown syntax highlighting, with fenced code blocks coloured per language
- A formatting toolbar, and a live preview pane
- Editor text size and interface size scale independently, with steppers and keyboard shortcuts for each
- Find and replace with regex, case, and whole-word toggles, plus a search across every document including the ones that are closed
Owning your data
- Every edit is saved to this browser 600ms after you stop typing, so imported notes are safe immediately
- Bind to a real
.jsonfile and it is rewritten after a five second lull - Detects when another program edits that file and asks what to do about it
- Export and import plain, readable JSON at any time
npm install
npm run dev # http://localhost:5173| Script | What it does |
|---|---|
npm run dev |
Development server with hot reload |
npm run build |
Typecheck, then build the static site into dist/ |
npm run preview |
Serve the built dist/ locally |
npm test |
Unit tests for the parsing, search, and text helpers |
npm run lint |
ESLint |
npm run typecheck |
TypeScript, no emit |
| Feature | Chrome / Edge | Firefox | Safari |
|---|---|---|---|
| Editing, storage, search | Yes | Yes | Yes |
| Bind to a file on disk | Yes | No | No |
| Export and import JSON | Yes | Yes | Yes |
Binding uses the File System Access API, which only Chrome and Edge implement today. Elsewhere the app says so plainly and falls back to export and import rather than pretending the binding worked.
netlify.toml is ready as-is: build npm run build, publish dist. Nothing
else is needed, because there is nothing to run on a server. Any static host
works; the Netlify file simply also sets the security headers described below.
NoteSilo installs as an app: it ships a web manifest with proper PNG icons (regular and maskable), so Install app in Chrome or Edge gives it a real icon and its own window instead of a browser-generated monogram.
| Location | Written when | Survives |
|---|---|---|
| IndexedDB | 600ms after you stop typing | Reloads and browser restarts |
Bound .json file |
5s after you stop typing | Anything - it is your file |
localStorage |
When you change a setting | Reloads |
| Downloads folder | File > Export as JSON | Anything |
Clearing your browser data deletes notes that only live in IndexedDB. Nothing is uploaded, which also means nobody else has a copy. Use File > Bind to file so a real file on disk is always current, or File > Export as JSON for a one-off backup.
| Documents | Editing | ||
|---|---|---|---|
Alt+N |
New document | Ctrl+D |
Duplicate line |
Ctrl+O |
Open a notes file | Alt+Shift+K |
Delete line |
Ctrl+S |
Save now | Alt+Up / Alt+Down |
Move line |
Alt+W |
Close tab | Alt+Click |
Add another cursor |
Alt+PageDown |
Next tab | Ctrl+Alt+Up/Down |
Add cursor above / below |
Alt+PageUp |
Previous tab | Ctrl+G |
Go to line |
Ctrl+B |
Toggle sidebar | Ctrl+Alt+H |
Add text to line start |
Alt+P |
Markdown preview | Ctrl+Shift+Alt+H |
Add text to line end |
Ctrl+, |
Settings | Ctrl+Shift+L |
Cursor at every match |
Ctrl+Shift+H |
Regex panel | Shift+Alt+I |
Cursor at each line end |
Alt+Shift+T |
Toggle theme | Ctrl+/ |
Toggle line comment |
Find is Ctrl+F, find and replace is Ctrl+H, and Ctrl+Shift+F searches every
document including closed ones. In the search panel Enter and Shift+Enter
step through matches, and tabs containing a match are underlined. Ctrl+Shift+H
opens the regex find and replace panel described below.
These are two separate controls, because wanting bigger prose to write in is not the same as wanting bigger menus.
| Control | Range | Steppers in | Shortcut |
|---|---|---|---|
| Editor text | 10 - 24 px | Settings | Alt+= / Alt+- |
| Interface | 80 - 160 % | Settings | Alt+Shift+= / - |
Alt+0 resets the editor text and Alt+Shift+0 resets the interface. The status
bar shows both as 14px / 100%; clicking it opens Settings.
Interface size scales the menu bar, tab strip, sidebar, status bar, icons and hit
targets together, from a single --ui-scale custom property. It deliberately
does not touch the editor, which follows the text size instead.
The layout is built to survive browser zoom as well. The sidebar is clamped against the viewport rather than fixed at 250px, the toolbar and menu bar scroll rather than clip, status bar counts drop out narrowest-first, and below 720px the sidebar folds to its icon rail on its own.
Alt+Click drops an extra cursor, and Ctrl+Alt+Up / Ctrl+Alt+Down stack them
above or below. For a rectangular block the default is Shift+Alt+drag.
Coming from Notepad++, open settings and set Multiple cursors to Ctrl+Click.
That swaps the pair over, so Alt+drag selects a column exactly as it does
there. Help > Keyboard shortcuts always lists whichever pair is currently active,
so the panel never shows you a key that does nothing.
The Edit menu also has a Column selection mode toggle, which turns every ordinary drag into a block selection until you switch it back off.
Stacking cursors down a ragged block is where most editors get annoying: you put
the cursor at the end of a long line, press Ctrl+Alt+Down, and the new cursor
lands in the middle of the shorter line below because it kept the column.
NoteSilo does not do that. When the cursor is already at the end of its line,
Ctrl+Alt+Down and Ctrl+Alt+Up put each new cursor at the end of its own line,
however ragged the block is. Anywhere else in a line the usual keep-the-column
behaviour applies, so nothing you already rely on changes.
Two more ways to get the same result:
- Select the lines and press
Shift+Alt+Ifor a cursor at the end of every one. - With cursors already placed anywhere, press
End. Each one jumps to the end of its own line.
And if you only want to append the same text to a set of lines, you do not need
cursors at all: select them and press Ctrl+Shift+Alt+H, which appends to every
selected line in a single undo step. Ctrl+Alt+H does the same at line starts.
Browsers act on some Ctrl combinations before a web page ever receives the
event, and no amount of preventDefault gets them back: Ctrl+N opens a
window, Ctrl+W closes the tab, Ctrl+T opens one, Ctrl+Tab and
Ctrl+Shift+Tab switch browser tabs, Ctrl+Shift+T reopens a closed one, and
Firefox takes Ctrl+Shift+K for its developer console. Every command that would
have used one of those is on Alt instead.
The Ctrl shortcuts that remain - S, O, F, H, G, D, B, ,, / -
are ones a page can reliably intercept, so they do what you expect and the
browser's own action never fires.
Bindings are matched on physical key position, so they keep working on layouts where holding Alt changes which character a key produces.
NoteSilo has a Regex panel in the sidebar - open it with Ctrl+Shift+H, the
.* button in the sidebar header, or Edit > Regex find and replace. It holds a
pattern field, a replacement field, a live match count across every document, a
syntax reference, and ready-made patterns you can click to load.
Replacement runs either on the current document or, behind a confirmation that
shows the exact count, on every document at once - including the ones that are
closed. The editor's own widget (Ctrl+F, Ctrl+H) still handles single-document
work.
| Where | Open with | Turn regex on |
|---|---|---|
| Find in this document | Ctrl+F |
the .* button, or Alt+R |
| Find and replace | Ctrl+H |
the same .* button |
| Search every document | Ctrl+Shift+F |
the .* button in the panel |
| Regex panel | Ctrl+Shift+H |
always on |
The flavour is JavaScript's own RegExp, searching line by line, so ^ and $
anchor to the start and end of a line rather than the whole document.
In the replacement, $1 and $2 refer to capture groups and $& to the whole
match.
| Goal | Find | Replace |
|---|---|---|
| Strip trailing whitespace | \s+$ |
(empty) |
| Collapse runs of blank lines | \n{3,} |
\n\n |
| Unwrap markdown links to their text | \[([^\]]+)\]\([^)]+\) |
$1 |
Turn Last, First into First Last |
(\w+), (\w+) |
$2 $1 |
| Make bare URLs into autolinks | (https?://\S+) |
<$1> |
| Renumber a list to all ones | ^\d+\. |
1. |
Each of these is in the panel's Ready-made patterns list: click to load it into the fields, check the match count, then replace.
Ctrl+Shift+L puts a cursor on every match of the current selection, which is
often quicker than a replace when the change is small.
- The three toggles combine. Whole word wraps your pattern as
\b(?:...)\b, so it stays correct even when the pattern contains alternation. - An incomplete pattern is not an error. While you type
([a-z, the search reports no matches and the panel says the expression is not valid yet. Nothing throws, and nothing is replaced. - A pattern that can match nothing, such as
x*, will not hang the search - the matcher steps past zero-length matches instead of looping on them. - In a replacement,
\nand\tbecome a real line break and tab. Without that there would be no way to type them into a single-line field. - Outside regex mode a typed
$stays literal, so replacing a price does not quietly become a capture-group reference. - Replacing across every document asks first, and tells you how many matches in how many documents it is about to change.
The saved JSON is meant to be readable and editable by hand, and by other tools.
Anything the app reads is repaired rather than trusted: ids that no longer point
at a document are dropped from groups and tabs, out-of-range settings are
clamped, and a document that nothing mentions is recovered into the root
instead of being lost. See src/utils/validators.ts.
Version 1 files, which kept a separate rootDocs list, open unchanged: the root
order is rebuilt as "every group, then the loose notes", which is exactly how
version 1 drew them.
React 19, TypeScript, Vite, Zustand for state, and Monaco for the editor.
src/
components/ Sidebar, TabBar, Editor, StatusBar, MenuBar, dialogs
hooks/ autosave, theme, file watching, global shortcuts
store/ appStore.ts - one Zustand store for documents and UI state
utils/ storage, file handling, markdown, search, validation
monaco-setup.ts Monaco wiring, keybinding rules, and the two editor themes
tests/ unit tests for the pure logic
A few decisions worth knowing about:
Monaco is bundled, never fetched. @monaco-editor/react downloads Monaco
from a CDN by default, which would be an outbound request on every page load.
src/monaco-setup.ts hands the loader the local npm copy instead. The CDN URL
still appears as a dead string inside the bundle; the loader resolves with the
injected instance and returns before it could ever be used.
Monaco's language services are stripped. The TypeScript, CSS, HTML, and JSON
services ship multi-megabyte workers for IntelliSense on languages this app
never sets on a model. vite.config.ts aliases them away, which removes about
9MB from the build. Fenced code blocks are still highlighted, because that comes
from the Monarch grammars, which are kept.
Find and replace is Monaco's own widget. It already does regex, case, whole word, match counts, next and previous, and replace-all. The custom search panel covers the part Monaco cannot: looking inside documents that are not open.
Markdown is sanitised twice. markdown-it runs with html: false, so raw HTML
in a note is escaped rather than parsed, and the result then goes through
DOMPurify. Code is only ever wrapped in span elements; nothing is executed.
Deleting a group never deletes notes. Its documents move out to the root, and the confirmation dialog says so.
You do not have to take any of this on trust:
- Open DevTools, go to the Network tab, and use the app. After the initial load of the page's own files, it stays empty - including while typing, saving, and searching.
- Turn off your network connection entirely. Everything keeps working.
- Search the built output yourself. Run
npm run build, then grepdist/assets/forXMLHttpRequest,sendBeacon, orWebSocket: there are no matches at all.fetch(matches three times, and none of them reach another origin - one is Vite's modulepreload polyfill requesting the app's own chunks, and the other two are a method that happens to be namedfetchon Monaco's inline-completion source, which is not the network API and has no providers registered. - In production the
Content-Security-Policyinnetlify.tomlenforces it regardless of the code:connect-src 'self'means the browser itself refuses to let the page send anything to another origin.
- Binding to a file needs the File System Access API, so Chrome or Edge. Other browsers get export and import instead.
- External-change detection polls the bound file every 3 seconds while the tab is visible, because the API offers no change notification.
- The editor chunk is ~4.3MB (~1.1MB gzipped). That is Monaco, and it is cached after the first visit.
main.htmlin the repo root is the original single-file prototype. It is left untouched and excluded from the build, lint, and formatting.
No license has been chosen yet. Add a LICENSE file before publishing this
anywhere public, since without one nobody else has permission to use it.
{ "version": 2, "theme": "dark", // settings live at the top level "fontSize": 14, "fontFamily": "Monaco", "lineHeight": 1.5, "tabSize": 2, "wordWrap": true, "minimap": false, "sidebarCollapsed": false, "maximizedGroupId": null, "activeDocId": "doc-1", "groups": [ { "id": "group-1", "name": "Work", "collapsed": false, "color": "#3498db", "docs": ["doc-2"] }, ], "rootOrder": [ // the top level, interleaved { "kind": "doc", "id": "doc-1" }, { "kind": "group", "id": "group-1" }, ], "openTabs": [{ "docId": "doc-1", "pinned": true }], "docs": { "doc-1": { "id": "doc-1", "name": "README", "type": "markdown", "content": "# Hello", "createdAt": "2026-08-30T10:00:00.000Z", "updatedAt": "2026-08-30T14:30:00.000Z", }, }, }