This file has a frontmatter header so Claude Code can load it as a skill. Other tools ignore it.
name: components-as-files
description: Write Obsidian UI components as single files against the components-as-files contract 0.2 (a default-exported function that receives ctx). Use when asked to build a dashboard widget, chart, tracker, visualiser or any small UI for an Obsidian vault.Writing a component
You are writing one component: one file that draws one piece of UI into a box and cleans up after itself. The full contract is in COMPONENTS.md; this file is everything you need to follow it.
Do not (read this first)
- Do not use
this.container,dv.container,document.bodyordocument.querySelector. Draw only insidectx.host. - Do not use
dv,dv.pages,appor any Dataview or Obsidian API. Usectx.queryandctx.navigate. - Do not invent APIs. If a
ctxmember is not listed below, it does not exist. There is noctx.onunload,container.onunload,app.embedLoaderorwindow.mermaid. - Do not call
setInterval,setTimeout,requestAnimationFrameoraddEventListeneronwindow/documentdirectly. Usectx.interval,ctx.timeout,ctx.frameandctx.listen. - Do not write colours as hex,
rgb()or names. Use CSS variables from the table below, orctx.palettein canvas. - Do not write
pxlengths in CSS, except1pxborders. Do not usevw,vhor media queries. Useem,%,fr,cqiand@container. Pixels are fine where they are not a design choice: SVG attributes, a canvas's pixel buffer, and positions you just measured (a tooltip placed at the point under the mouse). - Do not store anything on
window, and do not uselocalStorage. Usectx.stateorctx.storage. - Do not use element ids, and do not use bare class names like
.cardor.title. Two copies of a component can share a page, and the app's own styles reach into your box. Use classes prefixed with your component's name:.heatmap-cell,.heatmap-title. - Do not use
innerHTMLwith any data. Build elements withctx.h. - Do not write to the vault while rendering or from a timer.
- Do not use
import. The file must be self-contained. - Do not use placeholder or fake data when the component is about the user's notes. Read real notes with
ctx.query.notes(). - Do not set a fixed width or height on the component, and do not use
height: 100%. Your box's width comes from the host; its height comes from your content. Give a chart or canvas its height withaspect-ratio(and anemminimum if it needs one). - Do not change your box's size inside
ctx.onResize, for example by setting a canvas's CSS height there. That triggersctx.onResizeagain, in a loop. Set sizes in CSS once; inctx.onResize, only redraw. - Do not build a URL for a vault file yourself. For an image, use
ctx.query.url(path).
Output format
Reply with exactly one fenced code block containing the whole file, and nothing else unless you were asked for an explanation. Use js for a module component or html for an html component.
How a component runs
- The host calls your default export once per mount, with a fresh, empty
ctx.host. - The host may tear it down and mount it again at any time: when the note changes, when the layout changes, or when the user switches notes. Every mount is a new call with a new
ctx. - On teardown, everything you registered through
ctxis undone automatically: timers, frame loops, listeners, resize observers and styles. This is why rules 4 and 7 exist. ctx.stateis the only thing that carries over between mounts. Put UI choices there (selected tab, filter).
Template
export const meta = {
name: "Name",
version: "1.0.0",
description: "One sentence.",
contract: "0.2",
needs: ["vault-read"], // declare what you use: vault-read, vault-write, navigate, app
settings: { title: "Name" }, // every setting you read, with a default
};
export default function component(ctx) {
const notes = ctx.query.notes({ folder: "Journal" });
ctx.css(`
.name-root { display: grid; gap: 0.5em; padding: 0.5em; }
.name-title { font-weight: 600; color: var(--text-normal); }
@container (min-width: 30em) { .name-root { grid-template-columns: 1fr 1fr; } }
`);
ctx.host.append(
ctx.h("div", { class: "name-root" },
ctx.h("span", { class: "name-title" }, ctx.settings.title),
ctx.h("span", null, `${notes.length} notes`)));
}
ctx reference (complete)
| Member | Use |
|---|---|
ctx.host |
Your box. Append into it. It is a size container: its width comes from the host, its height from your content. |
ctx.h(tag, props, ...children) |
Create an element. props: class (string or array), style (object), on ({ click: fn }), dataset, aria-*, any attribute. Children: nodes, strings, numbers, arrays; null, false and true are skipped. |
ctx.svg(tag, attrs, ...children) |
Same, for SVG elements. |
ctx.css(text) |
Add CSS. Your rules only apply inside your box. Other styles can still reach in, so prefix every class and @keyframes name with your component name. |
ctx.palette.color(name) |
A resolved colour for canvas: text, muted, faint, accent, bg, bgAlt, border, red, orange, yellow, green, cyan, blue, purple, pink. |
ctx.palette.series(n) |
n chart colours from the theme. |
ctx.palette.ramp(t) |
CSS colour for intensity t in 0..1 (heatmaps). |
ctx.palette.dark |
true in a dark theme. |
ctx.palette.onChange(fn) |
Redraw canvas when the theme changes. |
ctx.interval(fn, ms) / ctx.timeout(fn, ms) |
Timers, cleaned up for you. Each returns a stop() function if you need to cancel early. |
ctx.frame(fn) |
Animation loop: fn(time) every frame; return false to stop. |
ctx.listen(target, type, fn) |
Listener on something outside your host, removed for you. Listeners on your own elements need nothing special. |
ctx.onResize(fn) |
fn({ width, height, em }) when your box changes size, in pixels; em is pixels per em, so Math.floor(width / (2 * em)) is how many 2em cells fit. It also fires once at the start. Never resize your box from here. |
ctx.teardown(fn) |
Anything else to undo. |
ctx.signal |
An AbortSignal for fetch and similar. |
ctx.query.notes({ folder, tag, where }) |
Notes as plain objects (see below). |
ctx.query.text(path) |
await the text of one file: a note, or a data file. For JSON, JSON.parse(await ctx.query.text(path)). |
ctx.query.url(path) |
A URL for a file in the vault, for <img src>. Empty when the file does not exist: check before you set src, because src="" loads the page itself. |
ctx.note |
{ path, frontmatter } of the note you are in, or null. |
ctx.settings |
Your settings (read-only). |
ctx.state |
Object that survives re-mounts of this mount (same file, same note, same key). |
ctx.storage.get(key, fallback) / .set(key, value) |
Small JSON values that persist. |
ctx.navigate(path, { newTab }) |
Open a note. |
A note from ctx.query.notes():
{ path, name, folder, tags /* ["a", "b/c"] */, frontmatter, created /* Date */, modified /* Date */, day /* "2026-03-04" or null */, size }
It is a normal array of normal objects: use .filter, .map, .reduce and .sort directly. Dates are JavaScript Date objects. day is set when the file name starts with a date. Frontmatter values are plain JSON; frontmatter dates are strings, so parse them with new Date(value). Folder paths have no leading or trailing slash, and paths may contain spaces.
Styling
Use these CSS variables. They exist in every Obsidian theme and in every conforming host.
| Purpose | Variables |
|---|---|
| Text | --text-normal, --text-muted, --text-faint, --text-accent, --text-on-accent, --text-error, --text-success, --text-warning |
| Surfaces | --background-primary, --background-primary-alt, --background-secondary, --background-secondary-alt |
| Lines and states | --background-modifier-border, --background-modifier-hover, --background-modifier-active-hover |
| Accent | --interactive-accent, --interactive-accent-hover, --interactive-normal, --interactive-hover |
| Colours | --color-red, --color-orange, --color-yellow, --color-green, --color-cyan, --color-blue, --color-purple, --color-pink |
| Type | --font-interface, --font-text, --font-monospace |
| Radius | --radius-s, --radius-m, --radius-l |
For a tint, mix: color-mix(in srgb, var(--color-green) 20%, transparent). Shadows too: box-shadow: 0 0.2em 0.6em color-mix(in srgb, var(--text-normal) 20%, transparent).
Sizes:
- Use
emfor spacing and type,%/frfor layout, andcqifor type that scales with the box:font-size: clamp(1.5em, 12cqi, 4em). - Change the layout with
@container (min-width: 30em) { … }. The component should look right from18emwide upwards. - Buttons in Obsidian have a default height and shadow. Reset them when you style your own:
height: auto; box-shadow: none;.
Canvas
Give the canvas its size in CSS, once: width: 100%; aspect-ratio: 16 / 9;. In ctx.onResize, set only its pixel buffer (canvas.width = Math.round(width * devicePixelRatio), the same for height) and redraw; never set its CSS size there. Read colours with ctx.palette.color(), and read them again in ctx.palette.onChange, because colours you saved at mount are stale after a theme change. Animate with ctx.frame.
Accessibility minimum
- Clickable things are
<button>elements or haverole,tabindex="0"and an Enter key handler. - Every chart or grid has an
aria-labelthat says what it shows in words. - Anything conveyed by colour alone is also in the text or the label.
- Text contrast comes from the variables above; do not lower it with
opacity.
html components
Use an html component when the code should not have vault access, or when the user pastes a finished HTML page.
- Write a normal HTML fragment with
<style>and<script>. It runs in a sandboxed iframe with no network. - Read settings from
window.cf.settingsand data fromwindow.cf.data.window.cf.onTheme(fn)fires on theme changes. - The same CSS variables are available; use them, so it follows the theme. Ids and
pxare fine here, because every html component has its own document. - Do not load anything from the network: no CDNs, fonts or images by URL. Inline everything.
Before you answer, check
- One fenced block, whole file, no imports.
-
meta.settingshas a default for every setting read. - Every timer, loop and outside listener goes through
ctx. - No hex, no
px(except 1px borders), novw/vh, no ids, no unprefixed class names, nowindowstate. - Works in a dark theme and a light theme without changes.
- Works at
18emwide and at60emwide, with no fixed width or height. - If it were mounted twice on the same page and re-rendered in the middle of an animation, nothing would leak or collide.