UI/UX Standards
Consistent UI/UX is essential for a native-feeling Obsidian plugin experience.
Table of Contents
- Sentence Case for UI Text
- Sentence Case for Locale Files
- Command Naming Conventions
- Settings & Configuration
- Declarative Settings (Obsidian 1.13+)
Sentence Case for UI Text
Enforce Sentence Case for UI Text
Rule: obsidianmd/ui/sentence-case (auto-fixable)
Note (v0.4.0): This rule is now enabled (
warn) in therecommendedconfig with{ enforceCamelCaseLower: true }— it was disabled in v0.3.0.
Use sentence case (first word capitalized, rest lowercase except proper nouns) for all UI text.
❌ INCORRECT:
.setName('Advanced Settings')
.setDesc('Configure Advanced Options')
.setButtonText('Save Changes')
new Notice('File Successfully Saved')✅ CORRECT:
.setName('Advanced settings')
.setDesc('Configure advanced options')
.setButtonText('Save changes')
new Notice('File successfully saved')Configuration options:
'obsidianmd/ui/sentence-case': ['warn', {
brands: ['Obsidian', 'GitHub'], // Preserve brand names
acronyms: ['API', 'URL', 'HTML'], // Preserve acronyms
enforceCamelCaseLower: true, // Fix camelCase to sentence case
}]Applies to:
.setName(),.setDesc(),.setText(),.setTitle().setButtonText(),.setPlaceholder(),.setTooltip()createEl()text and attributesnew Notice()messagesaddCommand()names.setAttribute()foraria-label,aria-description,title,placeholdertextContent,innerTextassignments
Handling Edge Cases: Plugin Names and Acronyms
The sentence-case rule may flag text that intentionally contains uppercase words — plugin names, brand names, or acronyms. The rule is advisory and there are always edge cases, but you can whitelist known terms via acronyms and brands options instead of suppressing the rule entirely.
❌ INCORRECT — suppressing the rule:
// eslint-disable-next-line obsidianmd/ui/sentence-case
.setName('Open TTRPG tools - controls')✅ CORRECT — configure the rule with your project's terms:
'obsidianmd/ui/sentence-case': ['warn', {
acronyms: ['TTRPG', 'API', 'URL', 'HTML'],
brands: [
'Style Settings',
'TTRPG Tools - Controls',
'TTRPG Tools',
],
}]acronyms— uppercase abbreviations that should stay uppercase (e.g.,API,URL,TTRPG)brands— proper names that should preserve their exact casing (e.g., plugin names likeStyle Settings,Dataview)
When a plugin references other plugins or well-known tools in its UI text, add those names to brands rather than rewriting them to sentence case.
Sentence Case for Locale Files
Plugins that externalize UI strings into locale files must also enforce sentence case.
Sentence Case for JSON Locale Files
Rule: obsidianmd/ui/sentence-case-json (auto-fixable)
Enforces sentence case for English locale strings stored in JSON files (e.g. en.json, locales/en.json).
❌ INCORRECT (en.json):
{
"openSettings": "Open Settings",
"saveChanges": "Save Changes",
"deleteFile": "Delete File"
}✅ CORRECT (en.json):
{
"openSettings": "Open settings",
"saveChanges": "Save changes",
"deleteFile": "Delete file"
}Sentence Case for TypeScript/JavaScript Locale Modules
Rule: obsidianmd/ui/sentence-case-locale-module (auto-fixable)
Enforces sentence case for English locale strings exported from TS/JS modules (e.g. en.ts, locales/en.js).
❌ INCORRECT (en.ts):
export default {
openSettings: "Open Settings",
saveChanges: "Save Changes",
};✅ CORRECT (en.ts):
export default {
openSettings: "Open settings",
saveChanges: "Save changes",
};Enable Locale Checks with recommendedWithLocalesEn
To enable all three sentence-case rules (TypeScript sources + JSON + TS/JS locale modules), use the recommendedWithLocalesEn config instead of recommended:
// eslint.config.mjs
import obsidianmd from "eslint-plugin-obsidianmd";
export default [
// Use this config to also lint en*.json, en*.ts, en*.js locale files
...obsidianmd.configs.recommendedWithLocalesEn,
];The recommended config only checks ui/sentence-case (inline TypeScript strings). The recommendedWithLocalesEn config additionally enables:
ui/sentence-case-json— checksen*.jsonanden/**/*.jsonui/sentence-case-locale-module— checksen*.ts,en*.js, anden/**/*
Command Naming Conventions
No Redundant "Command" in Names
Rules:
obsidianmd/commands/no-command-in-command-idobsidianmd/commands/no-command-in-command-name
❌ INCORRECT:
this.addCommand({
id: 'open-settings-command',
name: 'Open settings command',
});✅ CORRECT:
this.addCommand({
id: 'open-settings',
name: 'Open settings',
});No Plugin ID/Name in Command IDs
Rules:
obsidianmd/commands/no-plugin-id-in-command-idobsidianmd/commands/no-plugin-name-in-command-name
❌ INCORRECT:
// If plugin id is "my-plugin"
this.addCommand({
id: 'my-plugin-open-settings',
name: 'My Plugin: Open settings',
});✅ CORRECT:
this.addCommand({
id: 'open-settings',
name: 'Open settings',
});Rationale: Obsidian automatically namespaces commands with the plugin ID.
No Default Hotkeys
Rule: obsidianmd/commands/no-default-hotkeys
❌ INCORRECT:
this.addCommand({
id: 'toggle-feature',
name: 'Toggle feature',
hotkeys: [{ modifiers: ['Mod'], key: 't' }], // Don't set defaults
});✅ CORRECT:
this.addCommand({
id: 'toggle-feature',
name: 'Toggle feature',
// Let users configure their own hotkeys
});Rationale: Avoid hotkey conflicts. Let users choose their own shortcuts.
Use Appropriate Command Callbacks
Rule: Official guidelines
Choose the right callback type for your commands:
// callback: Always executes
this.addCommand({
id: 'show-info',
name: 'Show info',
callback: () => {
new Notice('Always works!');
}
});
// checkCallback: Conditional execution (returns true if executed)
this.addCommand({
id: 'format-selection',
name: 'Format selection',
checkCallback: (checking: boolean) => {
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
if (view) {
if (!checking) {
// Perform the action
const editor = view.editor;
const selection = editor.getSelection();
editor.replaceSelection(selection.toUpperCase());
}
return true;
}
return false;
}
});
// editorCallback: Only available when editor is active
this.addCommand({
id: 'insert-timestamp',
name: 'Insert timestamp',
editorCallback: (editor: Editor, view: MarkdownView) => {
editor.replaceSelection(new Date().toISOString());
}
});Rationale:
- Use
callbackfor unconditional execution - Use
checkCallbackfor conditional execution (command only shows when available) - Use
editorCallbackfor editor-dependent commands
Settings & Configuration
No Manual HTML Headings in Settings
Rule: obsidianmd/settings-tab/no-manual-html-headings
❌ INCORRECT:
containerEl.createEl('h3', { text: 'Appearance' });✅ CORRECT:
new Setting(containerEl).setName('Appearance').setHeading();Rationale: Use Obsidian's built-in heading API for consistency.
No Problematic Settings Headings
Rule: obsidianmd/settings-tab/no-problematic-settings-headings (auto-fixable)
❌ INCORRECT:
new Setting(containerEl)
.setName('General settings') // Don't use "General"
.setHeading();
new Setting(containerEl)
.setName('Plugin options') // Don't use "settings" or "options"
.setHeading();
new Setting(containerEl)
.setName('My Plugin preferences') // Don't include plugin name
.setHeading();✅ CORRECT:
new Setting(containerEl)
.setName('Appearance')
.setHeading();
new Setting(containerEl)
.setName('Behavior')
.setHeading();
new Setting(containerEl)
.setName('Advanced')
.setHeading();Rationale: Avoid redundant words in settings headings:
- Don't use "settings" or "options" (user already knows they're in settings)
- Don't use generic "General" heading
- Don't include the plugin name (already shown in settings tab title)
Declarative Settings (Obsidian 1.13+)
As of Obsidian 1.13.0, PluginSettingTab supports a declarative API: override getSettingDefinitions() to return an array of setting definitions. Obsidian handles rendering, persistence, validation, and search indexing. You describe the settings, not the DOM.
Migration Paths
Your minAppVersion |
Path | What to do |
|---|---|---|
>= 1.13.0 |
Path A (preferred) | Implement getSettingDefinitions() only. Delete display(). |
< 1.13.0 |
Path B (dual support) | Keep display() and add getSettingDefinitions() alongside it. Both must stay in sync. |
< 1.13.0, no need for new features |
No change | The API is opt-in. Leave the plugin as-is. |
Path A: Clean 1.13-only Migration
- Bump
minAppVersionto"1.13.0"inmanifest.json. - Override
getSettingDefinitions()— return an array of definition objects. - For each setting, write
{ name, desc, control: { type, key } }. Thekeymaps to a property onthis.plugin.settings. Obsidian reads, writes, and persists automatically. - Move value-shape validation (regex, range, format) from
onChangeinto avalidatecallback on the control. - Delete
display()and remove unused imports (typicallySetting).
import { App, PluginSettingTab } from 'obsidian';
class MySettingTab extends PluginSettingTab {
plugin: MyPlugin;
constructor(app: App, plugin: MyPlugin) {
super(app, plugin);
this.plugin = plugin;
}
getSettingDefinitions() {
return [
{
name: 'Enable feature',
desc: 'Turns the feature on or off.',
control: { type: 'toggle', key: 'enabled' },
},
{
name: 'Mode',
control: {
type: 'dropdown',
key: 'mode',
defaultValue: 'fast',
options: { fast: 'Fast', thorough: 'Thorough' },
},
},
{
name: 'Cache key',
desc: 'Alphanumeric only.',
control: {
type: 'text',
key: 'cacheKey',
placeholder: 'default',
validate: (value: string) =>
/^[a-z0-9]*$/i.test(value.trim()) ? undefined : 'Use letters and digits only.',
},
},
];
}
}Path B: Dual Support
Keep display() as-is and add getSettingDefinitions() alongside it. On 1.13.0+, Obsidian calls getSettingDefinitions() and skips display(). On older versions, display() runs as before.
Warning: The two implementations must stay in sync. Every time you add or change a setting, update both. If the maintenance overhead isn't worth it, prefer Path A and bump
minAppVersion.
Control Types
toggle, dropdown, text, textarea, number, slider, color, file, folder
Definition Kinds
control, render, and action on a definition are mutually exclusive. Definitions can also be empty (heading-only), groups, lists (addItem/onDelete/onReorder), and sub-pages.
When to Use render Instead of control
Use a render callback when control + validate isn't enough:
- Side effects on change — call a method, update a status bar, refresh another view
- Inverted or derived values — a toggle that drives a string config, a slider that drives a complex calculation
- Custom suggesters — a command picker, tag picker, or anything using
AbstractInputSuggest
For conditional visibility, use the visible predicate instead of render.
Shared Definition Fields
Every definition (control, render, action, or empty) accepts these alongside name:
| Field | Type | Purpose |
|---|---|---|
desc |
string | DocumentFragment |
Description. A fragment's textContent is what search indexes. |
aliases |
string[] |
Extra search terms — synonyms users might type instead of name. |
visible |
boolean | (() => boolean) |
Hides the item and excludes it from search for that render cycle. |
searchable |
boolean | (() => boolean) |
Keeps the item rendered but out of global search. |
Controls additionally accept disabled: boolean | (() => boolean), which greys out the control while leaving it visible — the right choice for "unavailable because X", where hiding the setting would just be confusing.
visible and disabled are re-evaluated on every render, so the function forms can reflect runtime state.
Refreshing: update() vs refreshDomState()
Two different costs — pick by what changed:
refreshDomState()— re-evaluates everyvisibleanddisabledpredicate and applies the result to the existing DOM. No re-render. Use this from arendercallback'sonChangeafter mutating state that other settings' predicates read.update()— re-runsgetSettingDefinitions()and re-renders. Use when the structure changed: items added or removed, list rows reordered.
Reach for refreshDomState() first; a full update() to toggle one control's enabled state is wasted work and drops focus.
Data-Shape Gotcha
Auto-persist calls saveData(plugin.settings), so all persisted plugin data must live inside the settings object. Sibling keys stored via saveData() outside settings will be clobbered.
❌ INCORRECT:
// Sibling key — clobbered when declarative settings auto-save
await this.saveData({ ...this.settings, bookmarks: this.bookmarks });✅ CORRECT:
// All persisted data under settings
interface MySettings {
enabled: boolean;
bookmarks: string[];
}Escape hatch: if you genuinely can't co-locate the data — settings split across multiple files, or a key that must live outside settings for backward compatibility — override getControlValue(key) and setControlValue(key, value) on your tab. PluginSettingTab implements them against plugin.settings; override both together and each control's key routes through your storage instead.
class MySettingTab extends PluginSettingTab {
getControlValue(key: string): unknown {
if (key === 'apiToken') return this.plugin.secrets.apiToken;
return super.getControlValue(key);
}
async setControlValue(key: string, value: unknown): Promise<void> {
if (key === 'apiToken') {
this.plugin.secrets.apiToken = value as string;
await this.plugin.saveSecrets();
return;
}
await super.setControlValue(key, value);
}
}Prefer reshaping your data over this. An override is a second persistence path to keep correct.
Pitfalls
getSettingDefinitions()runs on everyupdate()AND once at registration for search indexing. Keep it cheap — no I/O, no network calls.- A
rendercallback does not auto-save. Alwaysawait this.plugin.saveData(this.plugin.settings)after mutating settings. validateis a UI gate — it shows inline errors but doesn't modify stored values. Re-validate stored data inloadSettings()for data saved by older plugin versions.- Page names must be unique among siblings at the same depth.
- When an
actioncallback depends on row position, use theindexargument — don't capture index from an outermap(it goes stale after reorder/delete). - To refresh the tab after data changes, call
this.update()— orthis.refreshDomState()when onlyvisible/disabledpredicates changed. On 1.13.0+,display()is bypassed whengetSettingDefinitions()returns a non-empty array. validatealso runs once on mount, so a stored value that's already invalid surfaces its error immediately — but the bad value stays inplugin.settingsuntil the user fixes it.descacceptsstringorDocumentFragment. For rich descriptions with formatting or links, pass aDocumentFragmentbuilt withcreateFragment(...).
Relationship to Existing Rules
- Heading rules (
no-manual-html-headings,no-problematic-settings-headings) apply to legacydisplay()implementations - Sentence case still applies to
name,desc, andoptionsvalues in declarative definitions - Settings window opened in a new window since 1.13 — see Target Main Workspace from Settings