| name | obsidian-settings |
| description | 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.
|
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 |
slider requires all three of min, max, step (not optional like on number).
Examples
{ name: 'Enable sync', control: { type: 'toggle', key: 'syncEnabled' } }
{
name: 'API key',
control: {
type: 'text',
key: 'apiKey',
placeholder: 'Enter key…',
validate: (v) => v.length < 8 ? 'Must be at least 8 characters.' : undefined,
},
}
{
name: 'Theme',
control: {
type: 'dropdown',
key: 'theme',
defaultValue: 'system',
options: { system: 'System', light: 'Light', dark: 'Dark' },
},
}
{
name: 'Max results',
control: { type: 'number', key: 'maxResults', min: 1, max: 100, defaultValue: },
}
{
: ,
: { : , : , : , : , : },
}
{
: ,
: { : , : , : },
}
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.
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.
{
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:
{
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':
{
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....( ({
: path,
: ,
})),
}
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):
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)
{
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.
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() { }
}
{ 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).
{
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.
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
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.
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.
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;
}
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:
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:
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);
(!tab) ;
hit = (tab., name);
(!hit) ;
app..({ tab, : hit. });
app..(tab, hit.);
;
}
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
app.setting.open();
const tab = app.setting.openTabById("hotkeys") as (SettingTab & { setQuery(q: string): void }) | null;
tab?.setQuery(plugin.manifest.id);
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:
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:
- Add the key to the settings interface in the relevant service.
- Add the control definition in
getSettingDefinitions().
- 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)