<?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>Forem: 7onic</title>
    <description>The latest articles on Forem by 7onic (@7onic).</description>
    <link>https://forem.com/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>Forem: 7onic</title>
      <link>https://forem.com/7onic</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/7onic"/>
    <language>en</language>
    <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://forem.com/7onic/design-to-code-4-why-i-chose-radix-over-custom-primitives-50eo</link>
      <guid>https://forem.com/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://forem.com/7onic/design-to-code-3-copy-paste-vs-npm-install-2ai5</link>
      <guid>https://forem.com/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://forem.com/7onic/tailwind-guides-1-what-actually-broke-migrating-to-v4-485o</link>
      <guid>https://forem.com/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://forem.com/7onic/solo-builder-1-what-nobody-tells-you-59e1</link>
      <guid>https://forem.com/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>
    <item>
      <title>Design to Code #2: One JSON, Eleven Outputs</title>
      <dc:creator>7onic</dc:creator>
      <pubDate>Fri, 17 Apr 2026 01:55:04 +0000</pubDate>
      <link>https://forem.com/7onic/design-to-code-2-one-json-eleven-outputs-5443</link>
      <guid>https://forem.com/7onic/design-to-code-2-one-json-eleven-outputs-5443</guid>
      <description>&lt;p&gt;The entire &lt;a href="https://7onic.design" rel="noopener noreferrer"&gt;7onic&lt;/a&gt; design system runs on a single JSON file.&lt;/p&gt;

&lt;p&gt;It's 1,847 lines long.&lt;/p&gt;

&lt;p&gt;Colors live there as hex values like &lt;code&gt;#6B21A8&lt;/code&gt;. Spacing is stored as plain numbers like &lt;code&gt;16&lt;/code&gt;. Border radius uses the same format. Animations are defined as keyframe objects. If you've ever exported tokens from Figma Token Studio, it would look instantly familiar.&lt;/p&gt;

&lt;p&gt;Because that's exactly what it is.&lt;/p&gt;

&lt;p&gt;The file is called &lt;code&gt;figma-tokens.json&lt;/code&gt;, and it's the only place in the codebase where design values are allowed to exist.&lt;/p&gt;

&lt;p&gt;Everything else is generated from it.&lt;/p&gt;

&lt;p&gt;One command reads that file and spits out 11 distribution files in about 200 milliseconds. Change a color. Run the script. Ship it.&lt;/p&gt;

&lt;p&gt;That's the whole pipeline.&lt;/p&gt;

&lt;p&gt;Getting there, however, was a lot messier.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why one file somehow became eleven
&lt;/h2&gt;

&lt;p&gt;At first glance, eleven output files sounds excessive.&lt;/p&gt;

&lt;p&gt;Why not just generate one universal token file and call it a day?&lt;/p&gt;

&lt;p&gt;Because different environments want completely different things.&lt;/p&gt;

&lt;p&gt;A Tailwind v3 project expects a preset. Tailwind v4 wants &lt;code&gt;@theme&lt;/code&gt;. A developer writing vanilla CSS doesn't care about either of those — they just want CSS variables. Someone building outside React may only need TypeScript types. Another team may want raw JSON for internal tooling.&lt;/p&gt;

&lt;p&gt;Same source. Different consumers.&lt;/p&gt;

&lt;p&gt;So the pipeline produces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;css/variables.css&lt;/code&gt; — primitive CSS variables&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;css/themes/light.css&lt;/code&gt; — semantic light theme&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;css/themes/dark.css&lt;/code&gt; — semantic dark theme&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;css/all.css&lt;/code&gt; — bundled CSS&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tailwind/v3-preset.js&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tailwind/v4-theme.css&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tailwind/v4.css&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;js/index.js&lt;/code&gt; — CommonJS&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;js/index.mjs&lt;/code&gt; — ESM&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;types/index.d.ts&lt;/code&gt; — TypeScript declarations&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;json/tokens.json&lt;/code&gt; — processed token output&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most of these were straightforward.&lt;/p&gt;

&lt;p&gt;A few of them absolutely were not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The day &lt;code&gt;bg-primary/50&lt;/code&gt; broke everything
&lt;/h2&gt;

&lt;p&gt;The first version of the pipeline was beautifully naive.&lt;/p&gt;

&lt;p&gt;Read JSON. Loop tokens. Generate CSS variables. Generate Tailwind config. Done.&lt;/p&gt;

&lt;p&gt;Then I tried:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"bg-primary/50"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And nothing happened.&lt;/p&gt;

&lt;p&gt;Turns out Tailwind's opacity modifiers need access to the actual color channels so they can inject alpha values. That works fine with hex colors.&lt;/p&gt;

&lt;p&gt;It does not work 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="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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To Tailwind, a CSS variable is just a string. It can't crack it open, inspect the hex, and rebuild it with opacity.&lt;/p&gt;

&lt;p&gt;So I had to generate a second version of every color token:&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-primary&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="err"&gt;#6&lt;/span&gt;&lt;span class="nt"&gt;B21A8&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="nt"&gt;--color-primary-rgb&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="err"&gt;107&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;33&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;168&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now Tailwind v3 can do 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="nt"&gt;rgb&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-rgb&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;alpha-value&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which means these finally work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;bg-primary/50&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;border-border/40&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;text-foreground/80&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So yes, every color now ships twice: once as hex, once as RGB channels.&lt;/p&gt;

&lt;p&gt;Elegant? Debatable. Effective? Absolutely.&lt;/p&gt;

&lt;p&gt;Tailwind v4 made this easier later thanks to native &lt;code&gt;color-mix()&lt;/code&gt;, but v3 made me earn it first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dark mode: where simple ideas go to die
&lt;/h2&gt;

&lt;p&gt;Dark mode starts innocently.&lt;/p&gt;

&lt;p&gt;Put light values on &lt;code&gt;:root&lt;/code&gt;. Put dark values on &lt;code&gt;.dark&lt;/code&gt;. Toggle a class on &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Classic. Reliable. Works great in Tailwind v3.&lt;/p&gt;

&lt;p&gt;Then Tailwind v4 arrived with a different philosophy: use system preferences.&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also reasonable.&lt;/p&gt;

&lt;p&gt;Until a real user wants this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"My OS is dark, but I want your site in light mode."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's where things get awkward.&lt;/p&gt;

&lt;p&gt;If your entire dark mode strategy depends on &lt;code&gt;prefers-color-scheme&lt;/code&gt;, overriding it cleanly becomes surprisingly annoying.&lt;/p&gt;

&lt;p&gt;So the final system uses three strategies at once:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Follow OS preference by default&lt;/li&gt;
&lt;li&gt;Respect explicit &lt;code&gt;data-theme="dark"&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Support legacy &lt;code&gt;.dark&lt;/code&gt; class toggles&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And there's one important escape hatch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;html&lt;/span&gt; &lt;span class="na"&gt;data-theme=&lt;/span&gt;&lt;span class="s"&gt;"light"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That blocks OS dark mode and forces light mode.&lt;/p&gt;

&lt;p&gt;Which sounds simple now.&lt;/p&gt;

&lt;p&gt;It was not simple then.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Tailwind v4 trap no one warns you about
&lt;/h2&gt;

&lt;p&gt;This one was painful because I discovered it after shipping.&lt;/p&gt;

&lt;p&gt;I used:&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seems harmless.&lt;/p&gt;

&lt;p&gt;What it actually does is bake your token values directly into generated utilities.&lt;/p&gt;

&lt;p&gt;So instead of:&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;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get:&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="err"&gt;#6&lt;/span&gt;&lt;span class="nt"&gt;B21A8&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looks identical in light mode.&lt;/p&gt;

&lt;p&gt;Then you switch to dark mode... and nothing updates.&lt;/p&gt;

&lt;p&gt;Because CSS variables can change. Hardcoded hex values cannot.&lt;/p&gt;

&lt;p&gt;Dark mode didn't fail loudly.&lt;/p&gt;

&lt;p&gt;It failed politely.&lt;/p&gt;

&lt;p&gt;The fix was deleting one word:&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;inline&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That tiny difference now lives in script comments and architecture notes because future-me absolutely would have broken it again.&lt;/p&gt;

&lt;h2&gt;
  
  
  TypeScript types, because eventually someone asks
&lt;/h2&gt;

&lt;p&gt;The pipeline also generates declarations like:&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="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;declare&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;colorPrimary&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="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;declare&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;spacingMd&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Most of the time you won't need them.&lt;/p&gt;

&lt;p&gt;But when you're wiring tokens into charts, runtime themes, config systems, or anything dynamic, typed access becomes very convenient.&lt;/p&gt;

&lt;p&gt;And once generation is automated, adding it costs almost nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The only part that really matters
&lt;/h2&gt;

&lt;p&gt;All of this is interesting engineering trivia.&lt;/p&gt;

&lt;p&gt;But none of it is the real win.&lt;/p&gt;

&lt;p&gt;The real win is this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Design values lived in Figma&lt;/li&gt;
&lt;li&gt;Code values lived in the codebase&lt;/li&gt;
&lt;li&gt;Keeping them aligned required human discipline&lt;/li&gt;
&lt;li&gt;Human discipline eventually lost&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;After&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Values live in one file&lt;/li&gt;
&lt;li&gt;Everything else is derived&lt;/li&gt;
&lt;li&gt;Drift becomes structurally impossible&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's it.&lt;/p&gt;

&lt;p&gt;The 11 outputs, RGB channels, dark mode logic, Tailwind quirks — those are implementation details.&lt;/p&gt;

&lt;p&gt;The actual product is a system where design and code no longer argue about reality.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next: I launched 7onic as a standard npm package. Then I added a CLI that copies source files directly into your project. Here's why I made that shift — and what it actually costs.&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>tokenpipeline</category>
      <category>tailwindcss</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Design to Code #1: Why I Built 7onic</title>
      <dc:creator>7onic</dc:creator>
      <pubDate>Thu, 16 Apr 2026 10:47:03 +0000</pubDate>
      <link>https://forem.com/7onic/design-to-code-1-why-i-built-7onic-48fo</link>
      <guid>https://forem.com/7onic/design-to-code-1-why-i-built-7onic-48fo</guid>
      <description>&lt;p&gt;I remember the exact moment I gave up on handoffs.&lt;/p&gt;

&lt;p&gt;A developer had implemented a card component I designed. The spacing was off by 2 pixels. The border radius was &lt;code&gt;8px&lt;/code&gt; instead of &lt;code&gt;6px&lt;/code&gt;. The shadow was close but not quite right — they'd grabbed a Tailwind default instead of the value in the Figma file. Individually, none of these were worth a ticket. Together, the whole thing just looked... slightly wrong.&lt;/p&gt;

&lt;p&gt;I left a Figma comment. It got fixed in the next sprint. Then the same thing happened on the next component. And the next one. For ten years.&lt;/p&gt;

&lt;p&gt;I'm a designer. I've also been writing frontend code for most of those ten years, which means I've been on both sides of this handoff. I know why the developer used &lt;code&gt;rounded-md&lt;/code&gt; — it's right there in Tailwind, it's close enough, and who has time to check whether 6px rounds to &lt;code&gt;rounded-md&lt;/code&gt; or &lt;code&gt;rounded&lt;/code&gt; or something else entirely. (It doesn't map cleanly to either, by the way.)&lt;/p&gt;

&lt;p&gt;At some point the Figma comments stopped feeling productive and I just started writing the components myself.&lt;/p&gt;

&lt;h2&gt;
  
  
  It's not a people problem
&lt;/h2&gt;

&lt;p&gt;I want to be clear: the developers I worked with weren't sloppy. Most of them were better engineers than me. The issue was that design values lived in Figma and code values lived in... wherever the last developer put them.&lt;/p&gt;

&lt;p&gt;Someone builds a button. They eyeball the Figma file, pick &lt;code&gt;bg-gray-900&lt;/code&gt; because it looks right, move on. Next month, someone else builds a card header. They look at the button, assume that's the canonical dark color, copy it. Except the Figma file actually specified &lt;code&gt;#1a1a1a&lt;/code&gt;, which is close to &lt;code&gt;gray-900&lt;/code&gt; but not the same. Now you've got two slightly different "dark" colors in production and nobody remembers which one is correct.&lt;/p&gt;

&lt;p&gt;Multiply that by every color, every spacing value, every radius, every shadow. Across dozens of components, over months. The drift is slow and constant.&lt;/p&gt;

&lt;p&gt;The actual problem is obvious in hindsight: &lt;strong&gt;there was no shared source of truth.&lt;/strong&gt; The designer had one (Figma). The codebase had another (whatever was already in the code). Keeping them in sync was manual, which means it was nobody's job, which means it didn't happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  I tried all the "normal" solutions first
&lt;/h2&gt;

&lt;p&gt;Before building anything, I did what you'd expect.&lt;/p&gt;

&lt;p&gt;Exported tokens from Figma manually, dropped them into the Tailwind config. Worked great for about three weeks until the Figma file got updated and nobody remembered to sync the code. Back to drift.&lt;/p&gt;

&lt;p&gt;Tried Style Dictionary. It's genuinely powerful, but configuring it to output exactly the formats I needed — CSS variables, Tailwind v3 preset, Tailwind v4 theme, JS exports, TypeScript types — took longer than building the actual components. I spent a full weekend writing transforms and formatters and still didn't have something I trusted.&lt;/p&gt;

&lt;p&gt;Token Studio for Figma? Good plugin. But the exported JSON needed so much massaging before it was useful in a real Tailwind project that I was basically writing a custom pipeline anyway — just with an extra abstraction layer I didn't control.&lt;/p&gt;

&lt;p&gt;Every approach had the same gap. It handled the "get tokens out of Figma" part reasonably well, then left you alone for the "actually wire these into your codebase" part. That last-mile wiring is exactly where things break.&lt;/p&gt;

&lt;p&gt;So yeah, I built my own thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  One file, one command, zero drift
&lt;/h2&gt;

&lt;p&gt;The rule I started with was almost naively strict: &lt;strong&gt;if a value isn't in the design token file, it can't exist in code.&lt;/strong&gt; No &lt;code&gt;bg-gray-500&lt;/code&gt;. No &lt;code&gt;p-[17px]&lt;/code&gt;. No &lt;code&gt;text-[13px]&lt;/code&gt;. If you need a color, there's a token for it. If there isn't, you add one to the token file first.&lt;/p&gt;

&lt;p&gt;I realize that sounds annoying. It is, a little, at first. But it turns out that constraint is the whole point. It forces every visual decision through a single chokepoint: &lt;code&gt;figma-tokens.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One &lt;code&gt;sync-tokens&lt;/code&gt; command reads that file and generates everything:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CSS custom properties&lt;/li&gt;
&lt;li&gt;Tailwind v3 preset with RGB channels (so &lt;code&gt;bg-primary/50&lt;/code&gt; works)&lt;/li&gt;
&lt;li&gt;Tailwind v4 &lt;code&gt;@theme&lt;/code&gt; with native color-mix&lt;/li&gt;
&lt;li&gt;TypeScript types&lt;/li&gt;
&lt;li&gt;JSON for anything else&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Eleven files total. All from one source. I can change a color in the token file, run the command, and know it's updated everywhere. No searching the codebase for hardcoded hex values. No "did we update the Tailwind config too?"&lt;/p&gt;

&lt;p&gt;On top of that token layer, I built &lt;a href="https://7onic.design" rel="noopener noreferrer"&gt;7onic&lt;/a&gt; — 42 components, all using Radix UI under the hood for accessibility, styled with CVA variants. There's a CLI too:&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;This copies the actual source files into your project. Not a compiled package you import — the real &lt;code&gt;.tsx&lt;/code&gt; files, in your &lt;code&gt;components/&lt;/code&gt; folder, fully yours to read and modify. I started with npm install — the standard approach. The CLI came later, once I understood what copy-paste actually enables. I'll write a separate post about why I made that shift, because it's a real trade-off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Radix specifically
&lt;/h2&gt;

&lt;p&gt;Quick detour on this because people ask.&lt;/p&gt;

&lt;p&gt;Accessibility is the part I didn't want to get wrong and also the part I knew I would get wrong if I built it from scratch. Focus traps in dialogs, keyboard navigation in dropdowns, screen reader announcements for toasts — this stuff is brutally hard to get right across browsers and assistive technologies.&lt;/p&gt;

&lt;p&gt;Radix handles all of that and ships completely unstyled. No CSS to override, no opinions about how things look. You bring the design tokens, Radix brings the semantics.&lt;/p&gt;

&lt;p&gt;Could I have built primitives from scratch? Sure. Would the focus management in my dialog component be as robust as what the Radix team has iterated on for years? No. I know where my time is better spent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The solo thing
&lt;/h2&gt;

&lt;p&gt;I should mention that I built all of this alone. No team. No design review. No code review. Just me, my Figma files, and a growing collection of markdown documents where I argued with myself about naming conventions.&lt;/p&gt;

&lt;p&gt;(Is the base button size called &lt;code&gt;default&lt;/code&gt; or &lt;code&gt;md&lt;/code&gt;? Does the button need 5 sizes or is 3 enough? These are the questions that keep you up at night when there's nobody else to make the call. I went with 5 sizes and &lt;code&gt;default&lt;/code&gt; as the base, if you're curious. There's a whole doc about why.)&lt;/p&gt;

&lt;p&gt;Building solo has one unexpected benefit: it makes the "no hardcoding" rule actually stick. On a team, someone always has a deadline and a good reason to skip the token file. "I'll clean it up later." Solo, I'm the person who has to clean it up later, and I know I won't, so I just do it right the first time. The constraint isn't aspirational — it's survival.&lt;/p&gt;

&lt;p&gt;The downside is that a design system can grow forever. There's always one more component to add, one more variant to support, one more edge case to handle. I had to get comfortable shipping something incomplete. &lt;a href="https://7onic.design" rel="noopener noreferrer"&gt;7onic&lt;/a&gt; right now has 42 components, supports both Tailwind v3 and v4, has a CLI, and docs in three languages. It's also missing things. That's fine. Ship, iterate, repeat.&lt;/p&gt;

&lt;h2&gt;
  
  
  What comes next
&lt;/h2&gt;

&lt;p&gt;This is the first post in a series called "Design to Code." The plan is to write about the decisions behind 7onic — not the marketing version, but the actual reasoning, including the parts where I got it wrong and had to redo things.&lt;/p&gt;

&lt;p&gt;Some posts I'm planning:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How the token pipeline works end to end — the &lt;code&gt;sync-tokens&lt;/code&gt; script, why I generate RGB channel variables, how Tailwind v3 and v4 support both work from the same token file&lt;/li&gt;
&lt;li&gt;The copy-paste vs npm-import story — why I started with npm install and later built a CLI that copies source files instead&lt;/li&gt;
&lt;li&gt;Using AI to build a design system — what &lt;code&gt;llms.txt&lt;/code&gt; actually does, how I use Claude to write components, and where it falls apart&lt;/li&gt;
&lt;li&gt;Lessons from 42 components — patterns that scaled, patterns that didn't, things I'd do differently if I started over&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If any of that sounds interesting, stick around.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next up: how one JSON file becomes CSS variables, Tailwind presets, and TypeScript types — and why my first three attempts at building this pipeline failed.&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>designtokens</category>
      <category>figma</category>
      <category>react</category>
    </item>
  </channel>
</rss>
