{"slug": "embedding-tailwind-design-system-in-obsidian", "title": "Embedding Tailwind Design System in Obsidian", "summary": "A developer built a React UI component library for an experimental agent SDK and needed to reuse those styled components in an Obsidian plugin without rewriting them. The challenge was that the plugin's components must coexist with Obsidian's existing styling system, requiring a strategy to map design tokens to the host application's CSS variables while winning the CSS cascade to ensure proper rendering.", "body_md": "Over the last two months, I've been building my own [experimental agent SDK](https://github.com/franklin-md/franklin-mono). To get immediate feedback on how the agents were behaving, I built a set of React UI components in Electron and embedded them in a dedicated chat app. But I wasn't trying to build another chat app though; I was trying to create a Cursor experience within my favorite note-taking app, Obsidian.\n\nSo when the MVP of the SDK was finished, it was time to build out the Obsidian plugin to house these agents. I obviously didn't want to rewrite from scratch the UI I had built. But I also couldn't just naively import it. Unlike my Electron app, I don't have complete ownership over the styling. My plugin just gets to **contribute UI** to the platform, along with all the other developers in the ecosystem writing plugins, themes or the core. And that means the final computed style for components depends on more than just my code.\n\nThat led me to the topic of this post:\n\nHow can you reuse styled UI components from an application you own in a plugin for an application you don't own?\n\nBut could equally be softened to \"how to reuse an existing styled UI component you found online\".\n\nI'm going to focus on integrating specifically a ShadCN + Tailwind design system into Obsidian, although the principles may be extrapolated to other class-based design systems too.\n\nThat means the rendered components look something like this:\n\n```\n<button class=\"... bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary\" type=\"button\"...\n```\n\n### The Easy Part: Token Mapping\n\nWhen we ask for `bg-primary`\n\n, Tailwind is already aware of `primary`\n\nas a color in our design system's palette because I have introduced it through the Tailwind [theming mechanism](https://tailwindcss.com/docs/theme). Instead of directly defining the values, we use **CSS variables** to defer making a decision on what the colors actually are.\n\n```\n@theme inline {\n  --color-background: var(--background);\n  --color-foreground: var(--foreground);\n  --color-primary: var(--primary);\n  --color-primary-foreground: var(--primary-foreground);\n  --color-border: var(--border);\n  --color-ring: var(--ring);\n}\n```\n\nThat means each host application only needs to define `--primary`\n\n, `--background`\n\n, `--border`\n\n, etc. The shared component code can stay the same.\n\nIn the Electron app, I can then use Franklin's own theme values:\n\n```\n:root {\n\t--background: oklch(0.96 0.012 88);\n\t--foreground: oklch(0.21 0.04 302);\n\t--primary: oklch(0.21 0.04 302);\n\t--primary-foreground: oklch(0.92 0.035 88);\n...\n}\n```\n\nBut in Obsidian, I don't want Franklin's color palette to be the source of truth. Obsidian already exposes a large set of theme-aware CSS variables, and those variables are what community themes customize. So the Obsidian plugin defines the same Franklin tokens by **forwarding** them to [Obsidian's CSS variables](https://docs.obsidian.md/Reference/CSS+variables/CSS+variables):\n\n```\n.franklin {\n\t--background: var(--background-primary);\n\t--foreground: var(--text-normal);\n\t--primary: var(--interactive-accent);\n\t--primary-foreground: var(--text-on-accent);\n...\n}\n```\n\n✦ Insight\n\n**Components should depend on the design system; the design system should depend on the host application's theme variables**. The application owns theming, not the reusable components.\n\n### The Hard Part: Winning the Cascade\n\nIf you are a pro at CSS already, or want to skip to the solution (which is to use a prefix PostCSS plugin + a root element), go [here](#solution)\n\nNow we have `primary`\n\nmapping to the same color as the theme's primary color. But when I ran it, my buttons with the `bg-primary/10`\n\nclass did not take on the primary color and were instead using a neutral tone.\n\nⓘ Note\n\n`bg-primary/10`\n\nis Tailwind for having a primary background with 10% opacity\n\nExamining what Chromium DevTools was computing, it seemed like my `bg-primary/10`\n\nwas being subordinated by the rule with selector `button:not(.clickable-icon)`\n\ncoming from Obsidian's stylesheet `app.css`\n\n.\n\nThat means we need to **carefully consider the implications this Obsidian setup has on the CSS cascade**. The [Cascade Algorithm](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Cascade/Introduction) determines **which style properties apply to an element, in the presence of multiple candidate rules**. Conceptually, for a given **property** (like `color`\n\n), it is a multi-pass filter on all available CSS rules where, at each pass, we sort the remaining candidates and filter those not in the top bucket. By the end, we are left with at most one winning declaration.\n\nLet's now examine what happens to the two `background-color`\n\ndeclarations at each pass of the algorithm:\n\n##### Pass 1: **Relevance**\n\nWe discard all rules whose selector does not match the element. What we are seeing in DevTools is exactly the list of declarations whose rule passed this phase.\n\n##### Pass 2: **Origin & Importance**\n\n- We may associate an\n**origin** with each stylesheet (and therefore with its rules and declarations). There are three:\n`user-agent`\n\norigin (i.e. browser-provided)\n`user`\n\n(i.e. user-customized)\n`author`\n\n(i.e. application-defined)\n\n- A declaration may also be flagged as\n`!important`\n\n(and if not marked, is then considered *normal*)\n- We bucket declarations by both origin and importance. We then order buckets so that:\n- Important buckets are\n**higher priority** than normal buckets (naturally)\n- For normal declarations,\n`author`\n\ncomes first, then `user`\n\n, then `user-agent`\n\n.\n- For important declarations, this order is reversed!\n\n- Then we let the declarations in the highest-priority\n**non-empty** bucket through to the next stage.\n- E.g. if there were 0 author declarations, 3 user declarations and 2 user-agent declarations, then we would only consider the 3 user declarations in the next step. However, if we also had\n**one important user-agent declaration, that would win**\n\nYou can see this at play in our running example:\n\n`app.css`\n\nis Obsidian's provided stylesheet and it has an `author`\n\norigin.\n`<style>`\n\nis referring to my Obsidian plugin's stylesheet (the thing we have control over) and it is also `author`\n\norigin.\n`ButtonFace`\n\nvalue is provided by `user-agent`\n\n, hence why it is at the bottom of the list\n\nThat means **both Obsidian and our declarations have the same origin**. No clear winner, so we need to go on to the next pass.\n\n##### Pass 3: Specificity\n\nThe idea of this pass is that **more specific selectors** should take a higher priority. Intuitively, a selector with more conditions should trump one with fewer. But also, the category of condition is important and should be considered.\n\nFormally, we take the basic selectors and assign a vector to them called a `weight-value`\n\n:\n\n- ID Selector (like\n`#login-button`\n\n) = `1 0 0`\n\n- Class selectors (like\n`.btn`\n\nor pseudo-classes like `:hover`\n\n) = `0 1 0`\n\n- Type selectors (elements like\n`p`\n\nor pseudo-elements like `::before`\n\n) = `0 0 1`\n\nRemember, selectors may be composed together using combinators to create more complex conditions. You therefore sum together all constituent weight-values, column-wise.\n\nTo compare two weight values, you find the first column where the vectors differ and the one with the higher number wins. For example:\n\n`0 5 1`\n\n> `0 2 3`\n\nbecause they differ in the second column and 5 > 3\n`1 0 1`\n\n> `0 10 0`\n\nbecause they differ in the first column and 1 > 0\n\nSo to compare the selectors of our running example:\n\n**Obsidian** = `button:not(.clickable-icon)`\n\n= `0 1 1`\n\n- The\n`button`\n\nis a type selector because it matches the element `<button>`\n\n, and contributes `0 0 1`\n\n`:not(.clickable-icon)`\n\ncontributes `0 1 0`\n\n(technically the `:not`\n\nadds nothing, but the `.clickable-icon`\n\ninside it is a class name and so contributes to the second column)\n\n**Ours** = `.bg-primary/10`\n\n= `0 1 0`\n\n(single class)\n\n**This explains why our declaration loses out to Obsidian's!**\n\n#### Exploring the Decision Tree of Solutions\n\n##### Ideas\n\nThere are basically three ideas I considered, each targetting a different pass:\n\n**Use the important flag (**`!important`\n\n): I opted against this primarily because the flag is especially justified as a [user/accessibility override](https://www.w3.org/TR/CSS2/cascade.html#important-rules), and our goal of defining a style override is spiritually different.\n**Layers**: As a newer kind of **Pass 2.5**, CSS has a layer mechanism in which you may define and order \"layers\", put rules in those layers, and higher priority layers with the same origin and importance always trump lower priority ones. **However**, **unlayered rules always beat layered rules**, and since Obsidian has virtually everything in unlayered rules, there is no point adding layers because this can only decrease our priority.\n**Increasing Specificity**: The most natural choice. We pick our fight at the exact pass we are losing at.\n\nWith a little more analysis, I came to the conclusion that we really just want to be **adding another class selector** because:\n\n- Playing around with IDs isn't practical. A document can only have one element with a particular ID, and our design system totally ignores them anyway\n- Because of the way weight-values are compared, if, ignoring IDs, we can win at the class-selector column, we are guaranteed to win. Whereas if we try winning at the type-selector column (i.e. with more matching elements) then we are not guaranteed to win if our class-selector is inferrior.\n\n##### Implementation\n\nWe need both: 1) for our selectors to have a higher class weight-value, and 2) **for our selectors to still match**! (otherwise we wouldn't get past the relevance pass)\n\nUsing the same ideas as [this repo](https://github.com/nicholas-wilcox/tailwind-snippet-obsidian-plugin), the trick is to use a [Prefix PostCSS Plugin](https://github.com/RadValentin/postcss-prefix-selector) in order to add weight to every Tailwind selector. Tailwind scans your codebase for any class tokens that are part of its language, then it generates tiny utility classes to define them. With the prefix plugin, you are asking the build system to take all these generated classes and modify their selector to include another class. I chose to prefix with `.franklin`\n\n, which is the name of the project.\n\n``` python\nimport prefixSelector from 'postcss-prefix-selector';\n\nconst FRANKLIN_PREFIX = '.franklin';\n\n/** PostCSS plugin that wraps every selector with `.franklin`. */\nexport function franklinPrefix() {\n\treturn prefixSelector({\n\t\tprefix: FRANKLIN_PREFIX,\n        // Avoids prefixing a selector that already contains the prefix\n\t\texclude: [/\\.franklin/],\n\t\ttransform(prefix, selector) {\n            // Doesn't make sense to add the prefix to \"root element\" because by definition\n            // it cannot have a parent with `.franklin` and so would never match.\n            // Instead, we treat the `.franklin` wrapper element as the virtual root.\n\t\t\tif (selector === ':root' || selector === 'html') {\n\t\t\t\treturn prefix;\n\t\t\t}\n\t\t\treturn `${prefix} ${selector}`;\n\t\t},\n\t});\n}\n\n...\n\nconst cssEntry = resolve(srcDir, 'styles/globals.css');\n// Run Tailwind THEN prefix.\nconst processor = postcss([\n\ttailwindcss({ optimize: isProd || !isWatch }),\n\tfranklinPrefix(),\n]);\nconst css = readFileSync(cssEntry, 'utf8');\nconst result = await processor.process(css, {\n\tfrom: cssEntry,\n\tto: resolve(distDir, 'styles.css'),\n});\n```\n\nAnd here is what appeared in `styles.css`\n\n:\n\n```\n.franklin .bg-primary\\/10 {\n  background-color: var(--primary);\n  @supports (color: color-mix(in lab, red, red)) {\n    background-color: color-mix(in oklab, var(--primary) 10%, transparent);\n  }\n}\n```\n\nThis solves part one. In order to actually get this selector to match, we need an ancestor of the element to have the `.franklin`\n\nclass because we have combined the selectors together with a [Descendant Combinator](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/Descendant_combinator). All we had to do here was go to the root component that we were mounting the React element into and add the class!\n\n```\ncontentEl.classList.add(\"franklin\");\nroot = createRoot(contentEl);\nroot.render(component);\n```\n\nNow, our button matches the selector and `.franklin .bg-primary\\/10`\n\nwins against Obsidian's because it has a weight-value of `0 2 0`\n\nversus Obsidian's `0 1 1`\n\n.\n\n✦ Insight\n\nIn addition to increasing specificity, this approach also **scopes** all our styles only to our plugin's components.\n\nWhat is also beautiful about this approach is that **we did not need to touch the custom button's implementation**. We only added a build step and a wrapper component!\n\n#### Caveats\n\nOur solution raised the weight-value by `0 1 0`\n\n. That was enough for the conflict in our running example, but unfortunately there are some Obsidian selectors at `0 2 1`\n\nand above. For example:\n\n```\ninput[type=\"text\"]:hover {\n  background-color: var(--background-modifier-form-field-hover);\n  border-color: var(--background-modifier-border-hover);\n  transition:\n    box-shadow var(--anim-duration-fast) ease-in-out,\n    border var(--anim-duration-fast) ease-in-out;\n}\n```\n\nFrom what I've observed so far, these more specific cases haven't had a noticeable enough effect on the \"intended\" design of components to warrant shielding against. But you can probably imagine how we might go about that:\n\n- Use multiple class prefixes\n- Add them all to the root\n\nOne obvious downside would be that this starts to get quite nuclear just to address a shrinking set of edge cases, all at the cost of bloating `styles.css`\n\n.\n\nⓘ idea\n\nIt dawned on me that potentially combining the prefix method (as a way to scope) and also a PostCSS plugin to add `!important`\n\nto all generated Tailwind declarations may actually be a good synergy, but I haven't yet explored if we get some unintended issues.\n\n### Organizing the Codebase\n\nBefore finishing off, I wanted to give a rough notes on how I structured and packaged this.\n\nThere are two independent packages here: **Shared UI** and the **Obsidian Plugin**. The relevant shape looks like this:\n\n```\napps/\n├── shared/\n│   └── ui/\n│       └── src/\n│           ├── components/\n│           └── styles/\n│               ├── theme-tokens.css\n│               ├── shared.css\n│               └── theme.css\n└── obsidian/\n    ├── build/css/\n    │   ├── build.mjs\n    │   └── prefix.mjs\n    └── src/\n        ├── renderer/\n        │   └── mount.tsx\n        └── styles/\n            ├── global.css\n            └── theme.css\n```\n\n**Shared UI**:\n`theme-tokens.css`\n\nmaps `Tailwind Theme -> Design System`\n\n`shared.css`\n\n: Joins together all the mechanics to make reusing Tailwind components in any project. It includes\n`@source`\n\ndirectives: Make Tailwind scan the shared component code so those utility classes get emitted by the consuming app\n- Imports\n`theme-tokens.css`\n\n- (optional)\n`theme.css`\n\nallows multiple Electron apps to all define the same `Design System -> Theme`\n\nmapping.\n\n**Obsidian Plugin**:\n`theme.css`\n\ndefines the `Design System -> Obsidian Theme`\n\nmapping discussed in \"The Easy Part\"\n`global.css`\n\nis the entry point and imports the Obsidian `theme.css`\n\n, `shared.css`\n\n, and also adds `@source`\n\ndirectives to pull any plugin-specific components.\n\n## Conclusion\n\nAlthough this post focuses specifically on embedding ShadCN + Tailwind + React components in Obsidian extensions, the thought process is largely the same.\n\nYou need to:\n\n- Understand how the host application deals with its own styles and with the plugin styles.\n**You need a working model of how they interact during the Cascade algorithm**\n- Figure out the appropriate build steps needed to create all stylesheets for your components that\n**adhere to the host's theme** and **that have higher priority than ambient defaults/overrides**.\n\nI have also left a couple of topics out of this post, but they are also important to consider:\n\n**Portals**: How do the shared portal components know which root component they should attach to? (hint: it can't be the container of the extension's view because modals will add a background dim/blur effect to only part of the viewport).\n**Resets**: We've discussed how to introduce explicit styles. On the other hand, the computed value for a style might be implicit. For example, [Firefox](https://raw.githubusercontent.com/mozilla-firefox/firefox/main/layout/style/res/ua.css) adds this rule to style visited links: `:visited { color: VisitedText; }`\n\n. But changing the host (i.e. from your Electron app to Obsidian) also changes these values. The trick is to add each problematic case to a `reset.css`\n\nfile that has the exact same selector but is also prefixed by `.franklin`\n\n. Then, all elements scoped under the `.franklin`\n\nroot reset the host's defaults to something preferred.\n**Tailwind Preflight**: Because host defaults vary across browsers, Tailwind tries to normalize values through its `tailwindcss/preflight.css`\n\nstylesheet. Naively importing this in a plugin is a bad idea though because it is unscoped, hence why I omitted it from `global.css`", "url": "https://wpnews.pro/news/embedding-tailwind-design-system-in-obsidian", "canonical_source": "https://alessandrofarace.com/essay/how-to-embed-design-system-obsidian", "published_at": "2026-06-03 09:51:10+00:00", "updated_at": "2026-06-03 10:18:24.284019+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "ai-products", "ai-startups", "ai-research"], "entities": ["Obsidian", "Electron", "React", "Cursor", "ShadCN", "Tailwind", "Franklin", "GitHub"], "alternates": {"html": "https://wpnews.pro/news/embedding-tailwind-design-system-in-obsidian", "markdown": "https://wpnews.pro/news/embedding-tailwind-design-system-in-obsidian.md", "text": "https://wpnews.pro/news/embedding-tailwind-design-system-in-obsidian.txt", "jsonld": "https://wpnews.pro/news/embedding-tailwind-design-system-in-obsidian.jsonld"}}