<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: 7onic</title>
    <description>The latest articles on DEV Community by 7onic (@7onic).</description>
    <link>https://dev.to/7onic</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3863968%2Fcec9086a-4a1c-40ec-9254-35980686fe72.png</url>
      <title>DEV Community: 7onic</title>
      <link>https://dev.to/7onic</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/7onic"/>
    <language>en</language>
    <item>
      <title>Design to Code #8: The Cosmetics of Modularity</title>
      <dc:creator>7onic</dc:creator>
      <pubDate>Tue, 26 May 2026 07:39:56 +0000</pubDate>
      <link>https://dev.to/7onic/design-to-code-8-the-cosmetics-of-modularity-2bc7</link>
      <guid>https://dev.to/7onic/design-to-code-8-the-cosmetics-of-modularity-2bc7</guid>
      <description>&lt;p&gt;It was sometime in early April. Version 0.1.0 had been sitting on npm for maybe twenty-four hours. I was clicking through the documentation site I'd just deployed, riding that brief, fragile wave of pride you get right before you discover a critical bug.&lt;/p&gt;

&lt;p&gt;The Card component page featured a standard "Copy" button on the code block. Out of pure habit, I clicked it, flipped over to a scratch test project, and ran &lt;code&gt;npm install @​7onic-ui/react&lt;/code&gt;. The installation finished cleanly. Then, the development server lit up bright red.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Module not found: Can't resolve '@7onic-ui/react/card'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The code block on my own documentation site was instructing developers to import from a path that literally did not exist in the package they had just installed. It wasn't a typo. It wasn't a missing dependency. It was a path that had never existed and was never going to exist, simply because I had never written a &lt;code&gt;package.json&lt;/code&gt; exports map to support it.&lt;/p&gt;

&lt;p&gt;Anyone who copied that snippet on day one ran straight into a module-resolution error on line one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the Lie Came From
&lt;/h2&gt;

&lt;p&gt;The Card documentation page relied on a helper function called &lt;code&gt;generateCode()&lt;/code&gt; to render the Playground's live preview snippets. Somewhere deep in that utility, I had written a line that looked roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;importPath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`@7onic-ui/react/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;componentName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It feels entirely right when you write it. It's very shadcn-style. It looks clean, professional, and mimics the architecture of a massive, modular enterprise package. The string interpolated perfectly, the syntax highlighter mapped it beautifully, and the page rendered with zero console warnings.&lt;/p&gt;

&lt;p&gt;The only problem was that it had absolutely zero relationship to what was actually sitting inside the compiled &lt;code&gt;dist/&lt;/code&gt; directory.&lt;/p&gt;

&lt;p&gt;In reality, I had built the package with a single root entry point — one lone &lt;code&gt;index.ts&lt;/code&gt; file that re-exported every component. The &lt;code&gt;package.json&lt;/code&gt; had &lt;code&gt;"main"&lt;/code&gt; and &lt;code&gt;"module"&lt;/code&gt; and &lt;code&gt;"types"&lt;/code&gt; all pointing strictly to that one file, and that was it. There was no &lt;code&gt;"exports"&lt;/code&gt; map, no subpaths, nothing. The npm package and the documentation site were completely misaligned, and the docs site was writing checks that the package couldn't cash.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Did Instead of Managing 42 Subpaths
&lt;/h2&gt;

&lt;p&gt;My initial, knee-jerk instinct was to fix the documentation site by making the underlying package match its claims. I figured I'd just build the subpath infrastructure: write out the exports map — &lt;code&gt;"./card": "./dist/card.js"&lt;/code&gt;, &lt;code&gt;"./button": "./dist/button.js"&lt;/code&gt; — and repeat that for all forty-two components. I'd configure tsup to emit every single one of them as an isolated entry point, and then commit to maintaining that list for the rest of my life.&lt;/p&gt;

&lt;p&gt;I actually started doing this, but I stopped about four components into the refactor. The sheer volume of &lt;code&gt;package.json&lt;/code&gt; plumbing required per component was non-trivial. Every single new component added down the line would mean another entry to write, another build target to track, and another vector for human error if I forgot to map it.&lt;/p&gt;

&lt;p&gt;So, I decided to look at how mature UI libraries actually solve this.&lt;/p&gt;

&lt;p&gt;shadcn/ui wasn't a valid architectural comparison because it doesn't ship via an npm registry; you're copying raw source files directly into your project. But when I looked at libraries like Mantine, Chakra UI, and Radix Themes, they all import directly from a single root: &lt;code&gt;import { Button } from '@​mantine/core'&lt;/code&gt;. Not &lt;code&gt;@​mantine/core/button&lt;/code&gt;. The packages I had been quietly romanticizing in my head weren't even doing what my documentation claimed I was doing.&lt;/p&gt;

&lt;p&gt;Furthermore, the bundle-size argument didn't hold water. Running tsup with &lt;code&gt;splitting: true&lt;/code&gt; ensures that tree-shaking handles dead code elimination gracefully at the named-export level. If a consumer imports &lt;code&gt;{ Button }&lt;/code&gt; and nothing else, the rest of the library doesn't get shipped to the client anyway.&lt;/p&gt;

&lt;p&gt;The subpath import was entirely cosmetic. It was a vibe. It was the aesthetic of a modular architecture, not actual modularity.&lt;/p&gt;

&lt;p&gt;I formalized this decision in an Architecture Decision Record — &lt;code&gt;NO-SUBPATH-EXPORTS.md&lt;/code&gt; — mostly to stop future-me from having this exact same argument with himself. I established very clear re-evaluation criteria in the document: I would only revisit subpath exports if the library grew past 50 components, if a single root import began ballooning the bundle size past 100KB, or if actual production users explicitly demanded it. Right now, none of those conditions are true. We have 42 components, tree-shaking works flawlessly, and nobody has complained.&lt;/p&gt;

&lt;p&gt;Once the ADR was settled, I fixed &lt;code&gt;generateCode()&lt;/code&gt; to emit &lt;code&gt;@​7onic-ui/react&lt;/code&gt; and absolutely nothing else. I cleared the build cache, reloaded the documentation page, clicked Copy, and pasted it. It worked seamlessly. It was the first time my documentation had actually agreed with my compiled package.&lt;/p&gt;

&lt;h2&gt;
  
  
  The One Exception (Which Arrived Three Days Later)
&lt;/h2&gt;

&lt;p&gt;Of course there is an exception. There is always an exception.&lt;/p&gt;

&lt;p&gt;On 2026-04-08 I shipped v0.2.0 and the Chart component rolled out with its own dedicated subpath: &lt;code&gt;@​7onic-ui/react/chart&lt;/code&gt;. The ADR covering this deviation is titled &lt;code&gt;CHART-SUBPATH-EXPORT.md&lt;/code&gt;, and it's a direct response to a real dependency bottleneck.&lt;/p&gt;

&lt;p&gt;The Chart component relies heavily on recharts. recharts is a massive package, and most developers pulling in a core design system like &lt;a href="https://7onic.design" rel="noopener noreferrer"&gt;7onic&lt;/a&gt; don't need visualization tools right out of the gate. To accommodate this, I marked recharts as an optional peer dependency, adding it to &lt;code&gt;peerDependenciesMeta&lt;/code&gt; with &lt;code&gt;optional: true&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here is the JavaScript module quirk I didn't anticipate: if an optional library like recharts lives anywhere within the main entry point's import graph, importing literally anything from that main entry — a Button, an Input, a Badge — will instantly crash the application if recharts isn't installed in the consumer's project.&lt;/p&gt;

&lt;p&gt;Module resolution hits the application before tree-shaking even gets a vote. The bundler parses the dependency chain, spots &lt;code&gt;import { LineChart } from 'recharts'&lt;/code&gt;, immediately goes hunting for it in &lt;code&gt;node_modules&lt;/code&gt;, fails to locate it, and brings the entire application crashing down.&lt;/p&gt;

&lt;p&gt;The only real fix was to completely isolate the Chart component into its own independent entry point. This required two distinct tsup build configurations: &lt;code&gt;index&lt;/code&gt; (ensuring no recharts code touched the primary graph) and &lt;code&gt;chart&lt;/code&gt; (treating recharts as an explicit external dependency). I mapped two separate entries in the &lt;code&gt;package.json&lt;/code&gt; exports map, and thus, a single subpath earned its place by solving a tangible structural failure.&lt;/p&gt;

&lt;p&gt;The rule here isn't "never use subpaths." The rule is "no subpaths for vibes." If a subpath exists to isolate a heavy, optional dependency that would otherwise break the primary entry point, it earns its keep. If it exists simply to make the import line look more modular than it actually is, it gets cut.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the DX Improvement Actually Looked Like
&lt;/h2&gt;

&lt;p&gt;When developers ask for subpath imports, what they are usually chasing is a sense of visual intentionality. They like seeing &lt;code&gt;import { CardHeader } from '@​7onic-ui/react/card'&lt;/code&gt; because it provides immediate, spatial context that CardHeader is tightly coupled to the Card component ecosystem.&lt;/p&gt;

&lt;p&gt;v0.3.0 solved that psychological need without the overhead of subpaths. Every subcomponent is now exposed as a flat, named export right at the root entry point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Card&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;CardHeader&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;CardTitle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;CardContent&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@7onic-ui/react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get a single, clean import line with all subcomponents explicitly declared. The contextual grouping shifts from the file path to the editor's autocomplete panel and the names themselves — &lt;code&gt;Card&lt;/code&gt;, &lt;code&gt;CardHeader&lt;/code&gt;, &lt;code&gt;CardTitle&lt;/code&gt;. As you read the code from left to right, the structural relationship is entirely obvious.&lt;/p&gt;

&lt;p&gt;An added benefit is that you only ever need exactly one import statement per component family, no matter how many nested subcomponents your layout requires. I actually find myself copying and pasting far fewer import lines now than I did when I was pretending the library utilized subpath directories.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Still Bothering Me
&lt;/h2&gt;

&lt;p&gt;The reality I keep coming back to is that &lt;code&gt;generateCode()&lt;/code&gt; spent a non-trivial amount of time serving a complete fiction to early adopters, and I have no real way of knowing how many people copied it before I pushed the fix. Granted, v0.1.0 didn't hit massive installation numbers, but those early users existed.&lt;/p&gt;

&lt;p&gt;The resulting terminal error message is completely unambiguous, so anyone who tried it would have figured out the fix immediately. But they also would have known, on day one, that the maintainer didn't bother to test his own basic copy-paste documentation flow before executing a production release.&lt;/p&gt;

&lt;p&gt;To prevent this from happening again, I wrote a verification script that executes an &lt;code&gt;npm pack&lt;/code&gt; into a temporary directory and programmatically attempts to import components from the exact paths exposed by the documentation site. It exists purely because I no longer trust myself to remember to check manually. I shipped that script in the exact same release as the chart subpath — v0.2.0 — which feels poetically correct. Both implementations emerged from the exact same architectural realization: the npm package and the documentation site are two completely separate artifacts, and nothing forces them to agree unless you sit down and write the code that forces them to agree.&lt;/p&gt;

&lt;p&gt;I should probably write an automated end-to-end test that scrapes every single &lt;code&gt;generateCode()&lt;/code&gt; output across the entire live docs site, packs the library, and runs an isolated import test on each snippet. I haven't written it yet. It's on the list.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next: 42 components, one developer, no design review. What the patterns looked like after a year of building and what I'd do differently if I started over.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;About 7onic&lt;/strong&gt; — An open-source React design system where design and code never drift. Free, MIT licensed. Docs and interactive playground at &lt;a href="https://7onic.design" rel="noopener noreferrer"&gt;7onic.design&lt;/a&gt;. Source code on &lt;a href="https://github.com/itonys/7onic" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; — stars appreciated. More posts in this series at &lt;a href="https://blog.7onic.design" rel="noopener noreferrer"&gt;blog.7onic.design&lt;/a&gt;. Follow updates on X at &lt;a href="https://x.com/7onicHQ" rel="noopener noreferrer"&gt;@​7onicHQ&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>designsystem</category>
      <category>packageexports</category>
      <category>dx</category>
      <category>react</category>
    </item>
    <item>
      <title>Design to Code #7: How CVA Scaffolding Turned Into Dead Code</title>
      <dc:creator>7onic</dc:creator>
      <pubDate>Tue, 26 May 2026 07:09:53 +0000</pubDate>
      <link>https://dev.to/7onic/design-to-code-7-how-cva-scaffolding-turned-into-dead-code-31k2</link>
      <guid>https://dev.to/7onic/design-to-code-7-how-cva-scaffolding-turned-into-dead-code-31k2</guid>
      <description>&lt;p&gt;The lint config had been sitting in the repo for a week, untouched, when I finally ran it across &lt;code&gt;src/components/ui/&lt;/code&gt; on the afternoon of April 4th. I was expecting maybe a stray &lt;code&gt;console.log&lt;/code&gt;, a forgotten TODO — the kind of trivialities you hunt down right before any first publish. What I got back instead was a list of five files where &lt;code&gt;VariantProps&lt;/code&gt; was imported but never used: breadcrumb, divider, drawer, pagination, and toast. Fine. Dead imports. Delete them and move on.&lt;/p&gt;

&lt;p&gt;But then I opened &lt;code&gt;breadcrumb.tsx&lt;/code&gt; and noticed something worse: the &lt;code&gt;cva&lt;/code&gt; call itself was also completely unused. Not just the type import — the entire &lt;code&gt;const breadcrumbVariants = cva(...)&lt;/code&gt; block was sitting there, fully defined, exported in spirit, and referenced by absolutely nothing. The component rendered its classes via &lt;code&gt;cn()&lt;/code&gt; directly. The CVA scaffolding was pure decoration.&lt;/p&gt;

&lt;p&gt;I had written that file. Yet I had no memory of writing the CVA part of it, because I had not written it deliberately. I had simply pasted the boilerplate shape of a &lt;a href="https://7onic.design" rel="noopener noreferrer"&gt;7onic&lt;/a&gt; component and filled in the middle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why CVA Earns Its Slot Elsewhere
&lt;/h2&gt;

&lt;p&gt;Before getting into why removing it from breadcrumb was the right move, it helps to look at why that dependency is everywhere else in the first place.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;class-variance-authority&lt;/code&gt; is one of the four core packages the &lt;code&gt;7onic add&lt;/code&gt; CLI auto-installs alongside &lt;code&gt;@​7onic-ui/tokens&lt;/code&gt;, &lt;code&gt;clsx&lt;/code&gt;, and &lt;code&gt;tailwind-merge&lt;/code&gt;. Four dependencies. That is the absolute floor of this design system. If you install a single 7onic component, all four show up in your &lt;code&gt;node_modules&lt;/code&gt;, and CVA is the only one that isn't either a pure utility (&lt;code&gt;clsx&lt;/code&gt;, &lt;code&gt;tailwind-merge&lt;/code&gt;) or the design token layer itself.&lt;/p&gt;

&lt;p&gt;It earns that real estate mostly on components like Button.&lt;/p&gt;

&lt;p&gt;The Button component has, in its current form, four variants (&lt;code&gt;solid&lt;/code&gt;, &lt;code&gt;outline&lt;/code&gt;, &lt;code&gt;ghost&lt;/code&gt;, &lt;code&gt;link&lt;/code&gt;), six sizes (&lt;code&gt;xs&lt;/code&gt;, &lt;code&gt;sm&lt;/code&gt;, &lt;code&gt;md&lt;/code&gt;, &lt;code&gt;default&lt;/code&gt;, &lt;code&gt;lg&lt;/code&gt;, &lt;code&gt;icon&lt;/code&gt;), nine radius values (&lt;code&gt;none&lt;/code&gt; through &lt;code&gt;full&lt;/code&gt;), and a &lt;code&gt;fullWidth&lt;/code&gt; boolean. Multiply that out and you get something north of four hundred valid combinations, every single one of which needs to produce the exact right Tailwind class string. Without CVA, the alternative is a long, miserable staircase of nested ternaries and template literals, or some homegrown lookup map that re-implements the same idea worse. The CVA call collapses that entire matrix into a single declarative object:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;buttonVariants&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cva&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;inline-flex items-center justify-center whitespace-nowrap transition-all duration-micro focus-visible:focus-ring disabled:pointer-events-none disabled:opacity-50&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;variants&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;solid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;font-semibold&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;outline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;border border-border bg-background text-foreground hover:bg-background-muted&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ghost&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;link&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;xs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;h-7&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;h-8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;md&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;h-9&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;h-10&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;h-12&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;h-10 w-10&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;none&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&gt;/* ...nine total */&lt;/span&gt; &lt;span class="na"&gt;full&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;fullWidth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;true&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;w-full&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;defaultVariants&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;solid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;default&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;default&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;fullWidth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But handling class strings is only the smaller half of what CVA actually does for the library.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Part That Isn't About Classes
&lt;/h2&gt;

&lt;p&gt;The real reason CVA is worth a dedicated dependency slot is the line immediately following that &lt;code&gt;cva&lt;/code&gt; call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ButtonProps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ButtonHTMLAttributes&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;HTMLButtonElement&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;
  &lt;span class="nx"&gt;VariantProps&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;buttonVariants&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;VariantProps&amp;lt;typeof buttonVariants&amp;gt;&lt;/code&gt; reads the variants object and infers the prop types directly from it. &lt;code&gt;variant&lt;/code&gt; automatically becomes &lt;code&gt;'solid' | 'outline' | 'ghost' | 'link'&lt;/code&gt;. &lt;code&gt;size&lt;/code&gt; becomes &lt;code&gt;'xs' | 'sm' | 'md' | 'default' | 'lg' | 'icon'&lt;/code&gt;. If I add a new size next month, the type system updates without me ever touching the props interface. If I rename &lt;code&gt;solid&lt;/code&gt; to &lt;code&gt;filled&lt;/code&gt;, every consumer site gets a red compile error the moment they pull the updated version.&lt;/p&gt;

&lt;p&gt;You can easily build the class-string concatenation machinery yourself in twenty lines of code and tree-shake your way out of the dependency. What you cannot easily replicate yourself is this bidirectional link between the configuration object and the TypeScript types. CVA's true value lives inside the type system; the runtime class composition is just along for the ride.&lt;/p&gt;

&lt;p&gt;Which is exactly what made the breadcrumb situation slightly ridiculous. Breadcrumb had a &lt;code&gt;cva&lt;/code&gt; call with &lt;code&gt;variants: {}&lt;/code&gt; — empty object, no variants — and it still had &lt;code&gt;VariantProps&lt;/code&gt; imported at the top of the file. &lt;code&gt;VariantProps&amp;lt;typeof breadcrumbVariants&amp;gt;&lt;/code&gt; resolves to &lt;code&gt;{}&lt;/code&gt;. Zero props. The entire setup was a circle whose circumference was exactly zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Variants Aren't Enough
&lt;/h2&gt;

&lt;p&gt;The other thing worth knowing about CVA, before addressing the problem with defaults, is that it has a known soft edge. CVA fundamentally expects each variant to be orthogonal — meaning &lt;code&gt;size&lt;/code&gt; should be independent of &lt;code&gt;variant&lt;/code&gt;, which should be independent of &lt;code&gt;radius&lt;/code&gt;. The exact moment you introduce a prop that only makes sense in tight combination with another prop, you step outside the comfortable middle of the library.&lt;/p&gt;

&lt;p&gt;Button's &lt;code&gt;color&lt;/code&gt; prop is the clearest example. It accepts &lt;code&gt;default&lt;/code&gt;, &lt;code&gt;primary&lt;/code&gt;, &lt;code&gt;secondary&lt;/code&gt;, or &lt;code&gt;destructive&lt;/code&gt;, and it only actually does anything when &lt;code&gt;variant === 'solid'&lt;/code&gt;. An outline button doesn't have a solid fill color in the same way; a ghost button doesn't either. If I were to put &lt;code&gt;color&lt;/code&gt; inside the main CVA variants object, it would show up in the TypeScript types for all variants. Consumers could legally write &lt;code&gt;&amp;lt;Button variant="ghost" color="destructive" /&amp;gt;&lt;/code&gt; and get something that compiles cleanly but produces incoherent runtime styling.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;color&lt;/code&gt; lives outside CVA as a plain JavaScript lookup map:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;solidColorMap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;secondary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;destructive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the final class assembly looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;cn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nf"&gt;buttonVariants&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;radius&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fullWidth&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="nx"&gt;variant&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;solid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;solidColorMap&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;color&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="nx"&gt;className&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;variant === 'solid' &amp;amp;&amp;amp; ...&lt;/code&gt; line is load-bearing. CVA hands back the base utility classes; the conditional hands back the contextual color, but only when it is strictly relevant; &lt;code&gt;cn&lt;/code&gt; flattens everything out while resolving any Tailwind utility conflicts. It's not exceptionally beautiful, but it's an architectural seam. CVA handles the clean, rectangular center of the design space and the conditional patches the corners.&lt;/p&gt;

&lt;p&gt;For a long time I wanted to push &lt;code&gt;color&lt;/code&gt; back into CVA via &lt;code&gt;compoundVariants&lt;/code&gt; — the specific API designed for cross-prop interactions. I never found a way to express "this prop only exists when this other prop has this specific value" through &lt;code&gt;compoundVariants&lt;/code&gt;. It can apply extra utility classes when two variants combine, but it cannot make a prop conditionally part of the TypeScript type interface itself. So the escape hatch remains.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where compoundVariants Does Pull Its Weight
&lt;/h2&gt;

&lt;p&gt;Five of the 42 components use &lt;code&gt;compoundVariants&lt;/code&gt;: divider, tabs, segmented, textarea, and input. These are the components where the cross-prop logic isn't "should this prop exist?" but rather "what specific class combination does this intersection produce?"&lt;/p&gt;

&lt;p&gt;The clearest use case is Input, which maps out three entries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;compoundVariants&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;focusRing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;focus-visible:shadow-[0_0_0_2px_var(--color-focus-ring)]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;default&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;focusRing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;focus:border-border-strong&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;filled&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;border-transparent ... bg-[var(--color-error-bg)]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The third entry is the interesting one. &lt;code&gt;variant: 'filled'&lt;/code&gt; and &lt;code&gt;state: 'error'&lt;/code&gt; are both perfectly legal in isolation. A filled input without an error renders with a subtle gray background. An outline input with an error renders with a stark red border. But the combination of &lt;em&gt;filled and errored&lt;/em&gt; needs its own custom treatment — a transparent border (because there is no outline to color) and a red-tinted background instead of the standard gray. Without &lt;code&gt;compoundVariants&lt;/code&gt; you would either need a third state value (&lt;code&gt;filled-error&lt;/code&gt;?) or lift the conditional outside CVA, creating the exact same seam I had to build for Button's color.&lt;/p&gt;

&lt;p&gt;Divider's &lt;code&gt;compoundVariants&lt;/code&gt; block is less complex but significantly more dense — ten entries covering the cross product of &lt;code&gt;orientation&lt;/code&gt; (horizontal/vertical) with &lt;code&gt;spacing&lt;/code&gt; (sm/md/default/lg). Each combination produces the correct margin utility and border direction. &lt;code&gt;horizontal&lt;/code&gt; + &lt;code&gt;default&lt;/code&gt; yields &lt;code&gt;border-t my-4&lt;/code&gt;. &lt;code&gt;vertical&lt;/code&gt; + &lt;code&gt;sm&lt;/code&gt; yields &lt;code&gt;border-l mx-2&lt;/code&gt;. The entire matrix laid out with zero conditionals at the call site.&lt;/p&gt;

&lt;p&gt;These are the exact use cases CVA was actually engineered for.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Lint Sweep Was Actually Saying
&lt;/h2&gt;

&lt;p&gt;So, the tally stood at five files with unused &lt;code&gt;VariantProps&lt;/code&gt;. Four of them — divider, drawer, pagination, and toast — were using CVA productively but had simply forgotten to clean up the type import lines after a previous refactoring pass. An easy delete.&lt;/p&gt;

&lt;p&gt;The fifth file, breadcrumb, was the one where I had to admit something to myself. Breadcrumb genuinely does not have variants. It has a rigid structure — a list of items separated by a visual separator — and the only thing that changes between instances is the literal content. There is no &lt;code&gt;size&lt;/code&gt; prop because the size is inherited from the surrounding text context. There is no &lt;code&gt;variant&lt;/code&gt; prop because there is only one way to render a breadcrumb in this system. The component is a thin, sensible wrapper around &lt;code&gt;&amp;lt;nav&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;ol&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Yet I had still given it a full &lt;code&gt;cva&lt;/code&gt; call. With a completely empty &lt;code&gt;variants&lt;/code&gt; object. Simply because every other file in the library had one.&lt;/p&gt;

&lt;p&gt;That is the part worth writing down. The CLI installs CVA. The component templates start with CVA. Forty-two components use CVA. So when I sat down to author the breadcrumb file, my hands typed &lt;code&gt;import { cva, type VariantProps } from 'class-variance-authority'&lt;/code&gt; before my brain ever caught up with the fact that there were no variants to manage. The lint sweep wasn't merely catching dead code; it was catching the underlying assumption that the pattern itself is the point.&lt;/p&gt;

&lt;p&gt;The final result, after the cleanup: 0 errors, 0 warnings. Five files lighter on unused imports. One file lighter on an entire unused &lt;code&gt;cva&lt;/code&gt; block. And a small, sobering note left in the ADR: &lt;em&gt;"Pattern boilerplate vs deliberate use: not the same thing."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I think about that note more than I want to. The whole appeal of building a component library, from the inside, is that you stop having to make tiny, repetitive decisions. You reach for the established template, fill in the middle, and ship. The hidden cost is that you eventually stop noticing when the template is making the decisions for you. CVA inside the breadcrumb wasn't a load-bearing tool. It was just a habit wearing the costume of one.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next: 7onic ships one entry point. No &lt;code&gt;@​7onic-ui/react/button&lt;/code&gt; subpath imports. The reason is embarrassingly specific to one incident where generateCode() sent users to an import path that didn't exist.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;About 7onic&lt;/strong&gt; — An open-source React design system where design and code never drift. Free, MIT licensed. Docs and interactive playground at &lt;a href="https://7onic.design" rel="noopener noreferrer"&gt;7onic.design&lt;/a&gt;. Source code on &lt;a href="https://github.com/itonys/7onic" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; — stars appreciated. More posts in this series at &lt;a href="https://blog.7onic.design" rel="noopener noreferrer"&gt;blog.7onic.design&lt;/a&gt;. Follow updates on X at &lt;a href="https://x.com/7onicHQ" rel="noopener noreferrer"&gt;@​7onicHQ&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>designsystem</category>
      <category>tailwindcss</category>
      <category>typescript</category>
      <category>react</category>
    </item>
    <item>
      <title>Design to Code #6: When @theme inline Killed My Dark Mode</title>
      <dc:creator>7onic</dc:creator>
      <pubDate>Tue, 26 May 2026 06:24:28 +0000</pubDate>
      <link>https://dev.to/7onic/design-to-code-6-when-theme-inline-killed-my-dark-mode-blp</link>
      <guid>https://dev.to/7onic/design-to-code-6-when-theme-inline-killed-my-dark-mode-blp</guid>
      <description>&lt;p&gt;I was in test-v4 that morning, clicking the theme toggle for the fifteenth time, and the card background just kept staying white.&lt;/p&gt;

&lt;p&gt;The toggle itself worked. I could watch &lt;code&gt;data-theme="dark"&lt;/code&gt; flip on &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt; in DevTools. Text color changed. Border color changed. But the surface underneath stayed &lt;code&gt;#ffffff&lt;/code&gt;, smug and immovable, as if it had personally decided that dark mode was a phase I was going through.&lt;/p&gt;

&lt;p&gt;This was &lt;a href="https://7onic.design" rel="noopener noreferrer"&gt;7onic&lt;/a&gt; — my own library — on a fresh install of the v4 build I'd just shipped. The Tailwind v3 test page two tabs over worked fine. Same components, same tokens, same toggle. So the bug lived somewhere in the slice of CSS that only Tailwind v4 users would ever touch, which was exactly the slice I'd rewritten the week before, following the official migration guide step by step like a good citizen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three selectors, no JavaScript
&lt;/h2&gt;

&lt;p&gt;Before I get to what I broke, it helps to know what dark mode in 7onic is actually made of, because the answer is: very little.&lt;/p&gt;

&lt;p&gt;There's no &lt;code&gt;useTheme&lt;/code&gt; hook in the library. No context provider, no &lt;code&gt;&amp;lt;ThemeProvider&amp;gt;&lt;/code&gt; you have to drop into your root layout. The whole mechanism is three CSS selectors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefers-color-scheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;dark&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nd"&gt;:root:not&lt;/span&gt;&lt;span class="o"&gt;([&lt;/span&gt;&lt;span class="nt"&gt;data-theme&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;"light"&lt;/span&gt;&lt;span class="o"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="c"&gt;/* dark tokens */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;:root&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-theme&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;"dark"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="c"&gt;/* dark tokens */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;:root&lt;/span&gt;&lt;span class="nc"&gt;.dark&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="c"&gt;/* dark tokens */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first says: if the OS is dark, go dark — unless the user explicitly set &lt;code&gt;data-theme="light"&lt;/code&gt;. The second is for apps that want a manual toggle without touching &lt;code&gt;prefers-color-scheme&lt;/code&gt;. The third is there because Tailwind v3 projects flip &lt;code&gt;.dark&lt;/code&gt; on &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt;, and I'm not in the business of fighting that habit.&lt;/p&gt;

&lt;p&gt;Each block redefines a handful of CSS custom properties — &lt;code&gt;--color-background&lt;/code&gt;, &lt;code&gt;--color-foreground&lt;/code&gt;, &lt;code&gt;--color-border&lt;/code&gt;, the usual suspects. The components don't know any of this exists. A &lt;code&gt;&amp;lt;Card&amp;gt;&lt;/code&gt; just sets &lt;code&gt;background-color: var(--color-background)&lt;/code&gt; and trusts whichever block is currently winning the cascade to fill in the right value. The whole thing rests on one slight assumption: the utilities reference variables, not hex literals.&lt;/p&gt;

&lt;p&gt;Spoiler: in v4, mine didn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  What &lt;code&gt;@​theme inline&lt;/code&gt; actually compiles to
&lt;/h2&gt;

&lt;p&gt;If you've followed Tailwind's v4 migration guide, you've probably seen this snippet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@theme&lt;/span&gt; &lt;span class="nb"&gt;inline&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--color-background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--color-background&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--color-primary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--color-primary&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c"&gt;/* ... */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It looks innocent. It's documented. The Tailwind team recommends it for exactly the setup I have — design tokens defined elsewhere, Tailwind utilities that need to know about them. So I copy-pasted the pattern, ran the build, shipped the version, and went to bed feeling productive.&lt;/p&gt;

&lt;p&gt;Here's what &lt;code&gt;@​theme inline&lt;/code&gt; does that the docs don't quite shout at you: it resolves the values at compile time and bakes them into the utility classes as literals.&lt;/p&gt;

&lt;p&gt;So when my dev-mode tokens looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nd"&gt;:root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;--color-background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#ffffff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.dark&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;--color-background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#0a0a0a&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tailwind read &lt;code&gt;@​theme inline { --color-background: var(--color-background); }&lt;/code&gt;, looked up what &lt;code&gt;--color-background&lt;/code&gt; resolved to in the &lt;code&gt;:root&lt;/code&gt; scope at build time, found &lt;code&gt;#ffffff&lt;/code&gt;, and emitted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.bg-background&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#ffffff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not &lt;code&gt;var(--color-background)&lt;/code&gt;. Just the hex. Forever.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;.dark { --color-background: #0a0a0a }&lt;/code&gt; block was still sitting there in the CSS file. The variable was still being overridden at runtime. It just had nothing left to override, because no utility in the entire stylesheet was reading the variable anymore. It was the most polite kind of broken — every piece of the system looked correct in isolation, and the system as a whole did nothing.&lt;/p&gt;

&lt;p&gt;Drop the word &lt;code&gt;inline&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@theme&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--color-background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--color-background&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;…and Tailwind keeps the &lt;code&gt;var()&lt;/code&gt; reference in the emitted utilities:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.bg-background&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--color-background&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same authoring API. Completely different runtime behavior. One word.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I had &lt;code&gt;inline&lt;/code&gt; in the first place
&lt;/h2&gt;

&lt;p&gt;The honest answer: because the docs told me to, and because two weeks earlier I'd hit a different problem and &lt;code&gt;inline&lt;/code&gt; was how I worked around it.&lt;/p&gt;

&lt;p&gt;I'd just finished a token rename pass — &lt;code&gt;REMOVE-DEFAULT-SUFFIX&lt;/code&gt; in the ADR, exactly as glamorous as it sounds. The old name for the brand color was &lt;code&gt;--color-primary-default&lt;/code&gt;, and I'd decided the &lt;code&gt;-default&lt;/code&gt; suffix was noise nobody had asked for. The new name was just &lt;code&gt;--color-primary&lt;/code&gt;. Fine. Run the codemod, regenerate the token pipeline, move on.&lt;/p&gt;

&lt;p&gt;Except now my Tailwind config was trying to write this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@theme&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--color-primary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--color-primary&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which is, if you read it slowly, a variable defining itself in terms of itself. Tailwind liked that about as much as I did. The build either threw or it didn't throw and the value just collapsed to nothing — I don't fully remember which, because at the time I just wanted it to stop and I grabbed the first thing the docs suggested:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@theme&lt;/span&gt; &lt;span class="nb"&gt;inline&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--color-primary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#15a0ac&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hardcode the hex as a fallback. Tell &lt;code&gt;@​theme&lt;/code&gt; to resolve inline. Build went green. The brand color showed up. I committed.&lt;/p&gt;

&lt;p&gt;What I did not notice, at that moment, was that I'd also signed every other token in the file up for the same treatment. Because &lt;code&gt;@​theme inline&lt;/code&gt; isn't a per-property switch — it's a whole-block mode. Once you put &lt;code&gt;inline&lt;/code&gt; on the &lt;code&gt;@​theme&lt;/code&gt; block, every variable inside it gets resolved at compile time. Not just the one you were trying to rescue.&lt;/p&gt;

&lt;p&gt;The brand color was now a hex literal in the utilities. So was the background. So was the foreground. Every border and ring and surface color in the system. All of them, frozen at whatever they resolved to in the light-mode &lt;code&gt;:root&lt;/code&gt;. All of them, immune to &lt;code&gt;.dark&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I'd built a dark mode that worked perfectly in CSS and was reached by exactly zero utility classes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why shadcn doesn't have this problem
&lt;/h2&gt;

&lt;p&gt;While I was staring at this, I went to look at shadcn/ui's setup, because I knew they used &lt;code&gt;@​theme inline&lt;/code&gt; and their dark mode obviously worked. If the snippet was poison, surely they'd have noticed.&lt;/p&gt;

&lt;p&gt;The trick is in the naming. Their &lt;code&gt;@​theme inline&lt;/code&gt; block looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@theme&lt;/span&gt; &lt;span class="nb"&gt;inline&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--color-background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--background&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--color-foreground&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--foreground&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two different namespaces. The Tailwind utility variable is &lt;code&gt;--color-background&lt;/code&gt;. The user-facing token they reference is &lt;code&gt;--background&lt;/code&gt;. When &lt;code&gt;@​theme inline&lt;/code&gt; tries to resolve &lt;code&gt;var(--background)&lt;/code&gt; at compile time, it finds… &lt;code&gt;var(--background)&lt;/code&gt;, because nothing else in the stylesheet defines that name at the &lt;code&gt;:root&lt;/code&gt; level in a way Tailwind can statically resolve. So the &lt;code&gt;var()&lt;/code&gt; survives into the output. The utility becomes &lt;code&gt;.bg-background { background-color: var(--background); }&lt;/code&gt;. Dark mode overrides &lt;code&gt;--background&lt;/code&gt;, the utility reads &lt;code&gt;--background&lt;/code&gt;, everything works.&lt;/p&gt;

&lt;p&gt;Mine looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@theme&lt;/span&gt; &lt;span class="nb"&gt;inline&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--color-background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--color-background&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same name on both sides. Because the entire 7onic token pipeline — Figma Token Studio → JSON → generated CSS → published package — uses one canonical name per token. There is no &lt;code&gt;--background&lt;/code&gt; and &lt;code&gt;--color-background&lt;/code&gt;. There is just &lt;code&gt;--color-background&lt;/code&gt;, generated from &lt;code&gt;figma-tokens.json&lt;/code&gt;, used by the utilities, overridden by the theme blocks. Single source of truth.&lt;/p&gt;

&lt;p&gt;shadcn can afford the asymmetry because their tokens live in a hand-written &lt;code&gt;globals.css&lt;/code&gt; that the user edits. Renaming one side is a five-second find-and-replace. I can't rename one side, because both sides are the same generated artifact. The variable name is the token name. That's the deal I made, and I'm not unmaking it for a Tailwind directive.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;@​theme inline&lt;/code&gt; was always going to collapse the indirection on me. I just hadn't noticed yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deleting one word
&lt;/h2&gt;

&lt;p&gt;The fix took longer to think about than to type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* before */&lt;/span&gt;
&lt;span class="k"&gt;@theme&lt;/span&gt; &lt;span class="nb"&gt;inline&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--color-background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--color-background&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/* after */&lt;/span&gt;
&lt;span class="k"&gt;@theme&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--color-background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--color-background&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I had to solve the original self-reference problem a different way, which mostly meant putting the actual hex values directly in &lt;code&gt;@​theme&lt;/code&gt; rather than using &lt;code&gt;var()&lt;/code&gt; references there at all. The hex values go into &lt;code&gt;@​layer theme { :root { ... } }&lt;/code&gt;. The unlayered &lt;code&gt;variables.css&lt;/code&gt; wins the cascade. Dark mode wins inside that. Everything stays addressable through CSS variables, the whole way down.&lt;/p&gt;

&lt;p&gt;The cost showed up in the build output. CSS went from 62.73 KB to 71.20 KB — an extra 8.47 KB, about 13.5% bigger. Gzipped, 11.51 KB to 12.34 KB, so the wire cost is 0.83 KB. The bulk of the increase is the &lt;code&gt;@​layer theme { :root { ... } }&lt;/code&gt; block that now has to ship every token as a real variable definition, plus the &lt;code&gt;color-mix()&lt;/code&gt; expressions Tailwind generates for opacity modifiers (&lt;code&gt;bg-primary/50&lt;/code&gt; and friends), plus &lt;code&gt;@​supports&lt;/code&gt; fallbacks for browsers that don't yet speak &lt;code&gt;color-mix(in oklab, …)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I'll pay that 0.83 KB every day of the week to have dark mode actually work.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I keep thinking about
&lt;/h2&gt;

&lt;p&gt;The thing that bothers me isn't that I wrote the wrong directive. It's that I wrote it straight from the official migration guide, the build passed, the dev server rendered, and I shipped it to npm. Every user on the v4 entry point of the package got a CSS file that ignored their dark mode overrides for as long as that version was current.&lt;/p&gt;

&lt;p&gt;I had tests. I did not have a test that said: "the &lt;code&gt;.dark&lt;/code&gt; selector should actually change pixel colors when applied to &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt; in a real browser." I have one now. It would have caught this in five minutes.&lt;/p&gt;

&lt;p&gt;Most of the bugs in this project end up like that. Not a missing concept — just a missing assertion of the obvious thing everyone assumed was true. &lt;code&gt;inline&lt;/code&gt; is a single word. Dark mode worked. Then it didn't. Then it did again. The diff was four characters.&lt;/p&gt;

&lt;p&gt;The longer postmortem is in the ADR if anyone wants it. The short version fits in a sentence: if your token names and your utility variable names are the same string, &lt;code&gt;@​theme inline&lt;/code&gt; will quietly turn your design system into a screenshot.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next: 22 of 7onic's 42 components shipped with two different import patterns simultaneously. Both worked. One was a mistake, and I didn't find out for three releases.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;About 7onic&lt;/strong&gt; — An open-source React design system where design and code never drift. Free, MIT licensed. Docs and interactive playground at &lt;a href="https://7onic.design" rel="noopener noreferrer"&gt;7onic.design&lt;/a&gt;. Source code on &lt;a href="https://github.com/itonys/7onic" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; — stars appreciated. More posts in this series at &lt;a href="https://blog.7onic.design" rel="noopener noreferrer"&gt;blog.7onic.design&lt;/a&gt;. Follow updates on X at &lt;a href="https://x.com/7onicHQ" rel="noopener noreferrer"&gt;@​7onicHQ&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>tailwindcss</category>
      <category>css</category>
      <category>darkmode</category>
      <category>designsystem</category>
    </item>
    <item>
      <title>Design to Code #5: Using AI to Build a Design System</title>
      <dc:creator>7onic</dc:creator>
      <pubDate>Tue, 26 May 2026 05:39:21 +0000</pubDate>
      <link>https://dev.to/7onic/design-to-code-5-using-ai-to-build-a-design-system-47ih</link>
      <guid>https://dev.to/7onic/design-to-code-5-using-ai-to-build-a-design-system-47ih</guid>
      <description>&lt;p&gt;I gave Claude the Switch component spec and pointed it at &lt;code&gt;variables.css&lt;/code&gt;. What came back — CVA variants, Radix Primitives, forwardRef, controlled and uncontrolled both wired — was genuinely good. Better than my first draft would have been. I shipped it that afternoon without changing much.&lt;/p&gt;

&lt;p&gt;That was sometime in March. By April, I had written three shell scripts whose entire job was to catch Claude reporting things as done when they weren't.&lt;/p&gt;

&lt;p&gt;Not lying about the code. The code was still good.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built for Claude
&lt;/h2&gt;

&lt;p&gt;Before any of the problems, there's the setup. I spent more time building infrastructure for the AI workflow than I'd expected — more than I'd wanted to, honestly.&lt;/p&gt;

&lt;p&gt;The core issue: Claude doesn't know anything about my codebase unless I tell it. And the codebase is specific in ways that matter. Custom token variables, a particular component pattern (Radix + CVA + forwardRef + named exports only), a naming convention, a list of exactly eleven distribution files that &lt;code&gt;sync-tokens&lt;/code&gt; generates from one JSON source. None of that is in anyone's training data.&lt;/p&gt;

&lt;p&gt;So I built &lt;code&gt;llms.txt&lt;/code&gt;. Six variants of it, actually:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;llms.txt&lt;/code&gt; (root) — library overview, installation, quick reference&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tokens/llms.txt&lt;/code&gt; — token package specifics, variable names and categories&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cli/llms.txt&lt;/code&gt; — CLI command reference, flags, dependency resolution&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;public/llms.txt&lt;/code&gt; — lightweight, for AI tools that attach context by URL&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;public/llms-full.txt&lt;/code&gt; — full component API reference with prop types&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;public/llms-cli.txt&lt;/code&gt; — CLI workflow format, import paths already converted to &lt;code&gt;@/components/ui/&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last one exists because the import paths are different when you've copied the source files locally via the CLI, and I got tired of Claude giving users the wrong path in code examples. Small thing. Took a while to notice.&lt;/p&gt;

&lt;p&gt;Beyond the llms.txt files, there's &lt;code&gt;CLAUDE.md&lt;/code&gt; in the repo root. It loads automatically when I start a Claude Code session — the operating manual. Coding rules, token usage rules, the absolute constraints. The patterns that aren't obvious, like "use &lt;code&gt;items-center&lt;/code&gt; always — never &lt;code&gt;items-start&lt;/code&gt; with a &lt;code&gt;mt-0.5&lt;/code&gt; manual offset." Skill files for recurring workflows, so I don't re-explain the same pre-publish checklist every time. A memory directory with facts that persist across sessions — things like "don't add Co-Authored-By to public repo commits" (the repository is under my name; the ownership record matters for the MIT license).&lt;/p&gt;

&lt;p&gt;I didn't build all of this at once. Most of it came after something went wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  42 Components
&lt;/h2&gt;

&lt;p&gt;For component code, the workflow is genuinely useful. Given the spec, the relevant token file, and the established patterns, Claude produces something I can read, understand, and ship with maybe 10-20% editing. The CVA variant structure, the Radix primitive wiring, the TypeScript types — it handles all of that correctly when the context is set up right.&lt;/p&gt;

&lt;p&gt;42 components in &lt;a href="https://7onic.design" rel="noopener noreferrer"&gt;7onic&lt;/a&gt;. I built most of them this way.&lt;/p&gt;

&lt;p&gt;Where it's less reliable: anything that requires holding the full state of the codebase at once. "Does this change break anything else?" is hard for a tool that only sees what you've explicitly shown it. I learned to be specific about scope and stopped expecting it to catch cross-file implications on its own. That part I adapted to. The component code stayed good.&lt;/p&gt;

&lt;p&gt;The problem was somewhere else entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  The v0.3.0 Day
&lt;/h2&gt;

&lt;p&gt;The v0.3.0 release cycle is documented in an Architecture Decision Record now. I keep it not because I enjoy the reading, but because the pattern it describes keeps recurring in subtler forms and it helps to have it written down somewhere.&lt;/p&gt;

&lt;p&gt;In one day — we were landing the breaking change migration that converted 22 compound components to named exports — I got three separate confident reports that something had been verified or completed. None of them were accurate.&lt;/p&gt;

&lt;p&gt;The specifics varied each time. Once, Claude cited a subagent's verification results as its own without re-running the check itself. Once, the substitution count it reported didn't match what was actually in the files — it had counted text occurrences rather than checking structural correctness. Once, "technically verified" meant the code compiled, when what I needed was confirmation that the rendered output matched the expected output across modes.&lt;/p&gt;

&lt;p&gt;Each time, the language was the same: completed, verified, all passing. Each time I believed it and found the problem after.&lt;/p&gt;

&lt;p&gt;The ADR calls this "완료 선언 시 실측 증거 결여" — claiming completion without actual measurement. The problem wasn't that Claude was wrong. It's that it was confident and wrong in a way that looked exactly like confident and right. Nothing in the response distinguished between them.&lt;/p&gt;

&lt;p&gt;After the third one, I closed the laptop and went for a walk. When I came back I wrote an architecture decision record, which is apparently what I do when I need to make sure I don't repeat a mistake.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Got Built After
&lt;/h2&gt;

&lt;p&gt;The first thing was a vocabulary ban. &lt;code&gt;CLAUDE.md §4&lt;/code&gt; now classifies words like "완료", "검증됨", "all clean", "PASS" — used without direct tool output attached — as false reports. The required format for any completion claim is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✓ [item] — 도구: `[command]` → `[output line verbatim]`
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the command and output can't be cited, the required response is "⚠ 검증 안 함" with a stated reason. Not a failure mode to hide — just an honest "I didn't check."&lt;/p&gt;

&lt;p&gt;The second was a gate for commits. &lt;code&gt;scripts/hooks/verify-artifact-gate.sh&lt;/code&gt; blocks commits that touch UI source files unless a specific evidence file exists in &lt;code&gt;/tmp/&lt;/code&gt; — written by the live audit script, timestamped, expiring in 15 minutes. No evidence file, no commit. The hook reads the file. It doesn't read the message.&lt;/p&gt;

&lt;p&gt;Third was &lt;code&gt;scripts/hooks/hundred-percent-detector.sh&lt;/code&gt;, which scans incoming prompts for phrases like "100%", "전수", "완벽하게". When any appear, it forces the session into the &lt;code&gt;/100-percent-verify&lt;/code&gt; protocol — five gated phases, each requiring actual tool output before moving forward.&lt;/p&gt;

&lt;p&gt;The one I added last — after I realized how close I'd come to letting it happen — was &lt;code&gt;scripts/hooks/manual-only-gate.sh&lt;/code&gt;. It blocks &lt;code&gt;npm publish&lt;/code&gt;, &lt;code&gt;gh workflow run publish&lt;/code&gt;, and &lt;code&gt;npm dist-tag&lt;/code&gt; unless a one-time authorization token file exists in &lt;code&gt;.claude/gates/&lt;/code&gt;. I was running a verification workflow and noticed that the publish step was sitting right next to the check steps, and that nothing structural prevented Claude from running it. The commands are manual-only now. That's not a constraint Claude works around; it's a file that doesn't exist until I create it.&lt;/p&gt;

&lt;p&gt;I also flipped the document propagation model around the same time. Previously, Claude would automatically update documentation whenever I changed code. During active development, this meant docs were getting rewritten for iterations I abandoned ten minutes later. The new model is approval-based: Claude scans the git diff at commit time, proposes a propagation plan, waits for yes. Slower, but the docs stop reflecting decisions I've already reversed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where It Still Falls Apart
&lt;/h2&gt;

&lt;p&gt;The hooks cover the specific pattern that broke v0.3.0. Other things still go wrong.&lt;/p&gt;

&lt;p&gt;Verification is the most dangerous mode — it needs explicit forcing to produce actual tool output rather than an interpretation of it. "Check if this is correct everywhere" lands very differently than "read this file, now read this file, compare these specific values." Cross-file consistency is generally unreliable without breaking it down manually.&lt;/p&gt;

&lt;p&gt;Long sessions are the worst. Constraints that were clearly active at the start of a session get quietly dropped by the third or fourth task, with no signal that it happened. I've ended sessions where CLAUDE.md rules followed carefully at the start were just not being applied at the end — not defiantly, just absent. The skills help here: they re-establish rules at the moment they're needed rather than depending on something established hours earlier staying live.&lt;/p&gt;

&lt;p&gt;I still use Claude for almost everything. The component code is genuinely good, and the llms.txt infrastructure plus CLAUDE.md plus the memory system means a new session orients to the codebase fast. The maintenance overhead is real, but it's less than the alternative, which was rebuilding trust from scratch every day.&lt;/p&gt;

&lt;p&gt;The shell hooks exist for the edge cases. I'm glad they're there. I'd be gladder if I didn't need them.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next: How 7onic handles dark mode — no JavaScript, no &lt;code&gt;documentElement.classList&lt;/code&gt; toggling. Just CSS, and why one strategy wasn't enough.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;About 7onic&lt;/strong&gt; — An open-source React design system where design and code never drift. Free, MIT licensed. Docs and interactive playground at &lt;a href="https://7onic.design" rel="noopener noreferrer"&gt;7onic.design&lt;/a&gt;. Source code on &lt;a href="https://github.com/itonys/7onic" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; — stars appreciated. More posts in this series at &lt;a href="https://blog.7onic.design" rel="noopener noreferrer"&gt;blog.7onic.design&lt;/a&gt;. Follow updates on X at &lt;a href="https://x.com/7onicHQ" rel="noopener noreferrer"&gt;@​7onicHQ&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>designsystem</category>
      <category>opensource</category>
      <category>llms</category>
    </item>
    <item>
      <title>Build &amp; Release #2: Five Patches for One Line of CSS</title>
      <dc:creator>7onic</dc:creator>
      <pubDate>Mon, 18 May 2026 14:35:58 +0000</pubDate>
      <link>https://dev.to/7onic/build-release-2-five-patches-for-one-line-of-css-1552</link>
      <guid>https://dev.to/7onic/build-release-2-five-patches-for-one-line-of-css-1552</guid>
      <description>&lt;p&gt;I was running through a quick test on April 27 when I noticed something almost funny. Light background. White-ish text. Just barely legible enough that you could tell text was supposed to be there, like a watermark someone forgot to remove.&lt;/p&gt;

&lt;p&gt;The site was the 7onic documentation, rendered in light mode. My OS, however, was set to dark mode. The culprit? A single line in the body rule targeting &lt;code&gt;--color-foreground&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I had shipped that exact line four days earlier in v0.3.1. I had already patched it once. By that morning, I was about to patch it a second time. (The third patch—the one that actually solved the mystery—wouldn't happen until v0.3.5, and certainly not because I planned it.)&lt;/p&gt;

&lt;p&gt;All in all, it took five releases and four different values for &lt;code&gt;html body { color }&lt;/code&gt; to fix a text color. I patched the output file three separate times without ever touching the generator script—which meant every time I ran &lt;code&gt;npm run sync-tokens&lt;/code&gt;, my pipeline would have silently regenerated the exact bug I was trying to kill.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Original Sin
&lt;/h2&gt;

&lt;p&gt;The chaos started with v0.3.1 on April 23. I introduced &lt;code&gt;tokens/css/reset.css&lt;/code&gt; as a brand-new distribution file, bringing our total file count from 11 to 12. Tucked neatly inside this reset was a body baseline color set to &lt;code&gt;var(--color-foreground)&lt;/code&gt;. In that same release, I added &lt;code&gt;--foreground: var(--color-foreground)&lt;/code&gt; to &lt;code&gt;variables.css&lt;/code&gt; as a compatibility alias for Next.js.&lt;/p&gt;

&lt;p&gt;It blew up the exact same day.&lt;/p&gt;

&lt;p&gt;Next.js has a &lt;code&gt;@​theme inline&lt;/code&gt; directive that emits &lt;code&gt;--color-foreground: var(--foreground)&lt;/code&gt; on its end. Look closely at that loop: &lt;code&gt;--foreground&lt;/code&gt; was pointing to &lt;code&gt;--color-foreground&lt;/code&gt;, which was pointing right back to &lt;code&gt;--foreground&lt;/code&gt;. The browser did exactly what browsers do when caught in a circular reference trap—it gave up and resolved the whole thing to &lt;code&gt;unset&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Hours later, v0.3.2 went out as emergency triage. I pointed &lt;code&gt;--foreground&lt;/code&gt; at &lt;code&gt;var(--color-text)&lt;/code&gt; instead, deleted &lt;code&gt;reset.css&lt;/code&gt; as a standalone file, and embedded the body baseline straight into &lt;code&gt;variables.css&lt;/code&gt;. We were back down to 11 distribution files. I called this Approach Z because I had already burned through too many letters of the alphabet trying to make this work.&lt;/p&gt;

&lt;p&gt;But the body color? It stayed as &lt;code&gt;var(--color-foreground)&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three Days Later: Enter IACVT
&lt;/h2&gt;

&lt;p&gt;April 27. White text on a white background.&lt;/p&gt;

&lt;p&gt;The underlying mechanism took a while to see clearly, mostly because &lt;code&gt;--color-foreground&lt;/code&gt; &lt;em&gt;was&lt;/em&gt; defined in the codebase—it just wasn't defined in &lt;code&gt;variables.css&lt;/code&gt;. It lived exclusively in &lt;code&gt;tokens/tailwind/v4-theme.css&lt;/code&gt;, a file you only ever import if you are actively using Tailwind v4. If a user imported &lt;code&gt;variables.css&lt;/code&gt; standalone, used Tailwind v3, or pulled the raw CSS into a non-Tailwind project, that variable simply didn't exist to them.&lt;/p&gt;

&lt;p&gt;CSS has a terrifyingly specific name for what happens next: IACVT (Invalid At Computed Value Time).&lt;/p&gt;

&lt;p&gt;The browser parses the CSS declaration perfectly fine. But at runtime, it tries to resolve &lt;code&gt;var(--color-foreground)&lt;/code&gt;, realizes it's looking at a ghost, and throws the entire &lt;code&gt;color&lt;/code&gt; declaration away. Not the whole CSS rule—just that one property.&lt;/p&gt;

&lt;p&gt;The cascade then fell back to a global rule I had: &lt;code&gt;html:root { color-scheme: light dark }&lt;/code&gt;. This tells the browser, "Hey, I support both themes, pick whatever makes sense." Since my OS preference was set to dark mode, the browser obligingly picked a light text color for a dark background. Except, the actual background of the docs site was locked into light mode. Light text. Light background. Total invisibility.&lt;/p&gt;

&lt;p&gt;The fix went out as v0.3.4. I swapped the variable from &lt;code&gt;var(--color-foreground)&lt;/code&gt; to &lt;code&gt;var(--foreground)&lt;/code&gt;. Because &lt;code&gt;--foreground&lt;/code&gt; is defined inside &lt;code&gt;variables.css&lt;/code&gt; itself (within the &lt;code&gt;html:root&lt;/code&gt; compatibility alias block), it is guaranteed to be available wherever &lt;code&gt;variables.css&lt;/code&gt; is imported. I verified it locally, pushed, tagged, and called it a day.&lt;/p&gt;

&lt;p&gt;Or so I thought.&lt;/p&gt;

&lt;p&gt;I had patched the compiled output file. I had not looked at the generator.&lt;/p&gt;

&lt;p&gt;The generator was a script named &lt;code&gt;scripts/sync-tokens.ts&lt;/code&gt;, which automatically spits out &lt;code&gt;tokens/css/variables.css&lt;/code&gt;. Back in v0.3.3, when I was messing around with a monospace font baseline, I had touched that generator code and hardcoded it to emit &lt;code&gt;color: var(--color-foreground)&lt;/code&gt;. I had manually hotfixed the output file, but I forgot to fix the robot that writes the file. Nobody noticed. There was no one else to notice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Generator Never Lies
&lt;/h2&gt;

&lt;p&gt;The next day, while working on something completely unrelated, I casually ran &lt;code&gt;npm run sync-tokens&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I checked the git diff. The script had proudly stripped away my manual hotfix and put &lt;code&gt;var(--color-foreground)&lt;/code&gt; right back into production.&lt;/p&gt;

&lt;p&gt;That was the moment.&lt;/p&gt;

&lt;p&gt;Version 0.3.5 shipped on April 28. This time, I fixed the actual generator code to emit &lt;code&gt;var(--color-text)&lt;/code&gt;, and updated the output file to match. The first execution of the fixed script reported &lt;code&gt;1 updated, 12 unchanged&lt;/code&gt;. Running it a second time yielded &lt;code&gt;0 updated, 13 unchanged&lt;/code&gt;. Idempotent. Finally.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--color-text&lt;/code&gt; comes straight from our core theme files: &lt;code&gt;themes/light.css&lt;/code&gt; (resolving to gray-900) and &lt;code&gt;themes/dark.css&lt;/code&gt; (resolving to gray-100). No aliases. No indirection. It is defined in every single supported import permutation because it maps directly back to our Figma Single Source of Truth—the exact source this entire pipeline was built to serve.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decoupling the Three Variables
&lt;/h2&gt;

&lt;p&gt;Looking at the three variables lined up side-by-side makes the architectural hierarchy glaringly obvious:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--color-foreground&lt;/code&gt; is a Tailwind v4 alias. It lives strictly in &lt;code&gt;v4-theme.css&lt;/code&gt; so Tailwind utility classes can map correctly. Outside of Tailwind v4, it does not exist.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--foreground&lt;/code&gt; is a Next.js compatibility alias. It lives in the &lt;code&gt;html:root&lt;/code&gt; block of &lt;code&gt;variables.css&lt;/code&gt; so Next.js's &lt;code&gt;@​theme inline&lt;/code&gt; compiler has something to bind to.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--color-text&lt;/code&gt; is the Figma token. It lives in the core theme files — named after the &lt;code&gt;*-text&lt;/code&gt; token convention in &lt;code&gt;figma-tokens.json&lt;/code&gt;, the same source the entire pipeline was built around.&lt;/p&gt;

&lt;p&gt;The baseline rule inside &lt;code&gt;html body&lt;/code&gt; is the most universal selector in the entire project. By extension, it requires the most universal variable available. That was &lt;code&gt;--color-text&lt;/code&gt; the whole time. It had been sitting quietly in the theme files before any of this madness even started.&lt;/p&gt;

&lt;p&gt;I had instinctively reached for the Tailwind alias because Tailwind was the immediate sandbox environment I was developing in, completely forgetting that the resulting raw CSS file would be consumed by developers who weren't using Tailwind at all.&lt;/p&gt;

&lt;p&gt;The build generator was the only honest witness to what code I was actually shipping to production. I just wasn't bothered to ask it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next: 22 of the 42 components in &lt;a href="https://7onic.design" rel="noopener noreferrer"&gt;7onic&lt;/a&gt; shipped with two different import patterns at the same time. Both worked. One was a mistake.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;About 7onic&lt;/strong&gt; — An open-source React design system where design and code never drift. Free, MIT licensed. Docs and interactive playground at &lt;a href="https://7onic.design" rel="noopener noreferrer"&gt;7onic.design&lt;/a&gt;. Source code on &lt;a href="https://github.com/itonys/7onic" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; — stars appreciated. More posts in this series at &lt;a href="https://blog.7onic.design" rel="noopener noreferrer"&gt;blog.7onic.design&lt;/a&gt;. Follow updates on X at &lt;a href="https://x.com/7onicHQ" rel="noopener noreferrer"&gt;@​7onicHQ&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>css</category>
      <category>designtokens</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Build &amp; Release #1: How Apple's rsync Update Nuked My Repo</title>
      <dc:creator>7onic</dc:creator>
      <pubDate>Mon, 18 May 2026 09:05:32 +0000</pubDate>
      <link>https://dev.to/7onic/build-release-5-how-apples-rsync-update-nuked-my-repo-37e9</link>
      <guid>https://dev.to/7onic/build-release-5-how-apples-rsync-update-nuked-my-repo-37e9</guid>
      <description>&lt;p&gt;The sync script hadn't changed. Same flags, same paths, same output format. I'd been running it every single time I pushed a change to the public repo, and it had always been rock solid.&lt;/p&gt;

&lt;p&gt;Until April 8th. I ran the script, and the terminal output looked perfectly normal. But when I double-checked the local public repo directory, something was missing.&lt;/p&gt;

&lt;p&gt;No &lt;code&gt;.git&lt;/code&gt; folder. It was just... gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why There's a Script at All
&lt;/h2&gt;

&lt;p&gt;A quick bit of context: &lt;a href="https://7onic.design" rel="noopener noreferrer"&gt;7onic&lt;/a&gt; lives a double life across two repositories. There's a private repo where the full project lives — the docs site, design tokens, internal tooling, the whole nine yards. And then there's the public repo (&lt;code&gt;itonys/7onic&lt;/code&gt;) that contains only the open-source library: the core components, the CLI, and the token package.&lt;/p&gt;

&lt;p&gt;To keep them in sync, I wrote a script that uses rsync to copy the relevant directories from private to public, filter out the internal fluff, and push the clean code.&lt;/p&gt;

&lt;p&gt;Now, the &lt;code&gt;.git&lt;/code&gt; folder in that public directory is what makes it a Git repository in the first place. It holds every commit, every branch, every tag, and most importantly, your remote configuration — the stuff that tells Git, "Hey, when I type &lt;code&gt;git push&lt;/code&gt;, send this code to this exact GitHub URL." If you delete that folder, you lose your entire local history. Fortunately, the remote on GitHub stays intact, so a quick &lt;code&gt;git clone&lt;/code&gt; will save your skin. But any local-only state? Completely vaporized.&lt;/p&gt;

&lt;p&gt;My script explicitly included &lt;code&gt;--filter='P .git'&lt;/code&gt;. That P stands for Protect — a standard GNU rsync flag that tells the engine, "Whatever you do, do not touch anything named &lt;code&gt;.git&lt;/code&gt;." I had verified it. It had been working flawlessly for weeks.&lt;/p&gt;

&lt;p&gt;What I didn't know was that macOS had quietly pulled a fast one on me. A recent OS update had swapped out the native GNU rsync binary for &lt;code&gt;openrsync&lt;/code&gt;, Apple's own BSD-licensed re-implementation. And guess what? &lt;code&gt;openrsync&lt;/code&gt; doesn't give a damn about &lt;code&gt;--filter='P .git'&lt;/code&gt; the same way GNU does.&lt;/p&gt;

&lt;p&gt;Combined with the &lt;code&gt;--delete-excluded&lt;/code&gt; flag — which ruthlessly purges files in the destination that match an exclude rule — the public repo's &lt;code&gt;.git&lt;/code&gt; folder was silently executed on the very next sync.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix That Made It Worse
&lt;/h2&gt;

&lt;p&gt;My immediate, panic-fueled instinct was: Okay, rsync needs to keep its hands off &lt;code&gt;.git&lt;/code&gt; entirely.&lt;/p&gt;

&lt;p&gt;The obvious move was to tweak the flags. But in my state of confusion over which flag was doing what, I made the classic mistake of changing two things at once: I ripped out the protect flag and kept the exclude.&lt;/p&gt;

&lt;p&gt;Which meant rsync looked at the source directory — the private repo — and copied its &lt;code&gt;.git&lt;/code&gt; folder right over to the public destination.&lt;/p&gt;

&lt;p&gt;The public repo had a &lt;code&gt;.git&lt;/code&gt; folder again, sure. But it was a clone of the private repo's brain. This meant:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The public directory's &lt;code&gt;origin&lt;/code&gt; remote now pointed directly at the private GitHub repository.&lt;/li&gt;
&lt;li&gt;Any commit made from inside the public directory would bypass the public repo and push straight to the private remote.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I didn't realize this immediately. To verify my "fix" worked, I casually ran a test commit.&lt;/p&gt;

&lt;p&gt;That commit flew straight into the private remote. I stared at the terminal git log for a long, quiet minute.&lt;/p&gt;

&lt;p&gt;Oh, no.&lt;/p&gt;

&lt;h2&gt;
  
  
  321 Files Deleted. 93,300 Lines Gone.
&lt;/h2&gt;

&lt;p&gt;I managed to untangle the origin mess first — wiped the imposter &lt;code&gt;.git&lt;/code&gt; folder, ran a fresh &lt;code&gt;git clone&lt;/code&gt; from GitHub to restore the actual public history, and got the public repo pointing back to itself.&lt;/p&gt;

&lt;p&gt;But now, my local working tree was a crime scene. There was a bad commit (&lt;code&gt;13edc14&lt;/code&gt;) sitting there from the whole ordeal that needed to be reconciled. I decided to merge it.&lt;/p&gt;

&lt;p&gt;I did not run &lt;code&gt;git diff --stat&lt;/code&gt; first. Big mistake.&lt;/p&gt;

&lt;p&gt;The merge took the private repo's file structure as the absolute source of truth and aggressively subtracted everything the public repo wasn't supposed to have. Which, as it turned out, was almost the entire codebase.&lt;/p&gt;

&lt;p&gt;321 files deleted. 93,300 lines of code wiped from existence.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;site.config.ts&lt;/code&gt; got completely overwritten with the private version. &lt;code&gt;package.json&lt;/code&gt; followed suit. What followed was a cascade of four consecutive errors. I fixed each one blindly, treating the symptoms without looking at the larger picture, only to trigger the next error. It was that specific kind of miserable debugging session where you're chasing ghosts because you refuse to admit how deep the hole actually is.&lt;/p&gt;

&lt;p&gt;The ultimate salvation was running &lt;code&gt;git reset --hard f3453d8&lt;/code&gt; — rolling back to the last clean commit before the madness started. It worked instantly and cleanly. The actual damage was luckily contained to my local working state and one embarrassing, stray commit on the private remote.&lt;/p&gt;

&lt;p&gt;But it cost me four hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Fix
&lt;/h2&gt;

&lt;p&gt;The root problem was putting blind faith in rsync to protect a critical folder like &lt;code&gt;.git&lt;/code&gt;. Any solution that relies on a specific CLI implementation behaving perfectly across different OS flavors is fundamentally fragile. GNU rsync and &lt;code&gt;openrsync&lt;/code&gt; are simply not the same beast.&lt;/p&gt;

&lt;p&gt;The new approach completely eliminates rsync from the equation of trust:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Physically move .git completely out of rsync's line of sight&lt;/span&gt;
&lt;span class="nv"&gt;GIT_BACKUP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/tmp/.7onic-git-backup-&lt;/span&gt;&lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DEST&lt;/span&gt;&lt;span class="s2"&gt;/.git"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GIT_BACKUP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;trap&lt;/span&gt; &lt;span class="s1"&gt;'mv "$GIT_BACKUP" "$DEST/.git" 2&amp;gt;/dev/null'&lt;/span&gt; EXIT

&lt;span class="c"&gt;# rsync runs with .git physically absent from the destination&lt;/span&gt;
rsync &lt;span class="nt"&gt;-av&lt;/span&gt; &lt;span class="nt"&gt;--delete&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--exclude&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'.git'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SRC&lt;/span&gt;&lt;span class="s2"&gt;/"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DEST&lt;/span&gt;&lt;span class="s2"&gt;/"&lt;/span&gt;

&lt;span class="c"&gt;# Restore it like nothing happened&lt;/span&gt;
&lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GIT_BACKUP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DEST&lt;/span&gt;&lt;span class="s2"&gt;/.git"&lt;/span&gt;
&lt;span class="nb"&gt;trap&lt;/span&gt; - EXIT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's a three-layer defense: physically eject &lt;code&gt;.git&lt;/code&gt; to &lt;code&gt;/tmp&lt;/code&gt; before rsync even initializes so there's nothing to delete, tell rsync to exclude it anyway just to be safe, and then slide it back into place afterward. The &lt;code&gt;trap EXIT&lt;/code&gt; ensures that even if rsync crashes or the script aborts mid-run, the restore still fires. No flags to guess. No implementation quirks to worry about.&lt;/p&gt;

&lt;p&gt;It hasn't broken once since April 8th.&lt;/p&gt;

&lt;h2&gt;
  
  
  Post-Mortem
&lt;/h2&gt;

&lt;p&gt;The merge was the ultimate unforced error. By the time I hit that step, I had already fixed the remote origin issue — I was technically in recovery mode, not still drowning. A single &lt;code&gt;git diff --stat&lt;/code&gt; before hitting enter on that merge would have screamed "321 DELETIONS" and I would have stopped immediately.&lt;/p&gt;

&lt;p&gt;I didn't check because I was still trapped in "fix things fast" mode, which is historically the absolute worst mindset for carefully reviewing a git merge.&lt;/p&gt;

&lt;p&gt;The initial script fix would have been fine too if I had just changed one thing at a time. Instead, I altered two flags simultaneously while my brain was fried, which is exactly how you end up copying a private &lt;code&gt;.git&lt;/code&gt; directory somewhere it has absolutely no business being.&lt;/p&gt;

&lt;p&gt;I've added one final line to the end of the sync script now: &lt;code&gt;git -C "$DEST" remote -v&lt;/code&gt;. It prints the active origin remote after every single sync. It takes half a second. If it ever accidentally shows the private remote URL again, I'll catch it instantly before a test commit can make a fool out of me.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next: The same week I broke the sync script, I also shipped a color bug five times in a row — each fix introducing a new way to break the same thing.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;About 7onic&lt;/strong&gt; — An open-source React design system where design and code never drift. Free, MIT licensed. Docs and interactive playground at &lt;a href="https://7onic.design" rel="noopener noreferrer"&gt;7onic.design&lt;/a&gt;. Source code on &lt;a href="https://github.com/itonys/7onic" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; — stars appreciated. More posts in this series at &lt;a href="https://blog.7onic.design" rel="noopener noreferrer"&gt;blog.7onic.design&lt;/a&gt;. Follow updates on X at &lt;a href="https://x.com/7onicHQ" rel="noopener noreferrer"&gt;@​7onicHQ&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>git</category>
      <category>opensource</category>
      <category>rsync</category>
    </item>
    <item>
      <title>Component Anatomy #1: Perfect on Paper, Wrong in Production</title>
      <dc:creator>7onic</dc:creator>
      <pubDate>Mon, 18 May 2026 08:37:27 +0000</pubDate>
      <link>https://dev.to/7onic/component-anatomy-1-perfect-on-paper-wrong-in-production-1ekp</link>
      <guid>https://dev.to/7onic/component-anatomy-1-perfect-on-paper-wrong-in-production-1ekp</guid>
      <description>&lt;p&gt;I have an Architecture Decision Record (ADR) file sitting in my repo dated February 19, 2026. It contains three beautifully written paragraphs arguing why our xs button should be 28px instead of 24px. At the bottom of that file is a neat little table mapping out our definitive button size scale. Five rows: xs, sm, default, lg, and xl.&lt;/p&gt;

&lt;p&gt;The xl row was set to a beefy 56px.&lt;/p&gt;

&lt;p&gt;I deleted that entire row on March 11, 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  What xl Was Supposed to Do
&lt;/h2&gt;

&lt;p&gt;As a list, the scale made perfect mathematical sense:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;xs (28px): For tight secondary actions, toolbars, and dense data tables.&lt;/li&gt;
&lt;li&gt;sm (32px): For compact user interfaces.&lt;/li&gt;
&lt;li&gt;default (40px): The golden baseline.&lt;/li&gt;
&lt;li&gt;lg (48px): For prominent primary actions that needed breathing room.&lt;/li&gt;
&lt;li&gt;xl (56px): For... prominent hero actions? Massive landing page CTAs?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To be completely honest, I wasn't entirely sure what xl was actually for when I defined it. But man, it filled out the scale in a satisfying way. There was a comforting logic to it: each step up was roughly 8 to 12 pixels larger than the last, capping out at 56px — a number that felt solidly "large but not absurd." On paper, it was flawless.&lt;/p&gt;

&lt;p&gt;The illusion shattered the moment I put an actual 56px button into an actual UI layout.&lt;/p&gt;

&lt;p&gt;It looked immediately wrong. Not subtly wrong, but off-the-charts wrong. The kind of wrong where you instinctively double-check your code to see if you accidentally applied the wrong utility class, because surely this monstrosity wasn't what you intended. I tried shoving it into card footers, form submissions, modal confirmation rows, and sidebar navigations. It was a disaster everywhere. Our default (40px) already felt substantial enough in those contexts; at 56px, the button started aggressively picking fights with the section headers.&lt;/p&gt;

&lt;p&gt;Sure, there are interfaces where a button that massive makes sense — onboarding welcome screens, app store download buttons, or aggressive marketing landing pages where the entire design exists to funnel you into a single click. But those edge cases aren't what a core design system is built for. A design system size needs to work repeatedly, across different components, in different contexts. xl never did. It was a theoretical size that looked pretty in a token file and made zero sense in a real product.&lt;/p&gt;

&lt;p&gt;So, I took a knife to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Gap I Didn't See Coming
&lt;/h2&gt;

&lt;p&gt;What I didn't anticipate was that cutting xl would immediately expose a glaring flaw in the rest of the scale.&lt;/p&gt;

&lt;p&gt;With xl gone, I was left with four remaining sizes: xs (28px), sm (32px), default (40px), and lg (48px). I stared at that list for a long time, and something felt deeply unsettling. The jump from sm (32px) to default (40px) suddenly felt like a massive chasm. Eight pixels sounds tiny on paper, until you place those two buttons side by side in a real layout. sm is tight and compact. default is chunky and substantial. There was an entire register of UI missing in between.&lt;/p&gt;

&lt;p&gt;Think about toolbars where default feels way too heavy but sm looks like an accidental misclick. Or secondary actions in a form that need to feel slightly more important than a naked text link, but still play second fiddle to the main CTA.&lt;/p&gt;

&lt;p&gt;I added md at 36px the exact same day I axed xl.&lt;/p&gt;

&lt;p&gt;Now, 36 isn't a particularly magical number. It's just 4px above sm and 4px below default, which at least scratched my itch for symmetry. My actual validation process was less numerical and more vibes-based: I tried 34px and 38px first. 34px basically blurred into sm on a standard screen. 38px just felt visually illegal because it wasn't a clean multiple of 4 (I know, it's completely irrational — &lt;a href="https://7onic.design" rel="noopener noreferrer"&gt;7onic&lt;/a&gt;'s spacing scale already breaks the 4px rule in plenty of places — but my brain just couldn't shake it). 36px was the only one that slipped into the layout and looked like it belonged there without causing a scene.&lt;/p&gt;

&lt;p&gt;And just like that, the new scale was born: xs (28px) / sm (32px) / md (36px) / default (40px) / lg (48px).&lt;/p&gt;

&lt;p&gt;Same five sizes. But a completely different five sizes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why 28 and Not 24
&lt;/h2&gt;

&lt;p&gt;The logic behind our xs size is the one piece of this entire puzzle that remained completely untouched between February and March.&lt;/p&gt;

&lt;p&gt;WCAG 2.5.8 sets the minimum touch target size at 24px. That's the AA criterion — the absolute floor below which your UI is officially considered inaccessible. I'd seen plenty of other design systems set their smallest buttons to 24px just to check that compliance box.&lt;/p&gt;

&lt;p&gt;But I drew the line and set our xs to 28px.&lt;/p&gt;

&lt;p&gt;The reasoning, as documented in our ADR, is straightforward: WCAG's 24px is a minimum, not a target goal. The Apple HIG recommends 44pt. Material Design pushes for 48dp. Every mature, battle-tested design system sits comfortably well above the WCAG floor — not out of corporate generosity, but because 24px is genuinely too small for a human finger to interact with comfortably. On a cramped mobile screen, inside a packed toolbar, with an average thumb having a clumsy day, those 4 extra pixels matter in a way that's hard to articulate but instantly felt.&lt;/p&gt;

&lt;p&gt;There was also a dirty little practical reason: &lt;code&gt;h-7&lt;/code&gt; is exactly 28px in Tailwind. &lt;code&gt;h-6&lt;/code&gt; is 24px. While the accessibility argument absolutely came first, I won't pretend the clean alignment with Tailwind's token scale didn't put a massive smile on my face.&lt;/p&gt;

&lt;h2&gt;
  
  
  Back to Square One (But Better)
&lt;/h2&gt;

&lt;p&gt;The ultimate irony of this whole saga is that we shipped with five sizes anyway. I deleted one in March, added one back in March, and ended up exactly where I started numerically.&lt;/p&gt;

&lt;p&gt;It's been a few months of putting this scale through its paces in production now. Hilariously, md (36px) has turned out to be the size I reach for the most when building secondary actions — way more than sm (32px), which I initially thought would dominate that territory. Meanwhile, lg (48px) makes appearances far less often than I anticipated. And I have yet to open a component file in a real product and think, "You know what this needs? A 56px xl button."&lt;/p&gt;

&lt;p&gt;Is this scale absolutely perfect? Honestly, I don't know. But the goal was never perfection; it was utility. We wanted a size for every context without hoarding sizes that serve no context. Right now, all five of these sizes actively earn their keep — which is a hell of a lot more than xl could ever say.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next: 22 of the 42 components in &lt;a href="https://7onic.design" rel="noopener noreferrer"&gt;7onic&lt;/a&gt; shipped with two different import patterns at the same time — a namespace style and a named style. Both worked. One was a mistake. Here's how I figured out which.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;About 7onic&lt;/strong&gt; — An open-source React design system where design and code never drift. Free, MIT licensed. Docs and interactive playground at &lt;a href="https://7onic.design" rel="noopener noreferrer"&gt;7onic.design&lt;/a&gt;. Source code on &lt;a href="https://github.com/itonys/7onic" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; — stars appreciated. More posts in this series at &lt;a href="https://blog.7onic.design" rel="noopener noreferrer"&gt;blog.7onic.design&lt;/a&gt;. Follow updates on X at &lt;a href="https://x.com/7onicHQ" rel="noopener noreferrer"&gt;@​7onicHQ&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>components</category>
      <category>designsystem</category>
      <category>designtokens</category>
      <category>tailwindcss</category>
    </item>
    <item>
      <title>Token Deep Dive #1: The /50 That Did Nothing</title>
      <dc:creator>7onic</dc:creator>
      <pubDate>Mon, 18 May 2026 07:37:31 +0000</pubDate>
      <link>https://dev.to/7onic/token-deep-dive-2-the-50-that-did-nothing-3a3e</link>
      <guid>https://dev.to/7onic/token-deep-dive-2-the-50-that-did-nothing-3a3e</guid>
      <description>&lt;p&gt;The other day, I noticed something funky with my accordion component's hover state.&lt;/p&gt;

&lt;p&gt;It wasn't broken in an obvious, "error-thrown-in-console" kind of way. The background color changed when I hovered over it, sure, but the opacity was completely gone. I had explicitly coded &lt;code&gt;hover:bg-background-muted/50&lt;/code&gt;, but instead of a nice, subtle 50% tint, I was getting a solid, opaque slap of color. Same went for my glassmorphism effects (&lt;code&gt;bg-white/10&lt;/code&gt;) — just pure, blinding white.&lt;/p&gt;

&lt;p&gt;At first, I went down the usual rabbit holes: Is it a specificity issue? Did I miss an import somewhere?&lt;/p&gt;

&lt;p&gt;It took me longer than I'd like to admit to connect the dots to a change I'd pushed a few days prior: converting every single color in our &lt;a href="https://7onic.design" rel="noopener noreferrer"&gt;7onic&lt;/a&gt; Tailwind v3 preset from hardcoded HEX values to CSS &lt;code&gt;var()&lt;/code&gt; references.&lt;/p&gt;

&lt;p&gt;At the time, I thought I was a genius. I thought I was building the ultimate clean architecture.&lt;/p&gt;

&lt;p&gt;Spoiler alert: I wasn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "Single Source of Truth" That Betrayed Me
&lt;/h2&gt;

&lt;p&gt;The dream was beautiful. I was setting up a token pipeline where &lt;code&gt;figma-tokens.json&lt;/code&gt; was the Single Source of Truth. Run one build script, and it spits out eleven different distribution formats — CSS variables, Tailwind presets, TypeScript types, you name it.&lt;/p&gt;

&lt;p&gt;The goal was simple: keep the Tailwind v3 preset perfectly synchronized with those Figma tokens without the nightmare of maintaining separate, duplicated HEX values in a JS file.&lt;/p&gt;

&lt;p&gt;So I mapped every token reference to a CSS variable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;colors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;DEFAULT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;var(--color-primary)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;muted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;var(--color-background-muted)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at that. Whenever a designer shifted a hex code in Figma, &lt;code&gt;npm run sync-tokens&lt;/code&gt; would handle the rest downstream.&lt;/p&gt;

&lt;p&gt;Except the moment this shipped, &lt;code&gt;bg-primary/50&lt;/code&gt; died. So did &lt;code&gt;border-border/60&lt;/code&gt;, &lt;code&gt;text-foreground/80&lt;/code&gt;, &lt;code&gt;bg-gray-500/30&lt;/code&gt;. Every single opacity modifier across the entire component library silently stopped working.&lt;/p&gt;

&lt;p&gt;The truly embarrassing part? I had literally written the documentation for this pipeline a month earlier, explicitly stating that Tailwind v3 opacity modifiers require decomposable color values. I read it. I wrote it. And then I completely ignored it because I was blinded by how clean the &lt;code&gt;var()&lt;/code&gt; refactoring looked. Classic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Tailwind v3 Chokes on CSS Variables
&lt;/h2&gt;

&lt;p&gt;Here's the technical reality: Tailwind v3 handles opacity modifiers at build time.&lt;/p&gt;

&lt;p&gt;When the JIT compiler scans your code and sees &lt;code&gt;bg-primary/50&lt;/code&gt;, it looks up &lt;code&gt;primary&lt;/code&gt; in your Tailwind config. It expects a raw value — like a HEX or RGB string — that it can physically decompose into individual R, G, B channels to output &lt;code&gt;rgba(R, G, B, 0.5)&lt;/code&gt;. This is all build-time string manipulation.&lt;/p&gt;

&lt;p&gt;When you pass it &lt;code&gt;var(--color-primary)&lt;/code&gt;, the compiler hits a wall. It has no idea what RGB values that variable will resolve to, because that resolution happens at runtime in the browser. So instead of throwing an error, it just discards the &lt;code&gt;/50&lt;/code&gt; alpha channel entirely and outputs the base color as a solid block.&lt;/p&gt;

&lt;p&gt;Tailwind v4 doesn't suffer from this. It delegates the heavy lifting to the browser using the native &lt;code&gt;color-mix()&lt;/code&gt; function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* What bg-primary/50 compiles to in Tailwind v4 */&lt;/span&gt;
&lt;span class="nt"&gt;background-color&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nt"&gt;color-mix&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;in&lt;/span&gt; &lt;span class="nt"&gt;srgb&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;var&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;--color-primary&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="err"&gt;50&lt;/span&gt;&lt;span class="o"&gt;%,&lt;/span&gt; &lt;span class="nt"&gt;transparent&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Since the browser handles the blending at runtime, v4 doesn't need to know the raw RGB channels at build time. But when I was building this, forcing everyone onto v4 wasn't an option — and the 7onic token package had to support both v3 and v4.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Three-Tier Compromise
&lt;/h2&gt;

&lt;p&gt;Fixing the static, primitive colors like &lt;code&gt;gray-500&lt;/code&gt; was easy. I swallowed my pride and reverted them back to raw HEX strings: &lt;code&gt;gray-500: '#78787C'&lt;/code&gt;. These core palette shades never change based on the active theme anyway, so hardcoding them in the JS preset was fine, and it instantly brought opacity modifiers back to life.&lt;/p&gt;

&lt;p&gt;Semantic colors were a different problem. &lt;code&gt;background-muted&lt;/code&gt; needs to resolve to a light gray in light mode and a dark charcoal in dark mode. It has to be a CSS variable at runtime to handle theme toggling — but it also needs to be decomposable at build time for Tailwind v3.&lt;/p&gt;

&lt;p&gt;Tailwind v3 has a built-in escape hatch for exactly this dilemma. Instead of &lt;code&gt;'var(--color-background-muted)'&lt;/code&gt;, you write &lt;code&gt;'rgb(var(--color-background-muted-rgb) / &amp;lt;alpha-value&amp;gt;)'&lt;/code&gt;. The &lt;code&gt;&amp;lt;alpha-value&amp;gt;&lt;/code&gt; is a special placeholder Tailwind swaps out with the actual opacity percentage during compilation. The catch: you need a companion CSS variable that holds only the space-separated R G B numbers, not a full HEX code.&lt;/p&gt;

&lt;p&gt;The three-tier breakdown:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Preset format&lt;/th&gt;
&lt;th&gt;Reason&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Primitive colors (gray-500, primary-400…)&lt;/td&gt;
&lt;td&gt;HEX directly&lt;/td&gt;
&lt;td&gt;Static values, &lt;code&gt;/50&lt;/code&gt; works out of the box&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Semantic colors (primary, background…)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;rgb(var(--*-rgb) / &amp;lt;alpha-value&amp;gt;)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Theme-aware at runtime + alpha at build time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Non-color tokens (spacing, radius…)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;var()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Opacity doesn't apply here&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The preset ends up looking like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;colors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;DEFAULT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rgb(var(--color-primary-rgb) / &amp;lt;alpha-value&amp;gt;)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;muted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rgb(var(--color-background-muted-rgb) / &amp;lt;alpha-value&amp;gt;)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the theme CSS files expose those raw companion variables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;--color-background-muted&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nt"&gt;var&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;--color-gray-100&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="nt"&gt;--color-background-muted-rgb&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="err"&gt;244&lt;/span&gt; &lt;span class="err"&gt;244&lt;/span&gt; &lt;span class="err"&gt;246&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;--color-background-muted&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nt"&gt;var&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;--color-gray-700&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="nt"&gt;--color-background-muted-rgb&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="err"&gt;60&lt;/span&gt; &lt;span class="err"&gt;60&lt;/span&gt; &lt;span class="err"&gt;60&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the theme changes, the browser swaps the underlying RGB numbers. Tailwind's pre-baked alpha placeholder handles the transparency. &lt;code&gt;hover:bg-background-muted/50&lt;/code&gt; works correctly in both modes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Automating 135 Companion Variables Because I Value My Sanity
&lt;/h2&gt;

&lt;p&gt;I had 82 primitive color tokens and 53 semantic colors per theme. Calculating and writing those &lt;code&gt;-rgb&lt;/code&gt; channel variables by hand was not happening — it's tedious and drifts the moment a token value changes.&lt;/p&gt;

&lt;p&gt;So I hooked it into &lt;code&gt;sync-tokens.ts&lt;/code&gt;. A &lt;code&gt;hexToRgb&lt;/code&gt; helper converts &lt;code&gt;#F4F4F6&lt;/code&gt; → &lt;code&gt;"244 244 246"&lt;/code&gt; (space-separated, which is what modern CSS color syntax expects):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hexToRgb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;([&lt;/span&gt;&lt;span class="sr"&gt;0-9a-f&lt;/span&gt;&lt;span class="se"&gt;]{2})([&lt;/span&gt;&lt;span class="sr"&gt;0-9a-f&lt;/span&gt;&lt;span class="se"&gt;]{2})([&lt;/span&gt;&lt;span class="sr"&gt;0-9a-f&lt;/span&gt;&lt;span class="se"&gt;]{2})&lt;/span&gt;&lt;span class="sr"&gt;$/i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now every time the build pipeline runs, the script reads the original HEX values from &lt;code&gt;figma-tokens.json&lt;/code&gt;, extracts the raw channels, and outputs both variables side by side. The source file stays clean. The &lt;code&gt;-rgb&lt;/code&gt; values are derived, not stored — generated automatically, never manually maintained.&lt;/p&gt;

&lt;p&gt;For semantic tokens, the script resolves the full reference chain (semantic → primitive → final HEX) for both light and dark modes, emitting the correct channel values per theme.&lt;/p&gt;

&lt;h2&gt;
  
  
  The One Gotcha That Still Trips People Up
&lt;/h2&gt;

&lt;p&gt;If you're using &lt;code&gt;@​7onic-ui/tokens&lt;/code&gt; in a Tailwind v3 project and opacity modifiers suddenly vanish, 9 times out of 10 it's a missing theme import.&lt;/p&gt;

&lt;p&gt;Importing just &lt;code&gt;variables.css&lt;/code&gt; isn't enough. Because semantic &lt;code&gt;-rgb&lt;/code&gt; variables have different values per theme, they live inside &lt;code&gt;light.css&lt;/code&gt; and &lt;code&gt;dark.css&lt;/code&gt;. If those theme files aren't imported, the &lt;code&gt;-rgb&lt;/code&gt; variables are undefined — and &lt;code&gt;bg-background-muted/50&lt;/code&gt; either renders transparent or drops the background entirely, depending on the browser.&lt;/p&gt;

&lt;p&gt;It's documented right there in the getting started guide. Some lessons are best learned by breaking it first.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next: v3 and v4 dual support from one token source — how the two presets stay in sync and why the v3 format necessarily does more work.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;About 7onic&lt;/strong&gt; — An open-source React design system where design and code never drift. Free, MIT licensed. Docs and interactive playground at &lt;a href="https://7onic.design" rel="noopener noreferrer"&gt;7onic.design&lt;/a&gt;. Source code on &lt;a href="https://github.com/itonys/7onic" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; — stars appreciated. More posts in this series at &lt;a href="https://blog.7onic.design" rel="noopener noreferrer"&gt;blog.7onic.design&lt;/a&gt;. Follow updates on X at &lt;a href="https://x.com/7onicHQ" rel="noopener noreferrer"&gt;@​7onicHQ&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>designtokens</category>
      <category>tailwindcss</category>
      <category>cssvariables</category>
      <category>tailwindv3</category>
    </item>
    <item>
      <title>Design to Code #4: Why I Chose Radix Over Custom Primitives</title>
      <dc:creator>7onic</dc:creator>
      <pubDate>Fri, 24 Apr 2026 13:34:50 +0000</pubDate>
      <link>https://dev.to/7onic/design-to-code-4-why-i-chose-radix-over-custom-primitives-50eo</link>
      <guid>https://dev.to/7onic/design-to-code-4-why-i-chose-radix-over-custom-primitives-50eo</guid>
      <description>&lt;p&gt;I spent an entire afternoon trying to write a focus trap from scratch.&lt;/p&gt;

&lt;p&gt;The requirement seemed dead simple: when a modal is open, the Tab key should cycle through elements inside it—and nowhere else. When the modal closes, focus should snap back to whatever triggered it. I'd seen this in production apps a thousand times. How hard could it be? I sat down, cracked my knuckles, and started coding.&lt;/p&gt;

&lt;p&gt;The first version worked... until I tested it with a portal. Since the modal was rendering outside the main DOM tree, my trap simply missed it. Fixed that. Then, Tab landed on a &lt;code&gt;contenteditable&lt;/code&gt; element inside the modal, which my focusable query hadn't accounted for. Fixed that. Then I realized I'd completely ignored Shift+Tab. Fixed it. Finally, I fired up Safari with VoiceOver, and the screen reader didn't even acknowledge the thing was a modal—the ARIA was a mess.&lt;/p&gt;

&lt;p&gt;At that point, I stopped fixing things and started asking myself if I was even the right person to be fixing them.&lt;/p&gt;

&lt;p&gt;I deleted the file and looked up Radix UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  The accessibility argument is real, but...
&lt;/h2&gt;

&lt;p&gt;Saying "Radix handles accessibility" is technically true, but it's also the kind of thing people say when they want to end a conversation.&lt;/p&gt;

&lt;p&gt;The real story is more nuanced. Focus management in overlays isn't the kind of "hard" where you find the right answer once and you're done. It's the kind where combinations of browsers, assistive technologies, and OS versions behave in wildly inconsistent ways. The only way to find what's broken is to test it—systematically, on actual devices, with actual screen readers.&lt;/p&gt;

&lt;p&gt;The Radix team has been shipping this since 2020. Their &lt;code&gt;Dialog&lt;/code&gt; handles focus locks in portals. &lt;code&gt;Select&lt;/code&gt; and &lt;code&gt;RadioGroup&lt;/code&gt; implement roving tabindex so arrow keys work exactly how screen reader users expect. &lt;code&gt;Toast&lt;/code&gt; doesn't scream duplicate announcements into the ARIA live region. These behaviors didn't appear by magic; they're the result of years of iteration and real-world bug reports.&lt;/p&gt;

&lt;p&gt;I'm one person building a system with 42 components. Spending my hours on focus management in overlays is a poor use of time. It wasn't about checking a "Radix handles A11y" box—it was realizing that a dedicated team had already solved a class of problem I wasn't equipped to handle as well as they were.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other reason: truly zero styles
&lt;/h2&gt;

&lt;p&gt;What people often understate is that Radix ships with genuinely zero CSS. Not "easy to override" or "CSS-variable-based." Just... nothing. You bring the design tokens; Radix brings the interaction semantics.&lt;/p&gt;

&lt;p&gt;This is a massive win when you're building on a token system. &lt;a href="https://7onic.design" rel="noopener noreferrer"&gt;7onic&lt;/a&gt; generates CSS custom properties, Tailwind v3 presets, Tailwind v4 &lt;code&gt;@​theme&lt;/code&gt; blocks, and TypeScript types from a single &lt;code&gt;figma-tokens.json&lt;/code&gt;. The last thing that pipeline needs is a component library with hardcoded opinions about what "primary" looks like or what a dropdown's border radius should be.&lt;/p&gt;

&lt;p&gt;Radix doesn't have those opinions. It's a skeleton I put skin on. Because there's no overlap between the token system and the component library, they can't contradict each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Radix is not perfect
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0trvaf4nifqi3r70xnwe.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0trvaf4nifqi3r70xnwe.gif" alt=" " width="600" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There was one specific behavior that took me way too long to figure out.&lt;/p&gt;

&lt;p&gt;When you select an option in a &lt;code&gt;Select&lt;/code&gt; or close a &lt;code&gt;DropdownMenu&lt;/code&gt; with a mouse click, Radix calls &lt;code&gt;.focus()&lt;/code&gt; on the trigger element as it closes. This is correct for keyboard users—after navigating a menu with arrow keys and hitting Enter, focus should return to the trigger so they can keep tabbing through the page.&lt;/p&gt;

&lt;p&gt;The catch? If you used arrow keys at any point inside the dropdown before clicking with your mouse, the browser remembers that as "keyboard modality." So when Radix calls &lt;code&gt;.focus()&lt;/code&gt; programmatically, the browser applies &lt;code&gt;:focus-visible&lt;/code&gt; to the trigger. Result: you click with a mouse, the menu closes, and the trigger suddenly gets a focus ring for no reason.&lt;/p&gt;

&lt;p&gt;It looks like a visual glitch. I spent ages thinking it was a CSS bug in my token output. It wasn't.&lt;/p&gt;

&lt;p&gt;The fix is calling &lt;code&gt;e.preventDefault()&lt;/code&gt; in the &lt;code&gt;onCloseAutoFocus&lt;/code&gt; handler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;onCloseAutoFocus&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nx"&gt;onCloseAutoFocus&lt;/span&gt;&lt;span class="p"&gt;?.(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tradeoff is that after a keyboard-close, focus no longer returns to the trigger—Tab will land on whatever comes next in the DOM. For most use cases, this is fine. For specific keyboard workflows, it might not be. I documented the decision, shipped it, and moved on.&lt;/p&gt;

&lt;p&gt;That's what using Radix actually feels like: you delegate the hard problems, only to discover that "delegated" doesn't mean "invisible." The quirks are real, but they're localized and workable—which is a much better place to be than owning the entire problem yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The path not taken
&lt;/h2&gt;

&lt;p&gt;I looked at Headless UI. Similar philosophy, but at the time, their API leaned more toward render props and transitions. For a system where I'm defining the component APIs anyway, Radix's compositional model (&lt;code&gt;Select.Content&lt;/code&gt;, &lt;code&gt;Select.Item&lt;/code&gt;) was much easier to keep consistent across 42 components.&lt;/p&gt;

&lt;p&gt;React Aria from Adobe was also on the table. It's more comprehensive but also significantly more complex. Their hook-based API offers more granular control, but requires a lot more wiring per component. For a design system where I need a solid baseline but aren't shipping a low-level primitive library, it was more control than I actually needed.&lt;/p&gt;

&lt;p&gt;Building from scratch? That was off the table after my afternoon with the focus trap. Some problems are solved well enough that trying to re-solve them is just stubbornness.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where things stand
&lt;/h2&gt;

&lt;p&gt;All the interactive components in 7onic—Dialog, Select, Tabs, Accordion, and the rest—use Radix under the hood. The presentational ones like Skeleton or Spinner don't touch it because there's nothing to delegate.&lt;/p&gt;

&lt;p&gt;The decision has held up. I've shipped components I wouldn't have trusted myself to build alone, and the accessibility baseline is higher than anything I could have achieved on my own. Some of that is Radix; some of it is that committing to Radix early forced me to think about keyboard behavior and ARIA patterns before I otherwise would have.&lt;/p&gt;

&lt;p&gt;I still don't have a professional screen reader testing setup. I do basic VoiceOver checks, but "doesn't sound broken to me" isn't a substitute for "correct." It's on the list. It keeps getting pushed down the list.&lt;/p&gt;

&lt;p&gt;That's probably the most honest thing I can say about accessibility in a solo-built design system: the foundation is better than it would be without Radix, but it's still not as thorough as it should be. Both things are true.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next: 42 components, and every interactive one has at least five size variants. The smallest is 28px. Here's why it's not 24px, and what WCAG 2.5.8 actually says about touch targets.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;About 7onic&lt;/strong&gt; — An open-source React design system where design and code never drift. Free, MIT licensed. Docs and interactive playground at &lt;a href="https://7onic.design" rel="noopener noreferrer"&gt;7onic.design&lt;/a&gt;. Source code on &lt;a href="https://github.com/itonys/7onic" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; — stars appreciated. More posts in this series at &lt;a href="https://blog.7onic.design" rel="noopener noreferrer"&gt;blog.7onic.design&lt;/a&gt;. Follow updates on X at &lt;a href="https://x.com/7onicHQ" rel="noopener noreferrer"&gt;@​7onicHQ&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>radixui</category>
      <category>a11y</category>
      <category>react</category>
      <category>designsystem</category>
    </item>
    <item>
      <title>Design to Code #3: Copy-Paste vs npm Install</title>
      <dc:creator>7onic</dc:creator>
      <pubDate>Fri, 24 Apr 2026 11:33:09 +0000</pubDate>
      <link>https://dev.to/7onic/design-to-code-3-copy-paste-vs-npm-install-2ai5</link>
      <guid>https://dev.to/7onic/design-to-code-3-copy-paste-vs-npm-install-2ai5</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fir0104h3fuy15ldin306.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fir0104h3fuy15ldin306.gif" alt=" " width="600" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The first thing I did after publishing &lt;code&gt;@​7onic-ui/react@​0.1.0&lt;/code&gt; was install it in a test project.&lt;/p&gt;

&lt;p&gt;It worked. Imports resolved, buttons rendered, and Tailwind classes applied perfectly. I sat there for a moment feeling good about this. Then I wanted to double-check the focus ring behavior, so I went to inspect the source.&lt;/p&gt;

&lt;p&gt;What I found was &lt;code&gt;node_modules/@​7onic-ui/react/dist/index.mjs&lt;/code&gt;—a bundled, transformed mess with variable names half-mangled by the build step. I could see the output, but I couldn't truly read it. For most packages, that's the whole point. But for a design system, it felt wrong. The entire premise of this project was transparency, yet my code was hidden behind a build artifact.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I shipped vs. what I should have shipped
&lt;/h2&gt;

&lt;p&gt;The npm package made sense at the time. If you want a Button, you &lt;code&gt;npm install @​7onic-ui/react&lt;/code&gt;, &lt;code&gt;import { Button }&lt;/code&gt;, and you're done. It's the standard approach, it genuinely works, and it's still live for those who prefer it.&lt;/p&gt;

&lt;p&gt;But the problem wasn't a bug; it was the abstraction model.&lt;/p&gt;

&lt;p&gt;A utility library like lodash benefits from being a black box. You don't care how &lt;code&gt;_.debounce&lt;/code&gt; is implemented as long as it debounces. You import it, use it, and update it. Component libraries are different. When a button doesn't look quite right in your layout, you need to see the source. When you need a prop that doesn't exist, you need to modify it. When your TypeScript config is stricter than mine, a compiled &lt;code&gt;.js&lt;/code&gt; file won't help you debug why your build is failing.&lt;/p&gt;

&lt;p&gt;A design system is something you should own. The traditional package model says "I maintain this, you consume it." That's the wrong relationship.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five days later
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@​7onic-ui/react@​0.1.0&lt;/code&gt; went live on April 4th. By April 9th, I was already building a CLI.&lt;/p&gt;

&lt;p&gt;The idea was simple: instead of importing from a compiled package, you run &lt;code&gt;npx 7onic add button&lt;/code&gt; and the actual &lt;code&gt;.tsx&lt;/code&gt; source code is copied directly into your project. It lands in &lt;code&gt;src/components/ui/button.tsx&lt;/code&gt;—readable, and entirely yours. No &lt;code&gt;node_modules&lt;/code&gt; involved. The exact file living in the &lt;a href="https://7onic.design" rel="noopener noreferrer"&gt;7onic&lt;/a&gt; repo is what ends up in your codebase.&lt;/p&gt;

&lt;p&gt;I wasn't inventing this—shadcn/ui had been doing it for a while. But I hadn't fully appreciated why until I spent a few days trying to be a consumer of my own npm package.&lt;/p&gt;

&lt;p&gt;The registry approach works like this: the CLI bundles all 40 component source files (~456KB) and when you run &lt;code&gt;add&lt;/code&gt;, it writes them directly to your disk. No transformation, no obfuscation. You can open it, change it, or even break it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx 7onic add button input &lt;span class="k"&gt;select&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also handles dependencies automatically. &lt;code&gt;add input&lt;/code&gt; realizes it needs a field wrapper and pulls that in too. &lt;code&gt;add button-group&lt;/code&gt; knows to fetch &lt;code&gt;button&lt;/code&gt; if it's missing. Building the topological sort for this was a rabbit hole in itself. (Writing tests for dependency resolution is as boring as it sounds.)&lt;/p&gt;

&lt;h2&gt;
  
  
  What this actually enables
&lt;/h2&gt;

&lt;p&gt;The obvious benefit is customization. You can modify &lt;code&gt;button.tsx&lt;/code&gt; directly—add a custom variant or tweak a default class—without forking a repo or waiting for a PR to merge.&lt;/p&gt;

&lt;p&gt;But there was a less obvious win regarding TypeScript.&lt;/p&gt;

&lt;p&gt;Vite's default template sets &lt;code&gt;noUnusedLocals: true&lt;/code&gt; in &lt;code&gt;tsconfig.app.json&lt;/code&gt;. In my initial &lt;code&gt;card.tsx&lt;/code&gt;, I had a &lt;code&gt;sizePaddingMap&lt;/code&gt; object intended for future use but not yet wired up. In my own dev environment, &lt;code&gt;eslint-disable&lt;/code&gt; handled it fine. But in a user's Vite project, &lt;code&gt;tsc&lt;/code&gt; would simply fail the build.&lt;/p&gt;

&lt;p&gt;In a compiled npm package, that's a "wait for a fix" situation — I'd need to publish, users would need to update. With source files, the user can see the issue and fix it instantly. And once I pushed the official fix—deleting the unused object—the registry updated so the next &lt;code&gt;npx 7onic add&lt;/code&gt; gets the clean version. The friction of a version dependency became just a file in your project.&lt;/p&gt;

&lt;p&gt;There's also an AI angle I hadn't anticipated. When source code is buried in &lt;code&gt;node_modules&lt;/code&gt;, tools like Claude or Copilot can't easily see it without explicit context. When it lives in &lt;code&gt;src/components/ui/&lt;/code&gt;, it's part of your codebase. "Modify the Button component" becomes a direct, actionable instruction instead of something that requires explaining what's in the package first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trade-off
&lt;/h2&gt;

&lt;p&gt;Nothing is free.&lt;/p&gt;

&lt;p&gt;When I update &lt;code&gt;button.tsx&lt;/code&gt; with a bug fix or a new variant, users who copied the file don't get it automatically. They have to run &lt;code&gt;npx 7onic add button --overwrite&lt;/code&gt;, which many won't do. The code copied in April is the code they'll likely be running in October.&lt;/p&gt;

&lt;p&gt;With an npm package, &lt;code&gt;npm update @​7onic-ui/react&lt;/code&gt; handles that.&lt;/p&gt;

&lt;p&gt;It's a trade-off between convenience and ownership. If you want to stay in sync with upstream, use the package. If you want to own your UI and treat it as a snapshot in time, the CLI is the way to go.&lt;/p&gt;

&lt;p&gt;I don't think one is always right. But the copy-paste model better matches how developers actually treat design systems. Nobody updates their component library every week. The files get copied, maybe tweaked, and then just sit there doing their job. At least with the source approach, what's sitting in your folder is human-readable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The package still exists
&lt;/h2&gt;

&lt;p&gt;I still publish &lt;code&gt;@​7onic-ui/react&lt;/code&gt; with every release. My GitHub Actions workflow publishes the npm package and regenerates the CLI registry simultaneously.&lt;/p&gt;

&lt;p&gt;There are teams with strict policies about what lives in &lt;code&gt;src/&lt;/code&gt;, or monorepos that prefer package boundaries. For them, &lt;code&gt;import { Button } from '@​7onic-ui/react'&lt;/code&gt; is still the right call.&lt;/p&gt;

&lt;p&gt;I just don't think it's the default anymore.&lt;/p&gt;

&lt;p&gt;The CLI launched five days after the first npm release. That wasn't a planned timeline — it was a reaction. I wrote the install instructions for the package, tried to follow them myself, and spent the rest of the week building an alternative. Sometimes the fastest way to realize what's wrong with what you've built is to use it as if you didn't build it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next: 42 components in, there are patterns I'd never go back on — and at least three I'd design completely differently.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;About 7onic&lt;/strong&gt; — An open-source React design system where design and code never drift. Free, MIT licensed. Docs and interactive playground at &lt;a href="https://7onic.design" rel="noopener noreferrer"&gt;7onic.design&lt;/a&gt;. Source code on &lt;a href="https://github.com/itonys/7onic" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; — stars appreciated. More posts in this series at &lt;a href="https://blog.7onic.design" rel="noopener noreferrer"&gt;blog.7onic.design&lt;/a&gt;. Follow updates on X at &lt;a href="https://x.com/7onicHQ" rel="noopener noreferrer"&gt;@​7onicHQ&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>designsystem</category>
      <category>cli</category>
      <category>opensource</category>
      <category>react</category>
    </item>
    <item>
      <title>Tailwind Guides #1: Supporting Tailwind v3 and v4 Was Brutal</title>
      <dc:creator>7onic</dc:creator>
      <pubDate>Fri, 17 Apr 2026 11:02:03 +0000</pubDate>
      <link>https://dev.to/7onic/tailwind-guides-1-what-actually-broke-migrating-to-v4-485o</link>
      <guid>https://dev.to/7onic/tailwind-guides-1-what-actually-broke-migrating-to-v4-485o</guid>
      <description>&lt;p&gt;I was halfway through shipping a component update when v4 dropped. My design system, &lt;a href="https://7onic.design" rel="noopener noreferrer"&gt;7onic&lt;/a&gt;, has to work with both Tailwind v3 and v4 — same components, same token source, two different output formats. So my reaction to the v4 announcement was less "exciting new features" and more "great, another output target to maintain."&lt;/p&gt;

&lt;p&gt;I read the migration guide. It covered the syntax changes fine. What it didn't cover was that three of those changes would silently break things in ways that produced zero error messages and took hours to track down.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Config File Moved Into CSS
&lt;/h2&gt;

&lt;p&gt;You've probably heard this one. &lt;code&gt;tailwind.config.js&lt;/code&gt; becomes CSS. In v3, I had this big JavaScript preset mapping tokens to Tailwind's config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;colors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;DEFAULT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rgb(var(--color-primary-rgb) / &amp;lt;alpha-value&amp;gt;)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;hover&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rgb(var(--color-primary-hover-rgb) / &amp;lt;alpha-value&amp;gt;)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;borderRadius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;sm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;var(--radius-sm)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;lg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;var(--radius-lg)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In v4:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@theme&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--color-primary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#15A0AC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--color-primary-hover&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#107A84&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--radius-sm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--radius-lg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The nice part is &lt;code&gt;bg-primary/50&lt;/code&gt; just works now — v4 uses &lt;code&gt;color-mix()&lt;/code&gt; internally, so you don't need the &lt;code&gt;rgb()&lt;/code&gt; channel hack anymore. But what I actually appreciate more is the debugging. When a utility doesn't work in v3, I have to trace through JavaScript config merging logic. In v4, I open a CSS file and read it. Sounds small. It's not.&lt;/p&gt;

&lt;h2&gt;
  
  
  @​theme inline Killed My Dark Mode
&lt;/h2&gt;

&lt;p&gt;OK so this is the one I'm still kind of mad about.&lt;/p&gt;

&lt;p&gt;I used &lt;code&gt;@​theme inline&lt;/code&gt; because the docs said it avoids variable name collisions. Sounded reasonable. Everything worked in light mode. I toggled dark mode and the page just... stayed light.&lt;/p&gt;

&lt;p&gt;I checked everything. &lt;code&gt;.dark&lt;/code&gt; class on html — yes. CSS variables updating in DevTools — yes. Dark stylesheet loaded — yes. I even slapped a &lt;code&gt;background: red !important&lt;/code&gt; rule inside my dark block just to prove the file was being read. It was. Variables were changing. But the actual page colors didn't move.&lt;/p&gt;

&lt;p&gt;I wish I could say I figured it out quickly. I didn't. I spent an entire afternoon going in circles before I finally opened the compiled CSS and saw this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* What I assumed Tailwind was generating */&lt;/span&gt;
&lt;span class="nc"&gt;.bg-primary&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--color-primary&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/* What @theme inline ACTUALLY generated */&lt;/span&gt;
&lt;span class="nc"&gt;.bg-primary&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#15A0AC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;@​theme inline&lt;/code&gt; resolves everything at build time. It takes your CSS variables and replaces them with literal hex values in the output. So at runtime, your dark mode variables update correctly — but the utility classes aren't looking at variables anymore. They have hardcoded light-mode colors baked in.&lt;/p&gt;

&lt;p&gt;The fix was just removing the word &lt;code&gt;inline&lt;/code&gt;. Build size went up by 8.5KB (0.8KB gzipped). I cannot stress enough how little I care about that tradeoff.&lt;/p&gt;

&lt;p&gt;What bugs me is the naming. "inline" sounds like a performance optimization or a scoping strategy. It doesn't sound like "we will throw away all your CSS variables and hardcode the resolved values." If the flag were called &lt;code&gt;@​theme static&lt;/code&gt; or &lt;code&gt;@​theme resolved&lt;/code&gt;, I would have caught this in five minutes instead of five hours. But it is what it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Plugins Became @​utility
&lt;/h2&gt;

&lt;p&gt;Not much to say here honestly. v3 plugins become &lt;code&gt;@​utility&lt;/code&gt; blocks in CSS. I generate about 50 custom utilities (icon sizes, durations, z-index layers, focus rings) and the migration was completely mechanical. The v4 version is easier to read. Moving on.&lt;/p&gt;

&lt;h2&gt;
  
  
  @​source Failed Without Telling Me
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;content&lt;/code&gt; array is now &lt;code&gt;@​source&lt;/code&gt; in CSS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@source&lt;/span&gt; &lt;span class="s1"&gt;"../src/**/*.{ts,tsx}"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I wrote the path relative to the project root because that's what &lt;code&gt;content&lt;/code&gt; used. Tailwind generated an empty stylesheet. No error, no warning, nothing in the terminal. I spent twenty minutes convinced my PostCSS setup was broken before I realized: &lt;code&gt;@​source&lt;/code&gt; paths are relative to the CSS file, not the project root.&lt;/p&gt;

&lt;p&gt;This is the kind of bug where you feel stupid once you figure it out, but also — a warning would be nice? "Hey, that glob matched zero files" would save a lot of people a lot of time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Variant Stacking Order Reversed
&lt;/h2&gt;

&lt;p&gt;This one's painful if you support both versions.&lt;/p&gt;

&lt;p&gt;v3 stacks variant selectors right-to-left. v4 stacks left-to-right. Same words, different order, different result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// v3 — innermost first&lt;/span&gt;
&lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[&amp;amp;_div]:data-[state=checked]:bg-primary&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="c1"&gt;// v4 — outermost first&lt;/span&gt;
&lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;data-[state=checked]:[&amp;amp;_div]:bg-primary&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I found this in the Switch component. The toggle track styled correctly in v3, silently didn't apply in v4. No error — the generated CSS was valid, it just didn't match the DOM.&lt;/p&gt;

&lt;p&gt;I don't have a great answer for this. My component code avoids complex variant stacking and the docs show v3/v4 examples side by side. It works, but it's not elegant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dark Mode: Three Selectors for One Job
&lt;/h2&gt;

&lt;p&gt;v3 dark mode was &lt;code&gt;darkMode: 'class'&lt;/code&gt; and you're done. v4 defaults to &lt;code&gt;prefers-color-scheme&lt;/code&gt;, which follows the OS. That's a better default until your user wants to force light mode while their OS is set to dark — because you can't override a media query from JavaScript.&lt;/p&gt;

&lt;p&gt;I ended up with this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* Follow OS, unless user explicitly chose light */&lt;/span&gt;
&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefers-color-scheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;dark&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nd"&gt;:root:not&lt;/span&gt;&lt;span class="o"&gt;([&lt;/span&gt;&lt;span class="nt"&gt;data-theme&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;"light"&lt;/span&gt;&lt;span class="o"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="py"&gt;--color-background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--color-gray-900&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="py"&gt;--color-text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--color-gray-100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/* Manual override */&lt;/span&gt;
&lt;span class="nd"&gt;:root&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-theme&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;"dark"&lt;/span&gt;&lt;span class="o"&gt;],&lt;/span&gt;
&lt;span class="nd"&gt;:root&lt;/span&gt;&lt;span class="nc"&gt;.dark&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--color-background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--color-gray-900&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--color-text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--color-gray-100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;:not([data-theme="light"])&lt;/code&gt; is what makes "force light" possible. The &lt;code&gt;.dark&lt;/code&gt; class is v3 compat. Three selectors for the same variable declarations feels wrong, but each one handles a scenario the others can't, and I couldn't find a way to collapse them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Outline Flash
&lt;/h2&gt;

&lt;p&gt;Oh, this one. Tailwind v4 added &lt;code&gt;outline-color&lt;/code&gt; to &lt;code&gt;transition-colors&lt;/code&gt;. Inputs with focus rings now animate the outline appearing, which looks like a brief flash. I didn't notice for weeks — only caught it during unrelated side-by-side v3/v4 testing.&lt;/p&gt;

&lt;p&gt;Fix: &lt;code&gt;outline-transparent&lt;/code&gt; on the base state. One class. Applied it to Input, Textarea, and Select. The kind of thing nobody would file a bug about — you'd just feel like something was slightly off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Opacity Modifiers Got Good
&lt;/h2&gt;

&lt;p&gt;In v3, &lt;code&gt;bg-primary/50&lt;/code&gt; with CSS variables required decomposing every color into RGB channels. I generated 135 extra &lt;code&gt;--*-rgb&lt;/code&gt; variables for this. Every new color token meant two more CSS variables. It worked, but it was a hack that I maintained grudgingly.&lt;/p&gt;

&lt;p&gt;v4 uses &lt;code&gt;color-mix()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;background-color&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nt"&gt;color-mix&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;in&lt;/span&gt; &lt;span class="nt"&gt;srgb&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;var&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;--color-primary&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="err"&gt;50&lt;/span&gt;&lt;span class="o"&gt;%,&lt;/span&gt; &lt;span class="nt"&gt;transparent&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Any color format. No channel decomposition. I still generate the &lt;code&gt;-rgb&lt;/code&gt; variables for v3 users, but the day I drop v3 support, an entire pipeline stage disappears.&lt;/p&gt;

&lt;p&gt;Honestly this might be my favorite v4 change, even though it's the least dramatic. Removing a hack you've been carrying around for months feels disproportionately good.&lt;/p&gt;




&lt;p&gt;If you're about to migrate: check dark mode first. Not "does it toggle" — check that every color you expect to change actually changes. That's where &lt;code&gt;@​theme inline&lt;/code&gt; hides, that's where the media query vs class assumption lives, and that's where I wasted the most time.&lt;/p&gt;

&lt;p&gt;The rest is mostly find-and-replace. Dark mode is where your v3 instincts will lie to you.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next: I mentioned the &lt;code&gt;rgb()&lt;/code&gt; channel hack for v3 opacity modifiers. That hack is part of a bigger story — how CSS variables and Tailwind actually interact, and why most guides get the setup wrong.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;About 7onic&lt;/strong&gt; — An open-source React design system where design and code never drift. Free, MIT licensed. Docs and interactive playground at &lt;a href="https://7onic.design" rel="noopener noreferrer"&gt;7onic.design&lt;/a&gt;. Source code on &lt;a href="https://github.com/itonys/7onic" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; — stars appreciated. More posts in this series at &lt;a href="https://blog.7onic.design" rel="noopener noreferrer"&gt;blog.7onic.design&lt;/a&gt;. Follow updates on X at &lt;a href="https://x.com/7onicHQ" rel="noopener noreferrer"&gt;@​7onicHQ&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>tailwindcss</category>
      <category>tailwindv4</category>
      <category>migration</category>
      <category>css</category>
    </item>
    <item>
      <title>Solo Builder #1: What Nobody Tells You</title>
      <dc:creator>7onic</dc:creator>
      <pubDate>Fri, 17 Apr 2026 04:43:31 +0000</pubDate>
      <link>https://dev.to/7onic/solo-builder-1-what-nobody-tells-you-59e1</link>
      <guid>https://dev.to/7onic/solo-builder-1-what-nobody-tells-you-59e1</guid>
      <description>&lt;p&gt;Before I started building &lt;a href="https://7onic.design" rel="noopener noreferrer"&gt;7onic&lt;/a&gt;, I googled one very specific phrase:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;solo design system&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I expected at least a few useful battle stories.&lt;/p&gt;

&lt;p&gt;Instead, I found conference talks from teams of twenty, blog posts about "scaling component libraries," and articles that casually assumed I had designers, engineers, PMs, reviewers, and someone named Alex who owned tokens.&lt;/p&gt;

&lt;p&gt;I had none of those people.&lt;/p&gt;

&lt;p&gt;It was just me.&lt;/p&gt;

&lt;p&gt;So I learned what building a design system alone actually looks like the hard way.&lt;/p&gt;

&lt;p&gt;Here's what I wish I'd known.&lt;/p&gt;

&lt;h2&gt;
  
  
  You will make every decision, every day
&lt;/h2&gt;

&lt;p&gt;On a team, decisions get distributed.&lt;/p&gt;

&lt;p&gt;Someone owns tokens.&lt;br&gt;
Someone owns components.&lt;br&gt;
Someone owns docs.&lt;br&gt;
Someone owns accessibility.&lt;br&gt;
Someone joins the meeting late and disagrees with everything.&lt;/p&gt;

&lt;p&gt;That structure can be slow, but it spreads the mental load.&lt;/p&gt;

&lt;p&gt;When you're solo, every open question lands on your desk.&lt;/p&gt;

&lt;p&gt;Should the border radius be 6px or 8px?&lt;br&gt;
Do we support RTL now or later?&lt;br&gt;
What happens when someone passes both &lt;code&gt;variant&lt;/code&gt; and &lt;code&gt;className&lt;/code&gt;?&lt;br&gt;
Should defaults be strict or forgiving?&lt;/p&gt;

&lt;p&gt;None of these questions are difficult on their own.&lt;/p&gt;

&lt;p&gt;But answer fifty of them in a day — while writing code, docs, release notes, CLI tooling, and fixing one mysterious TypeScript issue — and it becomes draining in a way I didn't expect.&lt;/p&gt;

&lt;p&gt;For the first month, I kept a decision log.&lt;/p&gt;

&lt;p&gt;Then I stopped.&lt;/p&gt;

&lt;p&gt;Because logging every decision introduced a new decision:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Is this decision worth logging?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;What finally helped was a simpler rule:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Decide once. Document briefly. Move on.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not every choice deserves a framework.&lt;br&gt;
Most choices deserve ten seconds and a code comment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nobody will catch your mistakes
&lt;/h2&gt;

&lt;p&gt;This hit me around the third component.&lt;/p&gt;

&lt;p&gt;I had built a &lt;code&gt;Button&lt;/code&gt; with five size variants:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;xs&lt;/code&gt;, &lt;code&gt;sm&lt;/code&gt;, &lt;code&gt;md&lt;/code&gt;, &lt;code&gt;default&lt;/code&gt;, &lt;code&gt;lg&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Beautiful API. Typed props. Looked clean.&lt;/p&gt;

&lt;p&gt;Two weeks later I realized &lt;code&gt;md&lt;/code&gt; and &lt;code&gt;default&lt;/code&gt; were visually identical.&lt;/p&gt;

&lt;p&gt;Both were 36px.&lt;/p&gt;

&lt;p&gt;I had duplicated a token value by accident.&lt;/p&gt;

&lt;p&gt;Which meant I had:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;written the bug&lt;/li&gt;
&lt;li&gt;reviewed the bug&lt;/li&gt;
&lt;li&gt;approved the bug&lt;/li&gt;
&lt;li&gt;shipped the bug&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All personally.&lt;/p&gt;

&lt;p&gt;There's no pull request where a teammate says:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Wait... why do two sizes look exactly the same?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That moment taught me something important:&lt;/p&gt;

&lt;p&gt;When you build solo, your QA process is just future-you.&lt;/p&gt;

&lt;p&gt;And future-you is inconsistent.&lt;/p&gt;

&lt;p&gt;I've shipped duplicate variants.&lt;br&gt;
Exported components with the wrong display name.&lt;br&gt;
Published a version where dark mode didn't work because I tested in light mode and called it done.&lt;/p&gt;

&lt;p&gt;I still don't have a perfect solution.&lt;/p&gt;

&lt;p&gt;What helps a little:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;visual regression checklists&lt;/li&gt;
&lt;li&gt;writing changelog notes before release&lt;/li&gt;
&lt;li&gt;opening docs as if I were a new user&lt;/li&gt;
&lt;li&gt;forcing one final pass when I'm already tired and want to skip it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is glamorous.&lt;/p&gt;

&lt;p&gt;But neither is debugging yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scope creep has no natural predator
&lt;/h2&gt;

&lt;p&gt;On a product team, eventually someone says:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;That's out of scope.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A PM says it.&lt;br&gt;
A tech lead says it.&lt;br&gt;
A deadline says it.&lt;/p&gt;

&lt;p&gt;Solo builders often have none of those voices.&lt;/p&gt;

&lt;p&gt;Which means every idea feels valid and immediately actionable.&lt;/p&gt;

&lt;p&gt;I originally planned to build a CLI that installs components.&lt;/p&gt;

&lt;p&gt;Somehow that turned into:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;typo suggestions using Dice coefficient similarity&lt;/li&gt;
&lt;li&gt;package manager auto-detection from lockfiles&lt;/li&gt;
&lt;li&gt;Tailwind v4 auto-injection&lt;/li&gt;
&lt;li&gt;JSON schema support for IDE autocomplete&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each feature was reasonable.&lt;/p&gt;

&lt;p&gt;Each feature improved the product.&lt;/p&gt;

&lt;p&gt;Together, they delayed launch by two months.&lt;/p&gt;

&lt;p&gt;That's the trap:&lt;/p&gt;

&lt;p&gt;When you're solo, scope creep rarely feels like scope creep.&lt;/p&gt;

&lt;p&gt;It feels like craftsmanship.&lt;/p&gt;

&lt;p&gt;And sometimes it is.&lt;/p&gt;

&lt;p&gt;But sometimes the right feature is shipping.&lt;/p&gt;

&lt;p&gt;Now before I add anything, I write one sentence:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What problem does this solve for someone installing 7onic today?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If I have to stretch to answer it, the feature waits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Momentum gets weird in open source
&lt;/h2&gt;

&lt;p&gt;Shipping internally gives immediate feedback.&lt;/p&gt;

&lt;p&gt;Someone uses it tomorrow.&lt;br&gt;
Someone complains by lunch.&lt;br&gt;
Someone asks for a new prop by Friday.&lt;/p&gt;

&lt;p&gt;The loop is fast.&lt;/p&gt;

&lt;p&gt;Shipping to open source can feel very different.&lt;/p&gt;

&lt;p&gt;You release something you spent three weeks building.&lt;/p&gt;

&lt;p&gt;The next morning looks exactly the same as yesterday.&lt;/p&gt;

&lt;p&gt;No dashboard spike.&lt;br&gt;
No coworker message.&lt;br&gt;
No usage report.&lt;/p&gt;

&lt;p&gt;Maybe a few GitHub stars trickle in — and I'm genuinely grateful for every one — but early open source has a kind of silence to it.&lt;/p&gt;

&lt;p&gt;You need a different feedback loop.&lt;/p&gt;

&lt;p&gt;For me, writing helped.&lt;/p&gt;

&lt;p&gt;Not for traffic. There wasn't much.&lt;/p&gt;

&lt;p&gt;But because explaining a decision to an imaginary reader forces clarity.&lt;/p&gt;

&lt;p&gt;Whenever I couldn't explain why I built something a certain way, that was usually a sign the decision wasn't solid yet.&lt;/p&gt;

&lt;p&gt;This blog became part of the product process.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hidden half of the work
&lt;/h2&gt;

&lt;p&gt;People see components.&lt;/p&gt;

&lt;p&gt;They don't see the rest.&lt;/p&gt;

&lt;p&gt;Building components might be half the job.&lt;/p&gt;

&lt;p&gt;The other half is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;figuring out why TypeScript generated nonsense types&lt;/li&gt;
&lt;li&gt;debugging CI that works locally but fails remotely&lt;/li&gt;
&lt;li&gt;rewriting docs for the third time because the API changed again&lt;/li&gt;
&lt;li&gt;setting up smoke tests&lt;/li&gt;
&lt;li&gt;maintaining release notes&lt;/li&gt;
&lt;li&gt;answering occasional issues&lt;/li&gt;
&lt;li&gt;cleaning scripts no one will ever praise&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of it is dramatic.&lt;/p&gt;

&lt;p&gt;All of it consumes time.&lt;/p&gt;

&lt;p&gt;And when you're solo, all of that time belongs to you.&lt;/p&gt;

&lt;p&gt;Some weeks the components barely move because infrastructure needed attention first.&lt;/p&gt;

&lt;p&gt;That used to frustrate me.&lt;/p&gt;

&lt;p&gt;Now I see it more clearly:&lt;/p&gt;

&lt;p&gt;The product is one system.&lt;br&gt;
The tooling is another.&lt;br&gt;
Both need maintenance.&lt;/p&gt;

&lt;h2&gt;
  
  
  The upside people explain badly
&lt;/h2&gt;

&lt;p&gt;Everything above is true.&lt;/p&gt;

&lt;p&gt;But it's incomplete.&lt;/p&gt;

&lt;p&gt;There's a benefit to working alone that's harder to describe:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Coherence.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not freedom.&lt;br&gt;
Not control.&lt;br&gt;
Not "being your own boss."&lt;/p&gt;

&lt;p&gt;Coherence.&lt;/p&gt;

&lt;p&gt;The whole system fits in your head.&lt;/p&gt;

&lt;p&gt;If I want to change dark mode, I know exactly where to go.&lt;br&gt;
If I add a token, I know the full path from &lt;code&gt;figma-tokens.json&lt;/code&gt; to runtime output.&lt;br&gt;
If something feels inconsistent, I usually know why.&lt;/p&gt;

&lt;p&gt;No handoff.&lt;br&gt;
No ownership map.&lt;br&gt;
No "let me check with the person who manages that."&lt;br&gt;
No waiting.&lt;/p&gt;

&lt;p&gt;Just change it.&lt;/p&gt;

&lt;p&gt;Teams optimize for coordination.&lt;/p&gt;

&lt;p&gt;Solo builders can optimize for consistency.&lt;/p&gt;

&lt;p&gt;Those are different advantages.&lt;/p&gt;

&lt;p&gt;And for a design system — where consistency is the product — that matters more than people admit.&lt;/p&gt;

&lt;h2&gt;
  
  
  I didn't expect to like it this much
&lt;/h2&gt;

&lt;p&gt;Working alone is tiring in all the ways I described.&lt;/p&gt;

&lt;p&gt;It can be repetitive, lonely, slow, and mentally noisy.&lt;/p&gt;

&lt;p&gt;But it's also deeply satisfying in ways I didn't predict.&lt;/p&gt;

&lt;p&gt;There's something special about seeing a system become cleaner because you touched every layer of it.&lt;/p&gt;

&lt;p&gt;I still wouldn't romanticize solo building.&lt;/p&gt;

&lt;p&gt;But I also wouldn't dismiss it.&lt;/p&gt;

&lt;p&gt;Sometimes one person with enough stubbornness can build something surprisingly coherent.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next: I don't do code reviews in the traditional sense. Here's what I do instead — and what I've learned to catch by forcing myself through it.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;About 7onic&lt;/strong&gt; — An open-source React design system where design and code never drift. Free, MIT licensed. Docs and interactive playground at &lt;a href="https://7onic.design" rel="noopener noreferrer"&gt;7onic.design&lt;/a&gt;. Source code on &lt;a href="https://github.com/itonys/7onic" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; — stars appreciated. More posts in this series at &lt;a href="https://blog.7onic.design" rel="noopener noreferrer"&gt;blog.7onic.design&lt;/a&gt;. Follow updates on X at &lt;a href="https://x.com/7onicHQ" rel="noopener noreferrer"&gt;@7onicHQ&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>solobuilder</category>
      <category>designsystem</category>
      <category>opensource</category>
      <category>indiedev</category>
    </item>
  </channel>
</rss>
