Surface your notes without leaving your workflow. Write under date headings like ### March 4, 2026 and browse them by day, week, or month in a dedicated sidebar panel. Or define your own terms - words like Important or Follow Up - and Surface collects every heading that contains them, always visible in the Pinned tab.
Surface has two modes that work independently and can be used together.
Surface scans every markdown file in your vault for headings that match one of the enabled date formats. Everything written below that heading - until the next heading of equal or higher level - is treated as the entry's content and shown when you navigate to that date.
Example note:
# Work Journal
### March 4, 2026
Finished the auth refactor. Opened PR #42.
### March 5, 2026
Code review for Sarah's branch. Standup at 10am.Surface finds both entries and surfaces them when you navigate to those dates.
Define keywords in Settings and Surface will collect any heading in your vault that contains that word, regardless of date. Those entries appear in the Pinned tab, grouped by term.
Example note:
# Work Journal
### March 4, 2026
#### Important
The auth approach we chose has implications for the mobile team - loop them in before the next sprint.
#### Follow Up
Check in with Sarah about the deployment timeline.If you have Important and Follow Up configured as surface terms, both headings appear in the Pinned tab instantly - no searching, no hunting.
- Copy the following files into your vault's plugin directory:
<YourVault>/.obsidian/plugins/obsidian-surface/ main.js manifest.json styles.css - In Obsidian, go to Settings > Community Plugins.
- Turn off Safe Mode if prompted.
- Find Surface in the list and enable it.
Open the Surface panel via:
- The calendar icon in the left ribbon, or
- The command palette:
Surface: Open Surface view
The panel opens in the right sidebar.
| Control | Description |
|---|---|
| Day / Week / Month tabs | Browse date entries by granularity |
| Pinned tab | View all headings matching your surface terms |
| ← → arrows | Navigate back or forward (date modes only) |
Each entry is displayed as a card showing:
- The file it came from
- A preview of the content below the heading
- Expandable rendered markdown on click
- An ↗ jump button to open the file and scroll directly to that heading
Surface ships with six built-in date formats. Toggle them individually in Settings.
| Format | Example |
|---|---|
| Month D, YYYY | March 4th, 2026 |
| YYYY-MM-DD | 2026-03-04 |
| MM/DD/YYYY | 03/04/2026 |
| D Month YYYY | 4th March 2026 |
| Mon D, YYYY | Mar 4th, 2026 |
| D Mon YYYY | 4th Mar 2026 |
All formats support optional ordinal suffixes (1st, 2nd, 3rd, 4th...). The heading level (# count) does not matter - any level is recognized.
Surface terms are plain text keywords. Any heading whose text contains the term (case-insensitive) is collected into the Pinned tab.
Go to Settings > Surface > Surface terms to add terms. Each term has two fields:
| Field | Purpose |
|---|---|
| Label | The group header shown in the Pinned tab (e.g. Important) |
| Term | The text matched against heading content (e.g. Important) |
Label and term can differ - for example, label Action Items matched by term TODO.
Terms are matched as substrings, so follow matches ### Follow Up, ### Follow-up needed, and ### Things to follow.
This section is a primer for anyone new to Obsidian plugin development. Surface is a good reference implementation because it uses the three most common building blocks.
Obsidian is an Electron app, which means plugins run as Node.js/browser JavaScript inside a sandboxed environment. Plugins are loaded from a vault's .obsidian/plugins/<plugin-id>/ folder. Each plugin needs exactly three files to run:
| File | Purpose |
|---|---|
manifest.json |
Tells Obsidian the plugin's ID, name, version, and min app version |
main.js |
The compiled plugin code (a single CommonJS bundle) |
styles.css |
Optional stylesheet, auto-injected into the app when the plugin loads |
The plugin API is exposed via the obsidian module, which Obsidian provides at runtime. You never bundle it - it's listed as an external in the build config.
Every plugin exports a default class that extends Plugin. Obsidian calls two lifecycle hooks:
export default class MyPlugin extends Plugin {
async onload() {
// Called when the plugin is enabled.
// Register views, commands, event listeners, etc. here.
}
onunload() {
// Called when the plugin is disabled or Obsidian closes.
// Clean up anything that won't be garbage collected automatically.
}
}Everything you register inside onload (views, commands, event listeners) is automatically cleaned up by Obsidian when the plugin unloads, as long as you use the provided registration methods rather than raw DOM APIs.
this.app is your entry point to everything. The most useful sub-objects:
| Object | What it gives you |
|---|---|
app.vault |
Read, write, and list files in the vault |
app.workspace |
Open files, manage leaves/panels, get the active editor |
app.metadataCache |
Parsed frontmatter and cached file metadata |
Commands add entries to the command palette (Cmd/Ctrl+P):
this.addCommand({
id: "my-command",
name: "Do something",
callback: () => { /* ... */ },
});Ribbon icons add a clickable icon to the left sidebar:
this.addRibbonIcon("icon-name", "Tooltip text", () => { /* ... */ });Icon names come from Obsidian's built-in Lucide icon set.
A ItemView is a panel that lives in a workspace leaf (a tab slot). You register a view type, then open it by creating a leaf and setting its state:
// Register once in onload:
this.registerView("my-view-type", (leaf) => new MyView(leaf));
// Open it:
const leaf = this.app.workspace.getRightLeaf(false);
await leaf.setViewState({ type: "my-view-type", active: true });Inside the view, this.containerEl.children[1] is the scrollable content area. You build the UI by calling DOM helper methods that Obsidian attaches to every HTMLElement:
const div = container.createDiv("my-css-class");
const btn = div.createEl("button", { text: "Click me" });
btn.onclick = () => { /* ... */ };To render markdown strings into a DOM element, use MarkdownRenderer.render:
await MarkdownRenderer.render(this.app, markdownString, targetEl, sourcePath, component);The component argument (a Component instance) is used for lifecycle tracking - pass this from a view, or create a standalone new Component() and call .load() / .unload() yourself.
const files = this.app.vault.getMarkdownFiles(); // TFile[]
const content = await this.app.vault.cachedRead(file); // fast, uses cache
const content = await this.app.vault.read(file); // always reads diskcachedRead is preferred for display purposes. Use vault.read only when you need guaranteed freshness before writing.
styles.css is auto-loaded. Obsidian exposes a full set of CSS variables for colors, spacing, and typography so your plugin respects the user's theme automatically:
.my-element {
color: var(--text-normal);
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
}Never hardcode colors. The full variable reference is in the Obsidian developer docs.
Obsidian plugins must be compiled to a single CommonJS main.js. The standard setup uses esbuild:
// esbuild.config.mjs
await esbuild.context({
entryPoints: ["src/main.ts"],
bundle: true,
external: ["obsidian", ...builtins], // never bundle the obsidian module
format: "cjs",
outfile: "main.js",
});TypeScript is optional but strongly recommended - the obsidian npm package ships full type definitions.
# Install dependencies
npm install
# Watch mode (with inline source maps)
npm run dev
# Production build
npm run buildBuilt with esbuild. TypeScript source lives in src/.
| File | Purpose |
|---|---|
src/main.ts |
Plugin entry point, vault scanning, entry caching |
src/view.ts |
Sidebar panel UI - date view, pinned view, entry cards |
src/parser.ts |
Date heading parser, term matcher, date comparison utilities |
src/settings.ts |
Settings types, built-in date patterns, settings tab UI |
styles.css |
Panel styles (uses Obsidian CSS variables) |
manifest.json |
Plugin metadata |
