# Obsidian Settings

> Write declarative settings tabs for Obsidian plugins using the 1.13.0+ getSettingDefinitions() API. Use when creating or editing a PluginSettingTab, adding settings controls (toggle, text, number, dropdown, slider, file, folder, color), building settings groups/lists/sub-pages, wiring conditional visibility or validation, migrating from the imperative display() approach, or touching any file under apps/obsidian/src/services/*/setting-tab/. Also use when opening the settings modal from code, deep-linking to a settings sub-page, or revealing/highlighting one setting row. Also use when the user mentions "setting tab", "plugin settings", "getSettingDefinitions", "SettingControl", "SettingDefinitionItem", "app.setting", or "openTabById", even if they don't explicitly ask for this skill.

- Skill: `aidenlx/obsidian-settings` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add aidenlx/obsidian-settings`
- Raw SKILL.md: https://api.skillmd.com/api/skills/aidenlx/obsidian-settings/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: aidenlx (https://skillmd.com/u/aidenlx)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/aidenlx/obsidian-settings

---


# Obsidian Declarative Settings (1.13.0+)

Settings tabs describe their UI as data — an array of definition objects returned from
`getSettingDefinitions()`. Obsidian handles rendering, search indexing, persistence, and validation.

## Type definitions

The canonical source is `packages/obsidian-api/obsidian.d.ts`. Read the relevant types there when
you need precise signatures — the guide below covers usage patterns, not exhaustive type docs.

Quick lookup commands:
- `rg 'SettingControl<' packages/obsidian-api/obsidian.d.ts` — all control types
- `rg 'SettingDefinition' packages/obsidian-api/obsidian.d.ts` — all definition types
- `rg 'class PluginSettingTab' packages/obsidian-api/obsidian.d.ts` — tab class
- `rg 'class SettingPage' packages/obsidian-api/obsidian.d.ts` — imperative sub-page class

## How it works

Override `getSettingDefinitions()` on your `PluginSettingTab` subclass. Each entry in the returned
array is a `SettingDefinitionItem` — one of:

| Shape | What it does |
|---|---|
| `{ name, control: { type, key } }` | Binds one settings key to a UI control. Auto-reads/writes/saves. |
| `{ name, render: (setting, group) => … }` | Full imperative control over one `Setting` row. No auto-save. |
| `{ name, action: (el, index) => … }` | Clickable action row. |
| `{ name }` (no control/render/action) | Static heading or info row. |
| `{ type: 'group', heading, items }` | Visual grouping with a heading. |
| `{ type: 'list', heading, items, onDelete, … }` | User-managed collection (add/delete/reorder). |
| `{ type: 'page', name, items }` or `{ type: 'page', name, page: () => … }` | Navigable sub-page. |

`control`, `render`, and `action` are mutually exclusive on a single definition.

## Control types

Every control reads from and writes to `this.plugin.settings[key]` automatically. Obsidian calls
`saveData()` after each change.

| Type | Stored value | Required fields | Optional fields |
|---|---|---|---|
| `toggle` | `boolean` | `key` | `defaultValue`, `disabled` |
| `text` | `string` | `key` | `placeholder`, `defaultValue`, `validate`, `disabled` |
| `textarea` | `string` | `key` | `placeholder`, `rows`, `defaultValue`, `validate`, `disabled` |
| `number` | `number` | `key` | `min`, `max`, `step`, `placeholder`, `defaultValue`, `validate`, `disabled` |
| `slider` | `number` | `key`, `min`, `max`, `step` | `defaultValue`, `displayFormat`, `disabled` |
| `dropdown` | `string` | `key`, `options` | `defaultValue`, `disabled` |
| `file` | `string` (path) | `key` | `filter`, `placeholder`, `defaultValue`, `validate`, `disabled` |
| `folder` | `string` (path) | `key` | `filter`, `includeRoot`, `placeholder`, `defaultValue`, `validate`, `disabled` |
| `color` | `string` (hex) | `key` | `defaultValue`, `disabled` |

`slider` requires all three of `min`, `max`, `step` (not optional like on `number`).

### Examples

```ts
// Toggle
{ name: 'Enable sync', control: { type: 'toggle', key: 'syncEnabled' } }

// Text with validation
{
  name: 'API key',
  control: {
    type: 'text',
    key: 'apiKey',
    placeholder: 'Enter key…',
    validate: (v) => v.length < 8 ? 'Must be at least 8 characters.' : undefined,
  },
}

// Dropdown with default
{
  name: 'Theme',
  control: {
    type: 'dropdown',
    key: 'theme',
    defaultValue: 'system',
    options: { system: 'System', light: 'Light', dark: 'Dark' },
  },
}

// Number with range
{
  name: 'Max results',
  control: { type: 'number', key: 'maxResults', min: 1, max: 100, defaultValue: 20 },
}

// Slider (min/max/step required)
{
  name: 'Opacity',
  control: { type: 'slider', key: 'opacity', min: 0, max: 100, step: 1 },
}

// Folder picker
{
  name: 'Output folder',
  control: { type: 'folder', key: 'outputDir', includeRoot: true },
}
```

## Conditional visibility and disabling

Two predicates toggle a setting's state without rebuilding the tab:

- `visible` on any definition — hides the row when `false`. Hidden rows are excluded from search.
- `disabled` on a `control` or `action` — disables interaction without hiding.

Both accept `boolean | (() => boolean)`. The function form re-evaluates on every DOM-state refresh.
For `control` definitions, Obsidian refreshes automatically after every change.

```ts
getSettingDefinitions() {
  return [
    { name: 'Advanced mode', control: { type: 'toggle', key: 'advanced' } },
    {
      name: 'Debug level',
      visible: () => this.plugin.settings.advanced,
      control: {
        type: 'dropdown',
        key: 'logLevel',
        defaultValue: 'info',
        options: { info: 'Info', verbose: 'Verbose' },
      },
    },
  ];
}
```

Use `visible` when a setting is irrelevant in the current state. Use `disabled` when it exists but
is locked (prerequisite not met, feature not unlocked).

After mutating state from a `render` callback or other imperative path, call `this.refreshDomState()`
to re-run predicates without a full re-render. For changes that add/remove definitions (not just
toggle visibility), call `this.update()` instead.

## Validation

Every control accepts an optional `validate` callback. Return a non-empty string to reject and show
an inline error. Return `void`/`undefined`/empty string to accept. Async validators work too.

```ts
{
  name: 'Extension',
  control: {
    type: 'text',
    key: 'ext',
    validate: (v) => /\s/.test(v) ? 'No spaces allowed.' : undefined,
  },
}
```

`validate` is a UI gate, not a data invariant. Stored values may already be invalid (from older
plugin versions). Validate again when reading settings in `loadSettings()` if invariants matter.

## Groups

Group related settings under a heading:

```ts
{
  type: 'group',
  heading: 'Appearance',
  items: [
    { name: 'Font size', control: { type: 'number', key: 'fontSize', min: 8, max: 32 } },
    { name: 'Accent color', control: { type: 'color', key: 'accent' } },
  ],
}
```

Groups also accept `search` (renders a filter input in the header — since 1.13.1), `extraButtons`,
`cls`, and `visible`.

Groups cannot nest inside groups. Use sub-pages for deeper hierarchy.

## Lists

For user-managed collections (add/delete/reorder rows), use `type: 'list'`:

```ts
{
  type: 'list',
  heading: 'Watched folders',
  emptyState: 'No folders yet.',
  addItem: {
    name: 'Add folder',
    action: () => this.openAddFolderModal(),
  },
  onReorder: async (oldIndex, newIndex) => {
    let folders = this.plugin.settings.folders;
    let [moved] = folders.splice(oldIndex, 1);
    folders.splice(newIndex, 0, moved);
    await this.plugin.saveData(this.plugin.settings);
  },
  onDelete: async (idx) => {
    this.plugin.settings.folders.splice(idx, 1);
    await this.plugin.saveData(this.plugin.settings);
    this.update();
  },
  items: this.plugin.settings.folders.map((path) => ({
    name: path,
    searchable: false,
  })),
}
```

- `onReorder` adds drag handles. DOM is already reordered — just update your data and save.
- `onDelete` wires both the delete button and the Delete key. Always call `this.update()` after.
- `addItem` renders a `+` button (desktop) / tappable row (mobile). Open a Modal for multi-field input.
- `emptyState` shows when `items` is empty.
- `searchable: false` on items keeps individual rows out of global search.

For action rows inside lists, use `action` and read the live `index` argument (not the outer map index,
which goes stale after reorder):

```ts
items: commands.map((cmd) => ({
  name: cmd.name,
  searchable: false,
  action: (el, index) => this.plugin.doSomething(index),
})),
```

## Sub-pages

Navigable sub-pages for sections with self-contained scope. Use sparingly — only when the parent tab
is too long to scan.

### Declarative (preferred)

```ts
{
  type: 'page',
  name: 'Advanced',
  desc: 'Power-user options.',
  items: [
    { name: 'Debug logging', control: { type: 'toggle', key: 'debug' } },
    { type: 'group', heading: 'Cache', items: [
      { name: 'Size (MB)', control: { type: 'slider', key: 'cacheMb', min: 1, max: 500, step: 1 } },
    ]},
  ],
}
```

### Imperative (when runtime state drives UI)

Subclass `SettingPage` and pass a factory. Controls built in `display()` are invisible to
`getSettingDefinitions()` — no search indexing, no auto-save, no `visible`/`disabled` predicates.

```ts
import { SettingPage, Setting } from 'obsidian';

class StatusPage extends SettingPage {
  constructor(private plugin: MyPlugin) {
    super();
    this.title = 'Status';
  }

  display() {
    this.containerEl.empty();
    new Setting(this.containerEl)
      .setName('Refresh')
      .addButton((btn) => btn.setButtonText('Refresh').onClick(() => this.display()));
  }

  hide() { /* clean up observers/timers */ }
}

// In getSettingDefinitions():
{ type: 'page', name: 'Status', page: () => new StatusPage(this.plugin) }
```

`items` and `page` are mutually exclusive.

Page names must be unique among siblings at the same depth.

Since 1.13.1: `displayValue` shows a value on the page entry, `status: 'warning'` adds an indicator.

## Render callback

Use `render` when a setting needs side effects, derived values, or controls not covered by the
declarative types (moment format, progress bars, custom suggesters, multi-button rows).

```ts
{
  name: 'Date format',
  render: (setting) => {
    setting.addMomentFormat((fmt) => fmt
      .setValue(this.plugin.settings.dateFormat)
      .onChange(async (v) => {
        this.plugin.settings.dateFormat = v;
        await this.plugin.saveData(this.plugin.settings);
      }));
  },
}
```

`render` does not auto-save — always call `saveData()` yourself.

Return a cleanup function from `render` if you create subscriptions that outlive the DOM
(ResizeObserver, setInterval, etc.). Plain DOM listeners attached to elements inside the row are
cleaned up automatically.

## Custom settings storage

By default, `control` definitions read/write `this.plugin.settings[key]` and auto-call `saveData()`.

Override `getControlValue(key)` and `setControlValue(key, value)` to read/write a different store
(Svelte store, reactive proxy, immutable pattern). When overriding `setControlValue`, persist the
value yourself — the automatic `saveData()` call is replaced.

See `references/settings-guide.md` § "Custom settings storage" and § "Advanced: nested settings with
dot-notation keys" for the dot-path recipe.

## Reacting to external changes

If the tab displays state that changes outside the settings UI (vault contents, plugin computation),
call `this.update()` to rebuild from `getSettingDefinitions()`. Wire listeners in the constructor and
register through `plugin.registerEvent()`. Debounce bursty events.

```ts
constructor(app: App, plugin: MyPlugin) {
  super(app, plugin);
  this.plugin = plugin;
  let refresh = debounce(() => this.update(), 200, true);
  plugin.registerEvent(this.app.vault.on('create', refresh));
  plugin.registerEvent(this.app.vault.on('delete', refresh));
}
```

### Gotcha: `update()` skips the row holding focus

The reconciler behind `update()`/`refreshCurrentPage` **deliberately skips clearing and re-rendering
any setting row whose `settingEl` contains `document.activeElement`** — it preserves focus while the
user edits a field. A matched row (stable key) only re-runs its `render`/control build when
`e.setting && !e.settingEl.contains(activeElement)`.

**Symptom:** an `action`/button handler inside a row calls `this.update()`; every *other* row
refreshes but the clicked row stays stuck on its pre-action state, because the clicked button is
`activeElement` and lives in that row.

**Fix:** blur the button before triggering the update, e.g. `btn.extraSettingsEl.blur()` (or
`el.blur()`) before the async work. Modal-driven actions are unaffected — focus is on the modal, not
the row.

## Navigating to settings from code

`app.setting` is the settings modal. It is internal API — declared in
`apps/obsidian/src/typings/obsidian-ex.d.ts`, verified against Obsidian 1.13. Reach for it when a
notice, ribbon action, or command sends the user to a specific place in settings.

There is no setting **id**. Definitions carry `name`, `desc`, `aliases`, and (for controls) a storage
`key` — search and navigation use none of them as an address. A sub-page is addressed by its **page
`name` path**, and a row is addressed by the **definition object itself** (identity, not id).

### Open my plugin tab

```ts
app.setting.open();
app.setting.openTabById(plugin.manifest.id);
```

`open()` is idempotent and synchronous, so this is safe whether or not the modal already shows.
Which window it lands in depends on config — see [Verifying on screen](#verifying-on-screen).
`openTabById` returns the `SettingTab` or `null`, and does not open the modal on its own. Built-in tab
ids in 1.13: `about`, `appearance`, `editor`, `file`, `interface`, `hotkeys`, `keychain`, `plugins`,
`community-plugins`. `sync` and `publish` are core-plugin tabs.

### Open a sub-page

`navigateToSearchResult` is the one call that does tab → descend N sub-pages → reveal. It reads only
`tab`, `pagePath`, and `result.entry.definition`, so a plain object literal drives it — no real search
result needed. It short-circuits when the tab and page stack already match, so repeat calls are cheap.

```ts
function openSettingsPage(app: App, tabId: string, pagePath: string[]): boolean {
  app.setting.open();
  const tab = app.setting.openTabById(tabId);
  if (!tab) return false;
  app.setting.navigateToSearchResult({ tab, pagePath });
  return true;
}

// Two levels deep, outermost page first.
openSettingsPage(app, plugin.manifest.id, [m.settingsPageZotero(), m.settingsPageDatabase()]);
```

`pagePath` holds the exact `name` strings `getSettingDefinitions()` returned. Derive them from the
same message source as the definitions so they stay correct in every locale.

### Reveal one setting row

Two ways. Both scroll the row into view, focus it, and flash it for 750 ms — the same effect as
clicking a settings search result.

**By query** — works for any tab, including built-in ones:

```ts
function revealSettingByQuery(app: App, tabId: string, query: string): boolean {
  app.setting.open();
  const group = app.setting.searchIndex
    .search(query)
    .find((g) => g.tab.id === tabId && g.results.length > 0);
  if (!group) return false;
  app.setting.navigateToSearchResult(group, group.results[0]);
  return true;
}
```

**By definition** — resolve it from the live `tab.settingItems` at reveal time, then navigate and
scroll:

```ts
function locate(
  items: SettingDefinitionItem[],
  name: string,
  path: string[] = [],
): { definition: SettingDefinition; pagePath: string[] } | null {
  for (const item of items) {
    if (!("type" in item)) {
      if (item.name === name) return { definition: item, pagePath: path };
      continue;
    }
    const nested = item.type === "page" ? [...path, item.name] : path;
    const hit = item.items && locate(item.items, name, nested);
    if (hit) return hit;
  }
  return null;
}

function revealSetting(app: App, tabId: string, name: string): boolean {
  app.setting.open();
  const tab = app.setting.openTabById(tabId);
  if (!tab) return false;
  const hit = locate(tab.settingItems, name);
  if (!hit) return false;
  app.setting.navigateToSearchResult({ tab, pagePath: hit.pagePath });
  app.setting.scrollToDefinition(tab, hit.definition);
  return true;
}
```

Resolve the definition fresh on every reveal. `update()` re-runs `getSettingDefinitions()` and
produces new objects, so a cached reference stops matching. `scrollToDefinition` also searches only
the **rendered** tab or sub-page, which is why the `navigateToSearchResult` call comes first.

### Related: the Hotkeys tab, filtered

```ts
app.setting.open();
const tab = app.setting.openTabById("hotkeys") as (SettingTab & { setQuery(q: string): void }) | null;
tab?.setQuery(plugin.manifest.id);
```

## Verifying on screen

Since 1.13.4 `open()` renders the modal in its own Electron window whenever the
`settingsPopoutWindow` config is on, which is the default. A harness that evaluates JS in the main
window and captures it — `/obsidian-debug` drives one — then reports an app with no settings in
it. Put the modal back in the main window for the duration:

```ts
app.vault.setConfig("settingsPopoutWindow", false);
```

That persists to the vault's `.obsidian/app.json`, and the next `open()` honours it with no
reload — `close()` then `open()` re-homes a modal that is already up. The modal then answers
`document.querySelector(".modal.mod-settings")` and lands in a plain window capture. `open()`
stays synchronous in both modes, so the `openTabById` on the next line still finds its tab. The
Community plugins browser reads the same key, so it follows settings into whichever window they use.

To check how settings render in their own window, leave the config on and reach that window from
the main renderer:

```ts
app.setting.isInPopoutWindow(); // false while the modal renders in the main window
const win = app.setting.getPopoutWindow();
win.document.querySelector(".vertical-tab-header");
win.getComputedStyle(el);
win.electronWindow.webContents
  .capturePage()
  .then((img) => require("fs").writeFileSync("<abs>.png", img.toPNG()));
```

That window is a `Modal` popout rather than a `WorkspaceWindow`, so it stays out of
`app.workspace.floatingSplit` and fires no `window-open` event — `app.setting` is the only handle
on it. Its body carries `is-popout-modal` on top of the `is-popout-window` every popout gets.

While it is open the main renderer's `activeWindow` / `activeDocument` globals point at the
settings window, so reach the main window as `window` / `document`.

The reveal flash expires before a capture round-trip finishes, so assert `is-flashing` on the row
in the DOM instead of looking for it in a screenshot.

### Stability

| API | Risk |
|---|---|
| `open()`, `close()`, `openTabById()` | Safe — stable since ~0.9, used by the `app:open-settings` command. Handle the `null` return. |
| `activeTab`, `SettingTab.id` / `.name` | Low — plain fields. Treat as read-only. |
| `navigateToSearchResult()`, `scrollToDefinition()` | Medium — new in 1.13 with one core caller each. Guard with `?.` and keep calls in one helper. |
| `searchIndex.search()` | Medium — internal class returning bare object literals; a plausible refactor target. |
| Anything keyed by a setting id | Nonexistent — no id concept exists. |

There is no `obsidian://settings` URI and no per-setting command. For a deep link, register a handler
and dispatch to the helpers above:

```ts
this.registerObsidianProtocolHandler("zotlit-settings", ({ page, setting: name }) => {
  if (name) revealSetting(this.app, this.manifest.id, name);
  else openSettingsPage(this.app, this.manifest.id, page ? page.split("/") : []);
});
```

## Style guide

- **Sentence case** for all UI text: "Template folder location", not "Template Folder Location".
- **No top-level heading** — the sidebar tab title already names the plugin.
- **Headings only with multiple sections.** If the tab is one section, use no heading. When there
  are multiple sections and one is "general", leave general items at the top with no heading.
- **Don't repeat "settings"** in headings: "Advanced", not "Advanced settings".
- **Save on change**, not on submit. `control` does this automatically; in `render`, call `saveData()`
  from `onChange`.
- **One control per row.** Multiple controls per row stack vertically on mobile. Collect multi-field
  input in a Modal.
- **Avoid textareas on the main tab.** Push them to the bottom or into a modal.
- **Keep `desc` short** — one sentence. Put warnings in a Modal with explicit confirm.

## ZotLit-specific patterns

In this codebase, settings tabs use the declarative API with control keys that bridge to
`SettingsService`. The imperative dual-support UI lives under `setting-tab/compat/` for
Obsidian < 1.13 (minAppVersion 1.12.7), decoupled for easy removal.

When adding a setting:
1. Add the key to the settings interface in the relevant service.
2. Add the control definition in `getSettingDefinitions()`.
3. If dual-support is needed, add the imperative equivalent in `setting-tab/compat/`.

## Further reading

- `references/settings-guide.md` — full developer docs with all patterns and examples
- `references/migration-guide.md` — migrating from imperative `display()` to declarative
- `packages/obsidian-api/obsidian.d.ts` — canonical type definitions (grep for `SettingDefinition`,
  `SettingControl`, `PluginSettingTab`, `SettingPage`)

