- 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
1. **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.
2. **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/`.
3. **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:
1. **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.
2. **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`.
3. **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.
4. **Radius** → `zt:rounded-sm/md/lg/xl` (mapped to `--radius-s/m/l/xl`).
5. **Typography** → `zt:text-xs/sm/base/lg` (mapped to Obsidian UI font sizes). See `references/foundations.md#typography`.
6. **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`.
7. **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:
```css
@layer theme, base, components, utilities; /* explicit order: utilities > base */
@import "tailwindcss/theme.css" layer(theme) prefix(zt);
/* …@theme tokens… */
@import "tailwindcss/utilities.css" layer(utilities) prefix(zt);
@layer base {
.zt-root { @import "tailwindcss/preflight.css"; } /* Tailwind inlines + scopes this at build */
}
```
**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`.
```tsx
// Set the scope on the container the view owns — not in the React tree:
class AnnotationView extends ItemView {
async onOpen() {
this.contentEl.classList.add("zt-root");
createRoot(this.contentEl).render(<AnnotView />);
}
}
// Inside, semantic HTML + border utilities just work — no div+role, no border-solid:
<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** `<ul>`, …) has *no* 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 `<div>`.
- **`.zt-root` is for your own custom DOM.** Markdown rendered via `MarkdownRenderer` inside it still looks right (Obsidian's `.markdown-rendered` rules are unlayered → they win), but don't lean on preflight to style markdown.
### 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).
```tsx
// Correct — bare elements, Obsidian styles them
<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")} />
// For auto-growing textareas, use the wrapper
<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:
```tsx
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:
GitHub에서 보기