| name | obsidian-css |
| description | Style Obsidian plugin UI with Tailwind + native components. Use when writing or reviewing any styled React/JSX in apps/obsidian/ — picking colors, spacing, typography, borders, radius, shadows, dark-mode tokens, or choosing between Tailwind utilities, Obsidian native components, and custom CSS. Also use for .zt-root preflight setup, zt: prefix usage, theme compatibility, component wrapper selection, or any CSS/styling decision in the plugin. |
Obsidian Plugin CSS & Tailwind Style Guide
Plugin UI should feel like part of Obsidian — same colors, same spacing, same dark/light handling — without the plugin needing to know which theme the user has installed. Theme authors do this by overriding Obsidian's built-in CSS variables; plugin authors do it by consuming them through Tailwind utility classes.
The biggest mistake is hardcoding values (#1e1e1e, 12px, 1px solid #ccc). Hardcoded values look fine in the default theme and break in every other theme. Always use Tailwind tokens, which are backed by Obsidian CSS variables.
Styling approach
Tailwind-first with zt: prefix. Default to Tailwind utility classes for all styling. Avoid writing raw CSS stylesheets. The Tailwind theme in src/zt-main.css maps Obsidian CSS variables to Tailwind tokens.
Every utility class uses the zt: prefix — zt:flex, zt:gap-2, zt:bg-background, zt:text-muted-foreground, zt:rounded-md. Variants chain after the prefix: zt:hover:opacity-100, zt:@md:columns-2. This is Tailwind v4's prefix() feature applied to both theme.css and utilities.css imports — it scopes compiled selectors so they never collide with other plugins' Tailwind output. Custom @theme variables defined in zt-main.css (colors, radii, shadows mapped to Obsidian tokens) keep their authored names (no prefix).
When no Tailwind token exists for an Obsidian variable, either extend zt-main.css (follow the existing pattern) or use Tailwind's arbitrary CSS variable syntax: zt:bg-(--obsidian-var), zt:text-(--some-color), zt:p-(--size-4-3), etc. These compile to var(--…) at build time with full utility support.
Use cn() from @/lib/utils to merge Tailwind classes with conflict resolution — never raw string concatenation. cn() is prefix-aware via twMerge from @/lib/tw.
For React components with Obsidian modifier classes (mod-cta, is-enabled, clickable-icon), use tv from @/lib/tw (not directly from tailwind-variants) for variant composition — it's pre-configured with the zt prefix for correct class merging. See the existing wrappers in src/components/obsidian/ for the pattern.
The three rules
- Use Tailwind tokens, never hardcode. Hardcoded
#hex, rgb(…), and 8px values look correct in the default theme and break in every other theme. Use mapped tokens (zt:bg-background, zt:text-muted, zt:rounded-md). For spacing, Tailwind's default scale (zt:gap-2, zt:p-3) is fine — Obsidian's --size-4-N variables are just fixed 4px multiples that no theme overrides. See references/foundations.md for available variables.
- Prefer native components. Obsidian fully styles
<button>, <input>, <select>, <textarea>, and toggle — rebuilding these from Tailwind primitives fights Obsidian's styles and breaks across themes. Use bare elements or the React wrappers from src/components/obsidian/.
- No
!important, scope custom CSS. !important blocks user CSS snippets — they can never override your styles. Scope custom CSS under zt--prefixed classes and keep specificity low.
Quick decision tree
Picking a style goes:
- A native component exists → use the React wrapper from
src/components/obsidian/ (or the imperative obsidian API class). Don't restyle it with Tailwind — Obsidian's preflight handles appearance.
- Color → pick a semantic token (
zt:text-muted, zt:bg-background, zt:text-accent-foreground), not a raw palette one. Semantic tokens already track light/dark and the user's accent color. See references/foundations.md.
- Spacing / size → use Tailwind's default spacing scale (
zt:gap-2, zt:p-3, zt:mt-4). Obsidian's --size-4-N variables are just fixed multiples of 4px (never overridden by themes) and map 1:1 to Tailwind's scale, so there's no reason to use them directly.
- Radius →
zt:rounded-sm/md/lg/xl (mapped to --radius-s/m/l/xl).
- Typography →
zt:text-xs/sm/base/lg (mapped to Obsidian UI font sizes). See references/foundations.md#typography.
- A component CSS variable exists (e.g.
--modal-background, --button-radius, --tab-text-color) → use the arbitrary variable syntax (zt:bg-(--modal-background)) or extend zt-main.css. See references/components.md, references/editor.md, references/window.md, references/plugins.md.
- Nothing fits → use the arbitrary variable syntax (
zt:bg-(--obsidian-var)) to reference the Obsidian variable directly. If you want to expose it for user snippets, add a local custom property at your component root (.zt-foo { --zt-foo-bg: var(--background-secondary); }) and consume it via zt:bg-(--zt-foo-bg).
Obsidian element preflights
Obsidian fully styles <button>, <input> (all types), <textarea>, and <select> via its global stylesheet — see references/preflights.md for the full table. Lean into this — don't fight it with Tailwind resets.
What this means for you
- Don't add Tailwind background/border/padding/radius classes to these elements — you'll be fighting Obsidian's styles. Use the elements bare and they look correct.
- Tailwind layout classes are safe —
zt:flex, zt:gap-2, zt:w-full, zt:mt-2 etc. don't conflict because Obsidian's preflight doesn't set layout on these elements.
- Prefer native components — bare
<input type="checkbox">, <input type="radio">, and other preflighted elements need no wrapper. See next section for which elements have React wrappers and which don't.
- The flip side — non-form semantic elements (
blockquote, p, headings, lists, hr, …) that Obsidian doesn't reset get cleaned up by a scoped preflight applied to plugin roots. See Scoped preflight (.zt-root) below.
Scoped preflight (.zt-root)
src/zt-main.css applies Tailwind's preflight scoped to plugin UI roots, never globally:
@layer theme, base, components, utilities;
@import "tailwindcss/theme.css" layer(theme) prefix(zt);
@import "tailwindcss/utilities.css" layer(utilities) prefix(zt);
@layer base {
.zt-root { @import "tailwindcss/preflight.css"; }
}
Add zt-root to the container the view owns — ItemView.contentEl, a modal's contentEl, a settings pane — not inside the React tree. Then write normal semantic HTML — <blockquote>, <p>, <ul>/<li>, <h2>, <hr> — plus Tailwind border utilities; they render clean because preflight zeroes the UA margins/padding and sets border-style: solid. No div role workaround, no border-solid.
class AnnotationView extends ItemView {
async onOpen() {
this.contentEl.classList.add("zt-root");
createRoot(this.contentEl).render(<AnnotView />);
}
}
<blockquote className="zt:border-l-2 zt:pl-2" style={{ borderLeftColor: color }}>{text}</blockquote>
<p className="zt:select-text">{text}</p>
Why it's safe
Preflight sits in @layer base — your zt: utilities (@layer utilities) outrank it, and Obsidian's unlayered stylesheet outranks all layers. So preflight only overrides browser UA defaults (the leaks you want gone: <blockquote> margin:1em 40px, <p> margin:1em 0, <ul>/<ol> 40px indent, etc.), while your spacing/border utilities and Obsidian's native control styles both work normally inside .zt-root.
Caveats
- It does NOT reset Obsidian's own unlayered bare-element rules (unlayered beats
@layer base), and the bare-rule set is bigger than it looks. Structural: <hr> (border-top: var(--hr-thickness) + margin: 2rem 0 — note .markdown-rendered hr only re-declares the border, so the 2rem margin survives even there); <h1>–<h6> (per-level themed typography — size/weight/color/font/line-height — + var(--p-spacing) margin-block); <ol> (list-style-type: var(--list-numbered-style) → numbering survives); nested ul selectors like ul ul, ol ul (list-style-type: disc) — but a top-level bare <ul> has no rule at all, so preflight strips its markers; ul > li, ol > li (text-align: start) and their ::marker color. Inline elements: a/a:hover (accent color, underline, link weight/cursor — this beats preflight's a { color: inherit }, so links stay Obsidian-styled inside .zt-root), b/strong (bold weight + color), i/em (italic + color), kbd (fully styled: mono font, background, padding, radius). Other globals: button (full Obsidian button box, distinct from the <button> preflight discussion above), iframe, audio, * (box-sizing: border-box), and :focus (outline: none). Everything else (<p>, <blockquote>, <code>, <pre>, <table>/<th>/<td>, <img>, <video>, <mark>, <sub>/<sup>, <fieldset>, a top-level , …) has bare Obsidian rule — pure Chrome UA, so preflight fully governs them. Neutralize the ones you don't want with a utility/inline style or a plain .
Borders
Inside .zt-root a width utility is all you need: zt:border / zt:border-l-2 render solid (preflight supplies the style) and zt:divide-y zt:divide-border gives clean section rules — no zt:border-solid / zt:divide-solid. For a data-driven color (e.g. an annotation highlight) keep the width in the utility and set style={{ borderLeftColor: color }}.
No .zt-root? (fallback)
Outside a scoped root there is no preflight, so a width utility sets only width — border-style stays none (so zt:border alone is invisible) and unset sides fall back to UA medium, making zt:border-l-2 zt:border-solid a full box. There, add zt:border-solid / set the border inline, or render a <div> with the matching ARIA role (role="blockquote", role="paragraph", role="list" + child role="listitem", role="heading" + aria-level, role="separator"). Prefer adding zt-root — simpler, and keeps real semantics.
Native components
Prefer Obsidian's native components over building custom equivalents from Tailwind primitives. They get automatic theme compatibility, accessibility, and consistent behavior across Obsidian versions.
In React, use the wrappers in src/components/obsidian/ for components that need modifier-class logic or non-trivial behavior:
Button, IconButton, Toggle, Dropdown (+ DropdownItem, DropdownGroup), Slider, Color, Icon, SearchInput
<input type="text/search/email/password/number/date/datetime-local">, <input type="checkbox">, <input type="radio"> and <textarea> need no wrapper. Obsidian's preflight fully styles these elements — use them directly in JSX. Don't create thin React wrappers around them; the native elements already look correct.
- Use
AutosizeTextarea from react-textarea-autosize when the textarea should expand with content (e.g. template editors, note fields).
<input type="text" value={text} onChange={(e) => setText(e.currentTarget.value)} />
<textarea value={body} onChange={(e) => setBody(e.currentTarget.value)} rows={4} />
<input type="checkbox" checked={checked} onChange={(e) => setChecked(e.currentTarget.checked)} />
<input type="radio" name="group" value="a" checked={val === "a"} onChange={() => setVal("a")} />
<AutosizeTextarea value={body} onChange={setBody} minRows={2} maxRows={8} />
In imperative DOM (settings tabs, modals built with the Obsidian API), use the obsidian module classes directly:
ButtonComponent, ToggleComponent, TextComponent, TextAreaComponent, DropdownComponent, ColorComponent, SliderComponent
Read the wrapper source files for the full API. Each wrapper's JSDoc links to tooltipAttrs for tooltip usage.
Tooltips
Obsidian renders aria-label as a hover tooltip via global pointerover delegation. No extra library needed.
React — use tooltipAttrs() from @/lib/utils and spread onto the element:
import { tooltipAttrs } from "@/lib/utils";
<Button variant="cta" {...tooltipAttrs("Saves the current file")}>
Save
</Button>
<IconButton icon="settings" {...tooltipAttrs("Settings", { placement: "top" })} />
Imperative DOM — use setTooltip from the obsidian module:
import { setTooltip } from "obsidian";
setTooltip(buttonEl, "Saves the current file");
setTooltip(iconEl, "Settings", { placement: "top" });
Light / dark and theming
Obsidian sets .theme-light or .theme-dark on <body>. Almost always you do not need to write theme-specific rules — Tailwind tokens resolve to semantic Obsidian variables that already swap. Only branch on theme when:
- You're computing your own color (e.g. an SVG fill) and need different values per scheme.
- You're tweaking shadow / blend modes which are scheme-sensitive.
For accent-aware colors, use text-accent-foreground / bg-primary rather than text-blue-500. The accent is user-configurable in Settings → Appearance and Obsidian rebuilds it from --accent-h/s/l.
For RGB-with-opacity overlays, pair the -rgb variant: background: rgba(var(--color-red-rgb), 0.2).
Scoping custom CSS
Tailwind utilities are inherently element-scoped — they don't leak. You rarely need a BEM-style root class.
Use a scoped root class only when:
- You need CSS rules that target child elements (e.g. styling children of a container).
- You want to expose custom properties for user snippets.
- You're writing a
@layer rule that needs a specificity anchor.
When you do scope, prefix with zt-:
.zt-zotero-pane {
--zt-pane-padding-x: var(--size-4-4);
--zt-pane-row-gap: var(--size-4-2);
padding-inline: var(--zt-pane-padding-x);
}
.zt-zotero-pane__row { gap: var(--zt-pane-row-gap); }
Prefix custom properties with --zt-… to avoid colliding with Obsidian's. Default them to an Obsidian variable so the value flows through theme changes.
Anti-patterns (and the fix)
Hardcoded values — break in non-default themes because themes override Obsidian's base variables, not your literals.
<div className="zt:bg-[#2a2a2a] zt:text-[#ddd] zt:p-[8px_12px]" />
<div className="zt:bg-background zt:text-foreground zt:px-3 zt:py-2" />
<div className="zt:bg-white dark:zt:bg-[#1e1e1e]" />
<div className="zt:bg-background" />
Restyling a native element — Obsidian's preflight already handles appearance; adding Tailwind bg/border/padding fights it and drifts across Obsidian versions.
<button className="zt:bg-primary zt:text-primary-foreground zt:rounded-md zt:px-3 zt:py-1">Save</button>
<Button variant="cta">Save</Button>
Targeting Obsidian internals — undocumented class names change between Obsidian releases.
.workspace-leaf-content[data-type="zt-view"] .markdown-preview-view div.callout-title { … }
.zt-view .zt-callout-title { … }
!important to win specificity → restructure the selector instead. !important blocks user CSS snippets from ever overriding the value.
Verifying
Use the obsidian-debug skill for the build → reload → eval → screenshot loop. CSS-specific
probes worth running during that loop:
- Confirm a token resolved:
getComputedStyle(el).backgroundColor.
- Catch a leaked UA style: a bare
<blockquote> outside .zt-root reporting marginLeft: "40px"
(see Scoped preflight above).
- Compare left-edge offsets and element heights across components with
getBoundingClientRect().
Cross-theme checklist
Then confirm it holds up across themes:
- Toggle Settings → Appearance → Base color scheme between Light and Dark — your component should look correct in both with no extra CSS. Confirm which is active with
obsidian-cli eval code='document.body.className' (expect theme-light / theme-dark).
- Change the accent color — interactive elements should follow it.
- Try a popular community theme (Minimal, Things) — your component should still look at home. If something feels off, you're probably hardcoding a value that the theme is overriding.
Reference files
These are catalogs — read on demand, not all at once. Each file lists the Obsidian variables grouped by topic with one-line descriptions.
references/preflights.md — which bare HTML elements Obsidian fully styles (button, input, textarea, select, checkbox, radio, toggle).
references/foundations.md — colors (semantic + accent palette), spacing, typography, radiuses, borders, layers, icons, cursors. Read this first when you don't know which variable to use.
references/components.md — buttons, inputs, dropdowns, checkboxes, toggles, sliders, modals/dialogs, popovers, prompts, tabs, navigation, pills (multi-select), color inputs, indentation guides, dragging.
references/editor.md — markdown content: headings, links, tables, callouts, code, blockquotes, lists, tags, embeds, footnotes, properties, bases, inline title.
references/window.md — workspace chrome: ribbon, sidebar, status bar, dividers, scrollbars, window frame, vault profile, workspace.
references/plugins.md — built-in plugin views: file explorer, search, graph, canvas, sync.
references/app.css — the full Obsidian v1.12.7 stylesheet. Consult when you need to understand exactly what Obsidian applies to a bare element or class.
To find a variable when you only have a CSS property in mind:
- "background color of a panel" →
zt:bg-background / zt:bg-card (see references/foundations.md).
- "muted secondary text" →
zt:text-muted-foreground.
- "the user's accent color" →
zt:bg-primary (bg) or zt:text-accent-foreground (text).
- "a tab/pane border" →
zt:border-border.
- "icon button hover bg" →
zt:bg-muted.
- "modal/popover surface" →
zt:bg-popover (see references/components.md).