<?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: venkatesh m</title>
    <description>The latest articles on Forem by venkatesh m (@vmvenkatesh78).</description>
    <link>https://forem.com/vmvenkatesh78</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%2F2325755%2Fff599c1f-7669-4992-8a23-8abc1e4d8c51.jpg</url>
      <title>Forem: venkatesh m</title>
      <link>https://forem.com/vmvenkatesh78</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/vmvenkatesh78"/>
    <language>en</language>
    <item>
      <title>jsx-a11y has 36 rules. None of them catch these 6 patterns.</title>
      <dc:creator>venkatesh m</dc:creator>
      <pubDate>Mon, 13 Apr 2026 14:10:56 +0000</pubDate>
      <link>https://forem.com/vmvenkatesh78/jsx-a11y-has-36-rules-none-of-them-catch-these-6-patterns-3ih2</link>
      <guid>https://forem.com/vmvenkatesh78/jsx-a11y-has-36-rules-none-of-them-catch-these-6-patterns-3ih2</guid>
      <description>&lt;h2&gt;
  
  
  The Bug That Passed Lint
&lt;/h2&gt;

&lt;p&gt;I was building a menu component. The trigger had &lt;code&gt;aria-haspopup="menu"&lt;/code&gt;. The content panel had &lt;code&gt;role="dialog"&lt;/code&gt;. Every element was individually valid. jsx-a11y gave zero warnings. The component rendered correctly. Keyboard navigation worked.&lt;/p&gt;

&lt;p&gt;Then I tested with VoiceOver.&lt;/p&gt;

&lt;p&gt;The trigger announced: "Actions, button, menu popup." The user expects a menu — arrow keys to navigate between items, no Tab key needed. What they got was a dialog with Tab navigation. Visually identical. Functionally identical for sighted users. Completely wrong for screen reader users. The trigger promised a menu. The content delivered a dialog.&lt;/p&gt;

&lt;p&gt;jsx-a11y didn't catch this because it can't. It visits one JSX element at a time. It sees attributes on that element. It cannot see the parent, the sibling, or the child. The &lt;code&gt;aria-haspopup="menu"&lt;/code&gt; was valid. The &lt;code&gt;role="dialog"&lt;/code&gt; was valid. The mismatch between them is invisible to any tool that checks elements in isolation.&lt;/p&gt;

&lt;p&gt;This is not a jsx-a11y bug. It's a structural limitation. Element-level analysis cannot validate relationships.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Element-Level Linting Misses
&lt;/h2&gt;

&lt;p&gt;jsx-a11y has 36 active rules. Every one answers the same type of question: "Does this element have the right attributes?" Does this &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; have &lt;code&gt;alt&lt;/code&gt;? Does this &lt;code&gt;&amp;lt;button&amp;gt;&lt;/code&gt; have content? Does this element with &lt;code&gt;onClick&lt;/code&gt; have a keyboard handler?&lt;/p&gt;

&lt;p&gt;These are important checks. They catch real bugs. But the accessibility failures that survive to production are rarely about missing attributes on a single element. They're about how elements relate to each other.&lt;/p&gt;

&lt;p&gt;Six patterns. Each one passes jsx-a11y. Each one breaks for screen reader or keyboard users.&lt;/p&gt;




&lt;h3&gt;
  
  
  1. Dialog without &lt;code&gt;aria-modal="true"&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Passes jsx-a11y. Breaks screen reader navigation.&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"dialog"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h2&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Confirm deletion&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h2&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Are you sure?&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;code&gt;aria-modal="true"&lt;/code&gt;, screen readers allow virtual cursor navigation outside the dialog. A VoiceOver user presses the down arrow and starts reading the page behind the modal overlay. They're interacting with content that's visually obscured. The dialog looks modal. It isn't.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What the user expects&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Virtual cursor stays inside dialog&lt;/td&gt;
&lt;td&gt;Virtual cursor reads page behind dialog&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Escape closes, focus returns to trigger&lt;/td&gt;
&lt;td&gt;Escape may or may not work&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Confirm deletion, dialog" announced&lt;/td&gt;
&lt;td&gt;"dialog" announced, no context&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The fix is &lt;code&gt;aria-modal="true"&lt;/code&gt;. One attribute. jsx-a11y doesn't check for it because the attribute belongs to the relationship between the dialog and the page — not to the dialog element alone.&lt;/p&gt;




&lt;h3&gt;
  
  
  2. &lt;code&gt;aria-haspopup&lt;/code&gt; with an invalid value
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Passes jsx-a11y. Silently broken.&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;aria-haspopup&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"dropdown"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Open&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ARIA spec allows seven values for &lt;code&gt;aria-haspopup&lt;/code&gt;: &lt;code&gt;menu&lt;/code&gt;, &lt;code&gt;listbox&lt;/code&gt;, &lt;code&gt;tree&lt;/code&gt;, &lt;code&gt;grid&lt;/code&gt;, &lt;code&gt;dialog&lt;/code&gt;, &lt;code&gt;true&lt;/code&gt;, &lt;code&gt;false&lt;/code&gt;. That's it. "dropdown" is not one of them. Neither is "tooltip" or "popup" or "select."&lt;/p&gt;

&lt;p&gt;Browsers don't throw errors on invalid ARIA values. They silently treat them as &lt;code&gt;false&lt;/code&gt;. The popup is never announced. The user clicks the button, something appears on screen, and the screen reader says nothing about it. The popup is invisible to assistive technology.&lt;/p&gt;

&lt;p&gt;This isn't hypothetical. I've seen &lt;code&gt;aria-haspopup="tooltip"&lt;/code&gt; in production codebases from developers who reasonably assumed the value should describe what appears. The spec disagrees. The browser silently drops it.&lt;/p&gt;




&lt;h3&gt;
  
  
  3. Interactive elements inside a tooltip
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Passes jsx-a11y. Keyboard users can never reach the link.&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"tooltip"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;a&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"/help"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Learn more&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;a&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tooltips disappear on blur. A keyboard user cannot Tab into a tooltip because the moment focus leaves the trigger, the tooltip closes. The link inside it is unreachable. A mouse user can click it. A keyboard user cannot. A screen reader user cannot.&lt;/p&gt;

&lt;p&gt;If you need interactive content in a popup, use &lt;code&gt;role="dialog"&lt;/code&gt; with a focus trap. Tooltips are for text-only content — labels, descriptions, keyboard shortcuts. Putting a button or link inside one creates a feature that exists for mouse users and doesn't exist for everyone else.&lt;/p&gt;




&lt;h3&gt;
  
  
  4. Accordion trigger outside a heading
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Passes jsx-a11y. Invisible to heading navigation.&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;aria-expanded&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt; &lt;span class="na"&gt;aria-controls&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"panel-1"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    Section title
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"panel-1"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Panel content&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Screen reader users navigate pages by headings. In NVDA, pressing H jumps to the next heading. In VoiceOver, using the rotor filtered to headings gives an outline of the page. Accordion sections are page structure — they should appear in that outline.&lt;/p&gt;

&lt;p&gt;Without a heading wrapper, the accordion section is invisible to heading navigation. The user has to read the entire page sequentially to find it. The fix is one element:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h3&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;aria-expanded&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt; &lt;span class="na"&gt;aria-controls&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"panel-1"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    Section title
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h3&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The WAI-ARIA Accordion Pattern specifies this explicitly. jsx-a11y doesn't check it because the heading requirement is about the relationship between the trigger and its ancestor — not about the trigger element itself.&lt;/p&gt;




&lt;h3&gt;
  
  
  5. &lt;code&gt;role="menuitem"&lt;/code&gt; on a &lt;code&gt;&amp;lt;button&amp;gt;&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Passes jsx-a11y. Double announcement in some screen readers.&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"menuitem"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Edit&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;&amp;lt;button&amp;gt;&lt;/code&gt; has an implicit role of "button." Adding &lt;code&gt;role="menuitem"&lt;/code&gt; overrides it at the ARIA level, but NVDA with Firefox announces both: "button, menuitem, Edit." The user hears two conflicting roles for the same element. Is this a button or a menu item? The double announcement creates confusion about what the element does and how to interact with it.&lt;/p&gt;

&lt;p&gt;The correct pattern for menu items:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"menuitem"&lt;/span&gt; &lt;span class="na"&gt;tabIndex&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Edit&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No implicit role conflict. One announcement. Programmatically focusable via roving tabindex. This is what the WAI-ARIA Menu Button Pattern specifies. jsx-a11y doesn't flag the conflict because the conflict is between the element's implicit role and its explicit role — a relationship, not an attribute.&lt;/p&gt;




&lt;h3&gt;
  
  
  6. Dialog without an accessible name
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Passes jsx-a11y. "dialog" announced with no context.&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"dialog"&lt;/span&gt; &lt;span class="na"&gt;aria-modal&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h2&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Confirm deletion&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h2&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;This action cannot be undone.&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Cancel&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Delete&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When this dialog opens, VoiceOver announces: "dialog." That's it. The user doesn't know what the dialog is about until they navigate through its contents. Compare with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"dialog"&lt;/span&gt; &lt;span class="na"&gt;aria-modal&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt; &lt;span class="na"&gt;aria-labelledby&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"dialog-title"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h2&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"dialog-title"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Confirm deletion&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h2&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  ...
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now VoiceOver announces: "Confirm deletion, dialog." The user knows the purpose immediately. The difference is &lt;code&gt;aria-labelledby&lt;/code&gt; pointing to the heading — a relationship between two elements.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Gap Exists
&lt;/h2&gt;

&lt;p&gt;ESLint rules operate on AST nodes. When jsx-a11y visits a &lt;code&gt;JSXOpeningElement&lt;/code&gt;, it receives the attributes of that element. It can check if &lt;code&gt;role&lt;/code&gt; is valid. It can check if &lt;code&gt;aria-label&lt;/code&gt; exists. It cannot walk up to the parent and check if the parent is a heading. It cannot walk down to the children and check if any of them are focusable.&lt;/p&gt;

&lt;p&gt;Some of these checks are structurally possible within ESLint's visitor pattern. You can walk the parent chain by following the &lt;code&gt;parent&lt;/code&gt; property that ESLint sets on every AST node. You can inspect children through the &lt;code&gt;JSXElement.children&lt;/code&gt; array. jsx-a11y doesn't do this — its rules are designed to be fast, single-element checks. The performance trade-off is reasonable for a plugin with 12 million weekly downloads.&lt;/p&gt;

&lt;p&gt;But the consequence is that composition-level accessibility bugs — the ones where every element is individually correct but the combination is wrong — pass lint and ship to production.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Microsoft Built (and Didn't Build)
&lt;/h2&gt;

&lt;p&gt;Microsoft ships &lt;a href="https://github.com/microsoft/eslint-plugin-fluentui-jsx-a11y" rel="noopener noreferrer"&gt;&lt;code&gt;eslint-plugin-fluentui-jsx-a11y&lt;/code&gt;&lt;/a&gt; for their FluentUI component library. It checks that FluentUI-specific components have the right ARIA attributes — a &lt;code&gt;DialogBody&lt;/code&gt; needs a &lt;code&gt;DialogTitle&lt;/code&gt;, an accordion header needs an accessible name.&lt;/p&gt;

&lt;p&gt;It's a good plugin. It's also tied to FluentUI. It doesn't check that a generic &lt;code&gt;&amp;lt;button aria-haspopup="menu"&amp;gt;&lt;/code&gt; connects to a panel with &lt;code&gt;role="menu"&lt;/code&gt;. It doesn't check that a &lt;code&gt;&amp;lt;div role="tooltip"&amp;gt;&lt;/code&gt; doesn't contain interactive children. The composition problem in framework-agnostic React code was still unsolved.&lt;/p&gt;




&lt;h2&gt;
  
  
  eslint-plugin-a11y-enforce
&lt;/h2&gt;

&lt;p&gt;I built a plugin that checks these relationships. 10 rules, divided into two categories.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Component pattern rules&lt;/strong&gt; validate ARIA relationships in compound components — Dialog, Menu, Accordion, Tooltip:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;dialog-requires-modal&lt;/code&gt; — &lt;code&gt;role="dialog"&lt;/code&gt; must have &lt;code&gt;aria-modal="true"&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dialog-requires-title&lt;/code&gt; — &lt;code&gt;role="dialog"&lt;/code&gt; must have &lt;code&gt;aria-labelledby&lt;/code&gt; or &lt;code&gt;aria-label&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;haspopup-role-match&lt;/code&gt; — &lt;code&gt;aria-haspopup&lt;/code&gt; must be a valid ARIA value&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tooltip-no-interactive&lt;/code&gt; — &lt;code&gt;role="tooltip"&lt;/code&gt; must not contain focusable children&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;accordion-trigger-heading&lt;/code&gt; — accordion triggers must be inside headings&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;menuitem-not-button&lt;/code&gt; — &lt;code&gt;role="menuitem"&lt;/code&gt; should not be on &lt;code&gt;&amp;lt;button&amp;gt;&lt;/code&gt; elements&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;General interaction rules&lt;/strong&gt; catch common patterns every developer writes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;focusable-has-interaction&lt;/code&gt; — &lt;code&gt;tabIndex={0}&lt;/code&gt; requires a keyboard handler&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;input-requires-label&lt;/code&gt; — form inputs must have accessible labels (placeholder is not a label)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;radio-group-requires-grouping&lt;/code&gt; — radio buttons must be inside &lt;code&gt;&amp;lt;fieldset&amp;gt;&lt;/code&gt; or &lt;code&gt;role="radiogroup"&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;no-positive-tabindex&lt;/code&gt; — &lt;code&gt;tabIndex&lt;/code&gt; greater than 0 breaks natural tab order&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  How the rules work
&lt;/h3&gt;

&lt;p&gt;The composition rules use ancestor traversal — walking up the AST's parent chain to check if an element's ancestor has the right role or tag name. For &lt;code&gt;accordion-trigger-heading&lt;/code&gt;, when the visitor encounters a &lt;code&gt;&amp;lt;button&amp;gt;&lt;/code&gt; with &lt;code&gt;aria-expanded&lt;/code&gt;, it walks up the parent chain looking for &lt;code&gt;&amp;lt;h1&amp;gt;&lt;/code&gt;-&lt;code&gt;&amp;lt;h6&amp;gt;&lt;/code&gt; or &lt;code&gt;role="heading"&lt;/code&gt;. For &lt;code&gt;tooltip-no-interactive&lt;/code&gt;, when the visitor encounters a focusable element, it walks up looking for &lt;code&gt;role="tooltip"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is stateless — no mutable flags like &lt;code&gt;insideTooltip = true&lt;/code&gt; that would break with nested components, conditional rendering, or interleaved elements. Each check walks the tree from the current node, every time. The trade-off is performance (O(n*d) where d is tree depth), but for the file sizes ESLint processes, this is negligible.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the error messages say
&lt;/h3&gt;

&lt;p&gt;Every error message explains three things: what is wrong, why it matters for the user, and how to fix it. Not "violation" — a specific audio experience.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Tooltip (role="tooltip") must not contain interactive elements. Tooltips are non-interactive by design. Users cannot Tab to content inside a tooltip because it disappears on blur. If you need interactive content in a popup, use a Popover or Dialog instead.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If a developer reads this message and still doesn't understand the issue, the message failed, not the developer.&lt;/p&gt;




&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--save-dev&lt;/span&gt; eslint-plugin-a11y-enforce
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// eslint.config.js (ESLint 9+)&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;a11yEnforce&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;eslint-plugin-a11y-enforce&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="nx"&gt;a11yEnforce&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;configs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recommended&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;ESLint 8 is also supported via legacy config.&lt;/p&gt;

&lt;p&gt;Use both &lt;code&gt;jsx-a11y&lt;/code&gt; and &lt;code&gt;a11y-enforce&lt;/code&gt;. They complement each other. No rule overlap. jsx-a11y checks elements. a11y-enforce checks relationships.&lt;/p&gt;




&lt;h2&gt;
  
  
  Design Decisions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Zero runtime dependencies.&lt;/strong&gt; The plugin uses only ESLint's built-in AST APIs. No &lt;code&gt;aria-query&lt;/code&gt;, no &lt;code&gt;axe-core&lt;/code&gt;, no &lt;code&gt;axobject-query&lt;/code&gt;. The full rule set is 1,065 lines of TypeScript.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Educational over terse.&lt;/strong&gt; Every rule's error message explains the user impact, not just the spec violation. Developers who understand why a rule exists are less likely to disable it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Conservative on spreads.&lt;/strong&gt; &lt;code&gt;&amp;lt;div role="dialog" {...props}&amp;gt;&lt;/code&gt; fires the rule even though the spread might contain &lt;code&gt;aria-modal&lt;/code&gt;. Static analysis cannot see through spreads. If you set &lt;code&gt;role&lt;/code&gt; statically, set &lt;code&gt;aria-modal&lt;/code&gt; statically too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Single recommended preset.&lt;/strong&gt; All 10 rules as errors. No recommended/strict split until real-world usage data justifies one. If a rule fires, it's a real problem.&lt;/p&gt;




&lt;h2&gt;
  
  
  What This Doesn't Catch
&lt;/h2&gt;

&lt;p&gt;Static analysis has limits. This plugin cannot verify that &lt;code&gt;aria-labelledby&lt;/code&gt; points to an element that actually exists — that requires cross-file or runtime analysis. It cannot check that a focus trap is implemented correctly — that's behavior, not structure. It cannot validate screen reader announcement order — that varies by browser and AT combination.&lt;/p&gt;

&lt;p&gt;For rendered DOM testing, use &lt;a href="https://github.com/dequelabs/axe-core-npm/tree/develop/packages/react" rel="noopener noreferrer"&gt;&lt;code&gt;@axe-core/react&lt;/code&gt;&lt;/a&gt; in development. For real-world validation, test with an actual screen reader. Linting catches the structural errors. Testing catches the behavioral ones. Both matter.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Matters Now
&lt;/h2&gt;

&lt;p&gt;Accessibility enforcement is accelerating. The European Accessibility Act started enforcement on June 28, 2025, across all 27 EU member states. In the US, over 5,000 ADA digital accessibility lawsuits were filed in 2025 across federal and state courts — up from roughly 4,000 in 2024 (&lt;a href="https://info.usablenet.com/2025-year-end-report-on-web-accessibility-lawsuits" rel="noopener noreferrer"&gt;UsableNet 2025 Year-End Report&lt;/a&gt;). In India, the Supreme Court declared digital access a fundamental right under Article 21 in April 2025, and SEBI mandated WCAG compliance for the entire financial sector in July 2025.&lt;/p&gt;

&lt;p&gt;Your linter should catch these before they ship. jsx-a11y catches the element-level issues. a11y-enforce catches the composition-level issues. Install both.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;npm:&lt;/strong&gt; &lt;a href="https://www.npmjs.com/package/eslint-plugin-a11y-enforce" rel="noopener noreferrer"&gt;eslint-plugin-a11y-enforce&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/vmvenkatesh78/eslint-plugin-a11y-enforce" rel="noopener noreferrer"&gt;vmvenkatesh78/eslint-plugin-a11y-enforce&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;10 rules. 207 tests. Zero dependencies. ESM + CJS. ESLint 8 and 9.&lt;/p&gt;

</description>
      <category>a11y</category>
      <category>react</category>
      <category>typescript</category>
      <category>frontend</category>
    </item>
    <item>
      <title>From 3 components to 8: what actually changes when a design system scales</title>
      <dc:creator>venkatesh m</dc:creator>
      <pubDate>Thu, 02 Apr 2026 14:54:26 +0000</pubDate>
      <link>https://forem.com/vmvenkatesh78/from-3-components-to-8-what-actually-changes-when-a-design-system-scales-mhb</link>
      <guid>https://forem.com/vmvenkatesh78/from-3-components-to-8-what-actually-changes-when-a-design-system-scales-mhb</guid>
      <description>&lt;p&gt;When flintwork had 3 components (Button, Dialog, Tabs), every component was its own island. Each one had its own hooks, its own keyboard handling, its own focus management. The code worked but nothing talked to anything else.&lt;/p&gt;

&lt;p&gt;When it grew to 8 (adding Menu, Select, Popover, Accordion, Tooltip), the codebase went through a structural shift I didn't plan for. Patterns emerged that only become visible when you have enough components to compare.&lt;/p&gt;

&lt;p&gt;This is about what those patterns are and why they matter more than any individual component.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four components that are secretly the same thing
&lt;/h2&gt;

&lt;p&gt;Dialog, Popover, Menu, and Select look completely different to a user. Different triggers, different content, different interaction models. But internally they all do the same three things when they open:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Trap focus inside the content&lt;/li&gt;
&lt;li&gt;Close when you click outside&lt;/li&gt;
&lt;li&gt;Close when you press Escape&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The code is identical:&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;// All four components do exactly this in their Content component:&lt;/span&gt;
&lt;span class="nf"&gt;useFocusTrap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;contentRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;isOpen&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nf"&gt;useClickOutside&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;contentRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;onOpenChange&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="nf"&gt;useEffect&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handleEscape&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;KeyboardEvent&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Escape&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stopPropagation&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="nf"&gt;onOpenChange&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;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;keydown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;handleEscape&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;keydown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;handleEscape&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="nx"&gt;onOpenChange&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The behavioral difference between these four components lives entirely in their ARIA layer:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;role&lt;/th&gt;
&lt;th&gt;aria-modal&lt;/th&gt;
&lt;th&gt;aria-haspopup&lt;/th&gt;
&lt;th&gt;Trigger&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Dialog&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dialog&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;true&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dialog&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Opens only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Popover&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dialog&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Toggles&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Menu&lt;/td&gt;
&lt;td&gt;&lt;code&gt;menu&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;&lt;code&gt;menu&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Toggles&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Select&lt;/td&gt;
&lt;td&gt;&lt;code&gt;listbox&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;&lt;code&gt;listbox&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Toggles&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A screen reader user experiences Dialog and Popover as completely different things. But the code that powers them is the same three hooks composed the same way. The ARIA attributes are the differentiator, not the behavior.&lt;/p&gt;

&lt;p&gt;This is something you can't see with 3 components. You need at least 4 overlapping patterns before the shared shape becomes obvious.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision I'm most glad I made early
&lt;/h2&gt;

&lt;p&gt;Every hook is internal. Consumers never call &lt;code&gt;useFocusTrap&lt;/code&gt; directly. They use &lt;code&gt;&amp;lt;Dialog.Content&amp;gt;&lt;/code&gt; which calls it internally.&lt;/p&gt;

&lt;p&gt;This seemed like a small decision at the time. With 8 components, it turned out to be the most important architectural choice in the system. Here's why:&lt;/p&gt;

&lt;p&gt;When I needed to change how &lt;code&gt;useFocusTrap&lt;/code&gt; handles initial focus placement, I changed one file. If consumers were calling &lt;code&gt;useFocusTrap&lt;/code&gt; directly, that's a breaking change to a public API. With internal hooks, it's an implementation detail that no consumer code depends on.&lt;/p&gt;

&lt;p&gt;The compound component API is the public contract. The hooks are the private implementation. This boundary is what lets the system evolve without breaking consumers.&lt;/p&gt;

&lt;h2&gt;
  
  
  When not to share: typeahead
&lt;/h2&gt;

&lt;p&gt;Menu and Select both support typeahead search. Type a character and focus jumps to the first item that starts with that character. Same 500ms buffer timeout, same textContent matching logic.&lt;/p&gt;

&lt;p&gt;The obvious move is to add typeahead to &lt;code&gt;useRovingTabIndex&lt;/code&gt; since both Menu and Select already use it. I didn't.&lt;/p&gt;

&lt;p&gt;Typeahead is specific to menu and listbox patterns. Tabs use roving tabindex but don't have typeahead. A toolbar would use roving tabindex but wouldn't have typeahead either. Adding it to the generic hook would pollute a behavior primitive with pattern-specific logic.&lt;/p&gt;

&lt;p&gt;Instead, typeahead is a keydown handler directly in MenuContent and SelectContent. Both implementations are structurally identical. If a third component needs typeahead, that's when it gets extracted into &lt;code&gt;useTypeahead&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two instances is coincidence. Three is a pattern. Don't extract at two.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Portal positioning tradeoff
&lt;/h2&gt;

&lt;p&gt;Every component that renders floating content (Popover, Menu, Select, Tooltip) needs positioning. The obvious choice is to bundle @floating-ui/react and handle it internally.&lt;/p&gt;

&lt;p&gt;I deliberately didn't. flintwork provides Portal for stacking context escape but leaves positioning to the consumer. The consumer can use CSS, @floating-ui/react, or whatever they want.&lt;/p&gt;

&lt;p&gt;This was the hardest scope decision because it increases the barrier to entry. A consumer has to bring their own positioning. But the alternative is coupling the entire library to a specific positioning dependency that may not match the consumer's existing setup.&lt;/p&gt;

&lt;p&gt;The compromise: the docs site shows a recommended pattern using CSS &lt;code&gt;position: fixed&lt;/code&gt; with container refs. It works for the common case. If someone needs viewport-aware collision detection, they add @floating-ui/react themselves.&lt;/p&gt;

&lt;p&gt;Not every library needs to solve every problem. Knowing where to draw the scope boundary is as important as what you include.&lt;/p&gt;

&lt;h2&gt;
  
  
  Accordion broke the pattern
&lt;/h2&gt;

&lt;p&gt;Every component up to Accordion shared a simple context model: Root provides state, children consume it. One level of context.&lt;/p&gt;

&lt;p&gt;Accordion needs two levels. The root tracks which panels are open (an array of values). Each item tracks whether that specific item is open. An AccordionItem needs to know its own open state AND whether it should be open based on the root's selection model (single vs multiple).&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;// Root context: which panels are open&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;AccordionContext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;createContext&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;value&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="nl"&gt;onToggle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;itemValue&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;single&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;multiple&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="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Item context: is THIS panel open&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;AccordionItemContext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;createContext&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;isOpen&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;value&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="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;AccordionItem reads from root context to determine its state and provides item context to its children (Trigger and Content). The Trigger doesn't need to know about the root's selection model. It only needs &lt;code&gt;isOpen&lt;/code&gt; and &lt;code&gt;toggle&lt;/code&gt; from its item context.&lt;/p&gt;

&lt;p&gt;This two-level context pattern didn't exist in the system until Accordion forced it. It's the kind of structural change that only surfaces when you're actually building components, not when you're designing the architecture upfront.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone starting a design system
&lt;/h2&gt;

&lt;p&gt;Start with 3 components. Ship them. Use them. Then build 5 more.&lt;/p&gt;

&lt;p&gt;The architecture you design upfront for 3 components will be wrong for 8. That's fine. The hooks you write for Dialog will turn out to be the same hooks you need for Popover. But you won't see that until Popover exists. The context model that works for Tabs will need a second level for Accordion. But you won't know that until Accordion forces the change.&lt;/p&gt;

&lt;p&gt;The goal of v1 isn't to design the perfect system. It's to build enough components that the shared patterns become visible. Then you refactor toward those patterns. That's how the architecture emerges rather than being imposed.&lt;/p&gt;

&lt;p&gt;flintwork's source is on &lt;a href="https://github.com/vmvenkatesh78/flintwork" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; and the docs are live at &lt;a href="https://flintwork.vercel.app" rel="noopener noreferrer"&gt;flintwork.vercel.app&lt;/a&gt; if you want to see how this looks in practice.&lt;/p&gt;




</description>
      <category>react</category>
      <category>designsystem</category>
      <category>a11y</category>
      <category>typescript</category>
    </item>
    <item>
      <title>eslint-plugin-bad-vibes: I built a linter that enforces the worst frontend practices</title>
      <dc:creator>venkatesh m</dc:creator>
      <pubDate>Thu, 02 Apr 2026 14:09:54 +0000</pubDate>
      <link>https://forem.com/vmvenkatesh78/eslint-plugin-bad-vibes-i-built-a-linter-that-enforces-the-worst-frontend-practices-4m5g</link>
      <guid>https://forem.com/vmvenkatesh78/eslint-plugin-bad-vibes-i-built-a-linter-that-enforces-the-worst-frontend-practices-4m5g</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/aprilfools-2026"&gt;DEV April Fools Challenge&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;An ESLint plugin with 6 rules that enforce the worst frontend practices with deadpan corporate error messages. Real AST visitors. Real tests. Completely useless output.&lt;/p&gt;

&lt;p&gt;Every rule is something I have heard suggested in an actual code review, standup, or Slack thread. Not as a joke. As a genuine engineering opinion delivered with confidence by someone who has never opened a screen reader.&lt;/p&gt;

&lt;p&gt;The rules:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;th&gt;What it enforces&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no-semantic-html&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Flags &lt;code&gt;&amp;lt;button&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;nav&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;main&amp;gt;&lt;/code&gt;. Demands &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no-alt-text&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Flags any &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; that has an &lt;code&gt;alt&lt;/code&gt; attribute.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;prefer-positive-tabindex&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Errors on &lt;code&gt;tabIndex={0}&lt;/code&gt;. Requires &lt;code&gt;tabIndex={100}&lt;/code&gt; minimum.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no-aria-allowed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Flags every &lt;code&gt;aria-*&lt;/code&gt; attribute as "invisible complexity."&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;prefer-inline-styles&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Flags &lt;code&gt;className&lt;/code&gt;. CSS class names are "indirection."&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no-keyboard-handlers&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Flags &lt;code&gt;onKeyDown&lt;/code&gt;/&lt;code&gt;onKeyUp&lt;/code&gt;. Keyboard users are "statistically insignificant."&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The error messages are the actual payload. Each one reads like it was copy-pasted from an internal engineering standards document written by someone who has strong opinions about "developer velocity" but has never tabbed through their own product.&lt;/p&gt;

&lt;p&gt;Example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: Keyboard handler "onKeyDown" detected. Internal analytics confirm
that keyboard-only navigation represents a statistically insignificant
portion of our user base (estimated &amp;lt;3%, though we have not actually
instrumented this). Supporting keyboard interactions doubles the
interaction surface area of every component, increasing testing burden
and bug surface. Focus engineering effort on the primary input modality
(pointer). Keyboard support is tentatively scheduled for the accessibility
sprint (TBD, see backlog).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;If any of these rules made you think "wait, we actually do that" -- that is the point.&lt;/p&gt;
&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;Install it. Run it on your codebase. Watch it flag every good practice you have.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;eslint-plugin-bad-vibes &lt;span class="nt"&gt;--save-dev&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// eslint.config.js&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;badVibes&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;eslint-plugin-bad-vibes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;badVibes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;configs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recommended&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Then run &lt;code&gt;npx eslint src/&lt;/code&gt; and marvel at how much of your codebase is "correct" by bad-vibes standards.&lt;/p&gt;

&lt;p&gt;Here's what happens when you run it on a React component:&lt;/p&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%2Fcdyudscdi92h9wb2nkax.png" 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%2Fcdyudscdi92h9wb2nkax.png" alt="VSCode showing bad-vibes no-semantic-html error on a main element with corporate error message" width="800" height="341"&gt;&lt;/a&gt;&lt;/p&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%2Fkz5851w0f3fryc4rwt50.png" 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%2Fkz5851w0f3fryc4rwt50.png" alt="VSCode showing bad-vibes no-keyboard-handlers error on onKeyDown with message about accessibility sprint TBD" width="800" height="343"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The recommended config sets all 6 rules to &lt;code&gt;error&lt;/code&gt; because bad practices deserve the same enforcement rigor as good ones.&lt;/p&gt;
&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/vmvenkatesh78" rel="noopener noreferrer"&gt;
        vmvenkatesh78
      &lt;/a&gt; / &lt;a href="https://github.com/vmvenkatesh78/eslint-plugin-bad-vibes" rel="noopener noreferrer"&gt;
        eslint-plugin-bad-vibes
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      ESLint plugin that enforces the worst frontend practices with corporate confidence. 6 rules, 38 tests, zero value.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;eslint-plugin-bad-vibes&lt;/h1&gt;
&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;A production-grade ESLint plugin that enforces the worst frontend practices with the confidence of a principal engineer who has never opened a screen reader.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;6 rules. 38 tests. Zero value delivered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Please do not use this.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Why&lt;/h2&gt;
&lt;/div&gt;

&lt;p&gt;Every codebase has unwritten rules that make it worse. This plugin makes them written, enforceable, and blocking in CI.&lt;/p&gt;

&lt;p&gt;Born from years of reading code reviews that said things like "do we really need ARIA here?" and "can we just use a div?" This plugin answers: yes. Always. To everything.&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Install&lt;/h2&gt;
&lt;/div&gt;

&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;npm install --save-dev eslint-plugin-bad-vibes&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;You won't, but the infrastructure supports it.&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Usage&lt;/h2&gt;

&lt;/div&gt;

&lt;div class="highlight highlight-source-js notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;&lt;span class="pl-c"&gt;// eslint.config.js&lt;/span&gt;
&lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;badVibes&lt;/span&gt; &lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s"&gt;'eslint-plugin-bad-vibes'&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;

&lt;span class="pl-k"&gt;export&lt;/span&gt; &lt;span class="pl-k"&gt;default&lt;/span&gt; &lt;span class="pl-kos"&gt;[&lt;/span&gt;
  &lt;span class="pl-s1"&gt;badVibes&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;configs&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;recommended&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
&lt;span class="pl-kos"&gt;]&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;All 6 rules ship as errors because bad practices deserve the same enforcement rigor as good ones.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Rules&lt;/h2&gt;

&lt;/div&gt;

&lt;div class="markdown-heading"&gt;
&lt;h3 class="heading-element"&gt;&lt;code&gt;no-semantic-html&lt;/code&gt;&lt;/h3&gt;

&lt;/div&gt;

&lt;p&gt;Flags &lt;code&gt;&amp;lt;button&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;nav&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;main&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;header&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;footer&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;aside&amp;gt;&lt;/code&gt;…&lt;/p&gt;
&lt;/div&gt;


&lt;/div&gt;
&lt;br&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/vmvenkatesh78/eslint-plugin-bad-vibes" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;br&gt;
&lt;/div&gt;
&lt;br&gt;


&lt;p&gt;6 rules, 38 tests, zero dependencies. TypeScript strict mode. ESM + CJS dual build. The engineering is real. The rules are not.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;This uses the same AST visitor infrastructure as an actual ESLint plugin I am building, &lt;a href="https://github.com/vmvenkatesh78/eslint-plugin-a11y-enforce" rel="noopener noreferrer"&gt;eslint-plugin-a11y-enforce&lt;/a&gt;, which catches real accessibility composition errors. Same &lt;code&gt;Rule.RuleModule&lt;/code&gt; typing, same &lt;code&gt;JSXOpeningElement&lt;/code&gt; traversal, same helper utilities for attribute extraction and element classification.&lt;/p&gt;

&lt;p&gt;The stack: TypeScript, tsup for dual CJS/ESM builds, vitest for testing, ESLint's RuleTester for rule validation. Every rule uses proper AST analysis, not string matching or regex.&lt;/p&gt;

&lt;p&gt;Writing the error messages was the hardest part. They had to sound exactly corporate enough that you believe someone wrote them unironically. The &lt;code&gt;prefer-positive-tabindex&lt;/code&gt; message about "higher-revenue elements should receive lower tabIndex values for earlier focus" came from an actual conversation I overheard. The &lt;code&gt;no-keyboard-handlers&lt;/code&gt; message about the "accessibility sprint (TBD, see backlog)" is in every company's Jira right now.&lt;/p&gt;

&lt;p&gt;I used Google AI Studio to brainstorm increasingly absurd corporate justifications for each bad practice, then edited them down to the ones that were funny specifically because they sounded plausible. The best satire is indistinguishable from the thing it satirizes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Category
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Community Favorite&lt;/strong&gt; -- because I want this to be the plugin that makes people laugh, then uncomfortably realize their codebase would pass half these rules.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best Google AI Usage&lt;/strong&gt; -- Google AI Studio helped generate the corporate doublespeak for the error messages. The best ones came from prompting Gemini with "write a serious engineering justification for removing all ARIA attributes from a component library" and watching it produce text that was disturbingly close to things I have read in real PRs.&lt;/p&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%2Fgw9vfugjm3yhfdirhrgq.png" 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%2Fgw9vfugjm3yhfdirhrgq.png" alt="Google AI Studio generating justification for removing semantic HTML" width="800" height="433"&gt;&lt;/a&gt;&lt;/p&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%2Fam9nam0eep957mj7gc1n.png" 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%2Fam9nam0eep957mj7gc1n.png" alt="Google AI Studio generating corporate justification for removing ARIA attributes" width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>418challenge</category>
      <category>showdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>I Built an MCP Server That Lets Designers Change CSS From a Notion Table</title>
      <dc:creator>venkatesh m</dc:creator>
      <pubDate>Sat, 14 Mar 2026 20:49:14 +0000</pubDate>
      <link>https://forem.com/vmvenkatesh78/i-built-an-mcp-server-that-lets-designers-change-css-from-a-notion-table-2hj9</link>
      <guid>https://forem.com/vmvenkatesh78/i-built-an-mcp-server-that-lets-designers-change-css-from-a-notion-table-2hj9</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/notion-2026-03-04"&gt;Notion MCP Challenge&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;A designer opens a Notion table. Changes a color reference. An AI agent reads the change, validates it against every other token in the system, generates new JSON files, runs the build pipeline, and writes "synced" or "error" back to the same Notion row. The CSS output updates. The designer never leaves Notion.&lt;/p&gt;

&lt;p&gt;This is &lt;strong&gt;flintwork-token-sync&lt;/strong&gt; — an MCP server that turns Notion into a design token editor for &lt;a href="https://github.com/vmvenkatesh78/flintwork" rel="noopener noreferrer"&gt;flintwork&lt;/a&gt;, a design system I built from scratch with a three-tier token architecture (global → semantic → component), headless React components, and WAI-ARIA compliance.&lt;/p&gt;

&lt;h3&gt;
  
  
  The problem it solves
&lt;/h3&gt;

&lt;p&gt;Design token handoff is broken. A designer changes a color in Figma. Someone copies the hex value into a JSON file. They typo it. Or they reference a token that was renamed last sprint. Or they change the light theme but forget the dark theme. The build succeeds because JSON doesn't validate intent — and the wrong color ships to production.&lt;/p&gt;

&lt;p&gt;The fix is not more process. It's making the source of truth editable by designers with validation that catches errors before they reach code.&lt;/p&gt;

&lt;h3&gt;
  
  
  How it works
&lt;/h3&gt;

&lt;p&gt;Four Notion databases mirror the three tiers of the token system, plus an audit log:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Global Tokens&lt;/strong&gt; — 93 raw palette values. Every color, spacing value, font size, and shadow in the system. Each row is a token with its name, value, type, and sync status.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Semantic Tokens&lt;/strong&gt; — 96 intent mappings across light theme, dark theme, and typography. &lt;code&gt;color.text.primary&lt;/code&gt; references &lt;code&gt;{color.gray.900}&lt;/code&gt; in light mode and &lt;code&gt;{color.gray.50}&lt;/code&gt; in dark mode. A designer changing the primary text color edits one cell. Both themes stay consistent because the architecture enforces it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Component Tokens&lt;/strong&gt; — 64 component-specific bindings. &lt;code&gt;button.primary.bg&lt;/code&gt; references &lt;code&gt;{color.interactive.default}&lt;/code&gt;. The designer doesn't need to know the hex value — they work at the intent level.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build Log&lt;/strong&gt; — Every sync recorded. Timestamp, result, token counts, errors. The complete audit trail.&lt;/p&gt;

&lt;p&gt;When a designer edits a token value in Notion, they tell Claude: "Sync my design tokens." Claude orchestrates two MCP servers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Notion MCP&lt;/strong&gt; (remote) — reads token data from the four databases, writes sync status and build log entries back&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;flintwork-token-sync MCP&lt;/strong&gt; (local) — validates all 253 tokens, generates JSON files, runs the build pipeline that produces CSS custom properties&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The sync validates everything before writing anything. If a designer references &lt;code&gt;{color.purple.999}&lt;/code&gt; and that token doesn't exist, the sync stops. No JSON generated. No CSS changed. The error appears in the Notion row's Error column: "Unresolved reference: {color.purple.999} does not match any known token." The designer sees it immediately, fixes it, syncs again.&lt;/p&gt;

&lt;h3&gt;
  
  
  Five MCP tools
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sync_tokens&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Full pipeline: read → validate → generate JSON → build CSS → write status back&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;validate_tokens&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Read and validate only — preview errors before committing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;diff_tokens&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Compare Notion state against files on disk — see what would change&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;build_tokens&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Run the CSS build on existing JSON files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_token_summary&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Current state: counts, status breakdown, errors&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The diff tool is what makes this practical for a real workflow. Before syncing, the designer asks "What changed?" and sees exactly which tokens were modified and what the old and new values are. No surprises.&lt;/p&gt;

&lt;h3&gt;
  
  
  What makes this different from a Notion API integration
&lt;/h3&gt;

&lt;p&gt;This is not a script that calls the Notion API. It's a custom MCP server that Claude Desktop connects to alongside Notion MCP. The AI agent orchestrates both servers — reading from one, processing through the other, writing results back. The MCP protocol is the connective tissue, not a wrapper.&lt;/p&gt;

&lt;p&gt;The validation is real. It checks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Token name format (dot-separated alphanumeric)&lt;/li&gt;
&lt;li&gt;Color values (hex format, transparent, none)&lt;/li&gt;
&lt;li&gt;Dimension values (px, rem, unitless for line-height)&lt;/li&gt;
&lt;li&gt;Font weights (numeric only)&lt;/li&gt;
&lt;li&gt;Reference resolution (does the referenced token exist?)&lt;/li&gt;
&lt;li&gt;Circular references (A references B references A)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The build is real. The generated JSON feeds into flintwork's token build pipeline, which resolves cross-tier references and outputs CSS custom properties with full theme support. A single &lt;code&gt;[data-theme="dark"]&lt;/code&gt; attribute swap on the root element switches every color in the system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Video Demo
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://drive.google.com/file/d/1b8rJltn72pAYhKysRlIRZHABSx4jKWN_/view?usp=drive_link" rel="noopener noreferrer"&gt;Watch the demo →&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Show us the code
&lt;/h2&gt;

&lt;p&gt;

&lt;/p&gt;
&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/vmvenkatesh78" rel="noopener noreferrer"&gt;
        vmvenkatesh78
      &lt;/a&gt; / &lt;a href="https://github.com/vmvenkatesh78/flintwork-token-sync" rel="noopener noreferrer"&gt;
        flintwork-token-sync
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      MCP server that syncs design tokens between Notion and flintwork's token pipeline.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;flintwork-token-sync&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;MCP server that syncs design tokens between Notion databases and &lt;a href="https://github.com/vmvenkatesh78/flintwork" rel="noopener noreferrer"&gt;flintwork&lt;/a&gt;'s token pipeline. Designers edit tokens in Notion, an AI agent validates and builds them into CSS.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Architecture&lt;/h2&gt;
&lt;/div&gt;
&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;&lt;pre class="notranslate"&gt;&lt;code&gt;Claude Desktop
    ├── Notion MCP (remote) — reads/writes Notion workspace
    └── flintwork-token-sync MCP (local) — validates, generates, builds
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Claude orchestrates both MCP servers. It reads token state from Notion via Notion MCP, calls this server's tools to validate and build, and writes results back to Notion via Notion MCP.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;MCP Tools&lt;/h2&gt;
&lt;/div&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sync_tokens&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Full pipeline: read → validate → generate JSON → build CSS → write status + build log&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;validate_tokens&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Read and validate only — check for errors before committing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;diff_tokens&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Compare Notion state against JSON files on disk — preview what would change&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;build_tokens&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Run the build pipeline on existing JSON files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_token_summary&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Read current state from Notion — counts, status breakdown, errors&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Notion Databases&lt;/h2&gt;…&lt;/div&gt;
&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/vmvenkatesh78/flintwork-token-sync" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;




&lt;p&gt;Key files:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;src/core/sync.ts&lt;/code&gt; — The orchestrator. Single source of truth for the read → validate → generate → build → write-status pipeline. Both the MCP server and CLI call this function.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;src/core/validate.ts&lt;/code&gt; — Token validation. Hex format, reference resolution, circular reference detection, token name format. Pure functions, no Notion dependency.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;src/core/diff.ts&lt;/code&gt; — Compares Notion state against JSON files on disk. Normalizes values (arrays to comma-separated, numbers to strings) for accurate comparison.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;src/core/generate-json.ts&lt;/code&gt; — Transforms Notion rows into flintwork-compatible JSON. Groups by category, theme, and component. Handles font weights as numbers, font families as arrays.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;src/mcp-server/server.ts&lt;/code&gt; — Five MCP tools registered with the MCP SDK. Each tool is a thin wrapper around core functions.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;src/core/build-log.ts&lt;/code&gt; — Writes audit trail entries to the Build Log database after every sync.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The design system it syncs to: &lt;a href="https://github.com/vmvenkatesh78/flintwork" rel="noopener noreferrer"&gt;flintwork&lt;/a&gt; — headless React components (Button, Dialog, Tabs) with compound component API, full WAI-ARIA compliance, and 190 tests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Architecture:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Claude Desktop
    ├── Notion MCP (remote)
    │   └── reads/writes 4 Notion databases
    └── flintwork-token-sync MCP (local)
        ├── validate_tokens    → hex, refs, cycles, names
        ├── diff_tokens        → compare Notion vs disk
        ├── sync_tokens        → full pipeline
        ├── build_tokens       → run CSS build
        └── get_token_summary  → current state
                ↓
        flintwork/src/tokens/ (JSON files)
                ↓
        flintwork/dist/tokens/tokens.css (CSS output)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Tests:&lt;/strong&gt; 59 tests covering validation (token names, all value types, reference resolution, circular detection), JSON generation (file grouping, value formatting, all three tiers), and diff (added, removed, modified, unchanged, normalization).&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Used Notion MCP
&lt;/h2&gt;

&lt;p&gt;Notion MCP is not a backend for this project. It's the entire interface. There is no dashboard, no separate UI, no frontend. The designer works in Notion. The AI agent works through Notion MCP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reading tokens:&lt;/strong&gt; Claude reads all four databases through Notion MCP. Each token is a row with properties — Name (title), Value/Reference (text), Type/Theme/Component (select), Status (select), Last Synced (date), Error (text). The MCP server normalizes these into typed objects for validation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Writing status:&lt;/strong&gt; After every sync, the tool writes back to each changed row. Successful sync → Status: "synced", Last Synced: timestamp, Error: cleared. Validation failure → Status: "error", Error: specific message. The designer sees the result in the same table they edited, without switching tools.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build log:&lt;/strong&gt; Every sync writes a new row to the Build Log database — timestamp, result, token counts per tier, errors. The designer can open the Build Log and see the complete history of every sync.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Diff preview:&lt;/strong&gt; Before syncing, the designer can ask Claude "What changed?" Claude reads the current Notion state via MCP and compares it against the JSON files on disk. The diff shows exactly which tokens were added, removed, or modified with before/after values.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why MCP, not just the Notion API:&lt;/strong&gt; The same validation and build logic is available as a CLI (&lt;code&gt;npm run cli sync&lt;/code&gt;) for CI pipelines and scripts. But the MCP interface is what makes it accessible to designers. They don't run terminal commands. They talk to Claude in a chat window. Claude handles the orchestration — reading from Notion MCP, calling the custom MCP server for validation and build, writing results back through Notion MCP. Two MCP servers, one AI agent, bidirectional flow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Optimized write-back:&lt;/strong&gt; The initial implementation wrote status to all 253 rows on every sync (~88 seconds at Notion's rate limit). This exceeded Claude Desktop's MCP tool call timeout. The fix: only write back to rows whose status actually changed. A typical sync after one designer edit writes 1 row instead of 253.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>notionchallenge</category>
      <category>mcp</category>
      <category>ai</category>
    </item>
    <item>
      <title>Headless Components Are Useless Without a Styling Strategy</title>
      <dc:creator>venkatesh m</dc:creator>
      <pubDate>Fri, 13 Mar 2026 03:10:32 +0000</pubDate>
      <link>https://forem.com/vmvenkatesh78/headless-components-are-useless-without-a-styling-strategy-3fc3</link>
      <guid>https://forem.com/vmvenkatesh78/headless-components-are-useless-without-a-styling-strategy-3fc3</guid>
      <description>&lt;p&gt;Most headless component libraries stop at behavior. They give you a Dialog that traps focus and a Tabs component with roving tabindex, then tell you to "bring your own styles." &lt;/p&gt;

&lt;p&gt;That's where most side projects stop too. The headless primitives work. The README says "unstyled." And the components sit in a repo that nobody wants to actually use — because now the consumer owns all the CSS, and without a token system driving it, the visual consistency depends entirely on discipline.&lt;/p&gt;

&lt;p&gt;The gap isn't behavior. It's the bridge between behavior and presentation. How do you add a visual layer on top of headless primitives without welding the two together? How do you let the token pipeline drive every color, every spacing value, every border radius — while keeping the styled layer thin enough that replacing it doesn't mean rewriting the component?&lt;/p&gt;

&lt;p&gt;That's what Phase 3 of flintwork solves. And the answer turned out to be surprisingly small.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Two-Layer Architecture
&lt;/h2&gt;

&lt;p&gt;Every component in flintwork exists as two things:&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;headless primitive&lt;/strong&gt; that owns behavior — state management, keyboard interactions, focus trapping, ARIA attributes. Zero styling. Outputs plain HTML with the right attributes and event handlers. This is what Articles &lt;a href="https://dev.to/vmvenkatesh78/130-shades-of-gray-building-a-design-token-pipeline-that-killed-our-color-chaos-6j"&gt;1&lt;/a&gt; and &lt;a href="https://dev.to/vmvenkatesh78/your-dialog-has-roledialog-that-doesnt-make-it-accessible-4lha"&gt;2&lt;/a&gt; covered.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;styled wrapper&lt;/strong&gt; that owns presentation — colors, spacing, typography, borders, shadows. Consumes the primitive, adds a single data attribute, and ships a CSS file that targets that attribute. The wrapper is so thin it's almost trivial. That's the 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="c1"&gt;// Headless — behavior only, no opinions&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Button&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;flintwork/primitives&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Styled — behavior + token-driven visuals&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Button&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;flintwork&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;flintwork/styles/button.css&lt;/span&gt;&lt;span class="dl"&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 consumer picks which layer they want. Teams that need full visual control use the primitive and write their own CSS. Teams that want a working design system out of the box use the styled export. Both share the same accessibility and keyboard behavior because that lives in the primitive, not the styles.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;exports&lt;/code&gt; field in &lt;code&gt;package.json&lt;/code&gt; maps each import path to the correct file — TypeScript types, ESM, CJS, and CSS all resolved per path. &lt;code&gt;flintwork/styles/button&lt;/code&gt; points to a single CSS file. A consumer who only uses Button doesn't load Dialog or Tabs CSS. Tree-shaking at the CSS level, not just the JavaScript level.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Not CSS-in-JS, CSS Modules, or Tailwind?
&lt;/h2&gt;

&lt;p&gt;Every CSS strategy has a runtime or tooling cost. CSS-in-JS adds JavaScript to your bundle that runs on every render. CSS Modules require a bundler plugin — your consumer's build pipeline has to support them. Tailwind needs its own build step and a config file.&lt;/p&gt;

&lt;p&gt;The styled layer uses plain CSS files with data attribute selectors. No runtime. No bundler plugin. No config. The consumer imports a &lt;code&gt;.css&lt;/code&gt; file and the styles apply. If their bundler can handle CSS imports (every modern bundler can), it works.&lt;/p&gt;

&lt;p&gt;The selectors target data attributes that the headless primitive already emits:&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="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-fw-button&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="c"&gt;/* base structure */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-fw-button&lt;/span&gt;&lt;span class="o"&gt;][&lt;/span&gt;&lt;span class="nt"&gt;data-variant&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;"primary"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="c"&gt;/* primary colors */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-fw-button&lt;/span&gt;&lt;span class="o"&gt;][&lt;/span&gt;&lt;span class="nt"&gt;data-size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;"md"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="c"&gt;/* medium sizing */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-fw-button&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="nd"&gt;:hover&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="c"&gt;/* hover state */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The primitives emit &lt;code&gt;data-variant&lt;/code&gt;, &lt;code&gt;data-size&lt;/code&gt;, &lt;code&gt;data-state&lt;/code&gt;, &lt;code&gt;data-loading&lt;/code&gt;, &lt;code&gt;aria-disabled&lt;/code&gt; — all the state the CSS needs to respond to. The styled layer doesn't inject any JavaScript. It reads what the primitive already outputs.&lt;/p&gt;

&lt;p&gt;In DevTools, you see &lt;code&gt;data-variant="primary"&lt;/code&gt; on the element and &lt;code&gt;[data-variant="primary"]&lt;/code&gt; in the stylesheet. Self-documenting. No generated class names, no hash suffixes, no layer of indirection between what you see in the DOM and what you see in the CSS.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CSS Pattern That Killed Repetition
&lt;/h2&gt;

&lt;p&gt;A button has four variants (primary, secondary, danger, ghost), three sizes (sm, md, lg), and multiple states (hover, active, focus, disabled, loading). That's potentially dozens of selector blocks, each repeating the same property declarations with different values.&lt;/p&gt;

&lt;p&gt;The first approach I tried was direct:&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="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-variant&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;"primary"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;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;--fw-button-primary-bg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;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;--fw-button-primary-text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;border-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;--fw-button-primary-border&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-variant&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;"primary"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="nd"&gt;:hover&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;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;--fw-button-primary-bgHover&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;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;--fw-button-primary-text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;border-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;--fw-button-primary-border&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-variant&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;"secondary"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;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;--fw-button-secondary-bg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;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;--fw-button-secondary-text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;border-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;--fw-button-secondary-border&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/* repeat for every variant × every state */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works. But the &lt;code&gt;background&lt;/code&gt;, &lt;code&gt;color&lt;/code&gt;, and &lt;code&gt;border-color&lt;/code&gt; declarations repeat in every block. Add a fifth variant and you're writing the same three lines again. Add a property — say &lt;code&gt;box-shadow&lt;/code&gt; — and you're editing every variant block.&lt;/p&gt;

&lt;p&gt;The pattern that fixed this: &lt;strong&gt;declare the structure once, let variants swap values&lt;/strong&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="c"&gt;/* Structure — declared once, never repeated */&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-fw-button&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;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;--_bg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;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;--_text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&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;--_border&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;padding&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;--_py&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;--_px&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;font-size&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;--_fs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/* Variants only assign values to the intermediates */&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-variant&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;"primary"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--_bg&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;--fw-button-primary-bg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--_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;--fw-button-primary-text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--_border&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;--fw-button-primary-border&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-variant&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;"primary"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="nd"&gt;:hover&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--_bg&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;--fw-button-primary-bgHover&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-variant&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;"secondary"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--_bg&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;--fw-button-secondary-bg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--_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;--fw-button-secondary-text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--_border&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;--fw-button-secondary-border&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;--_&lt;/code&gt; prefix marks these as component-private intermediate variables. They're not part of the token system — consumers never reference &lt;code&gt;--_bg&lt;/code&gt; directly. They're internal wiring that connects the token pipeline to the CSS declarations.&lt;/p&gt;

&lt;p&gt;The base selector says &lt;em&gt;what properties a button uses&lt;/em&gt;. The variant selectors say &lt;em&gt;which token values fill those properties&lt;/em&gt;. Adding a fifth variant is one block of variable assignments — no structural changes to the base.&lt;/p&gt;

&lt;p&gt;The hover block for primary is a single line. It only overrides &lt;code&gt;--_bg&lt;/code&gt; because the text and border don't change on hover. In the direct approach, you'd repeat all three properties just to change one.&lt;/p&gt;

&lt;p&gt;This is the same principle as the token architecture itself: separate what something is from how it's configured.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Styled Wrapper Is Almost Nothing
&lt;/h2&gt;

&lt;p&gt;Here's the entire styled Button component:&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;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Button&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;forwardRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;T&lt;/span&gt; &lt;span class="na"&gt;extends&lt;/span&gt; &lt;span class="na"&gt;ElementType&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;'button'&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;(
    props: StyledButtonProps&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;,
    ref: React.Ref&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Element&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;,
  ) =&amp;gt; &lt;span class="si"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ButtonPrimitive&lt;/span&gt;
        &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;data-fw-button&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;""&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="si"&gt;}&lt;/span&gt;,
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One attribute: &lt;code&gt;data-fw-button=""&lt;/code&gt;. That's the only thing the styled layer adds. Everything else — the variant prop, the size prop, loading state, disabled state, aria attributes, keyboard handlers, polymorphic &lt;code&gt;as&lt;/code&gt; prop — passes straight through to the primitive.&lt;/p&gt;

&lt;p&gt;The CSS file targets &lt;code&gt;[data-fw-button]&lt;/code&gt;. Without the CSS import, this component renders identically to the headless primitive. The attribute is inert until the stylesheet loads.&lt;/p&gt;

&lt;p&gt;Dialog follows the same pattern but only wraps the sub-components that produce visible DOM:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Dialog          → pass-through (no DOM, pure context)
Dialog.Trigger  → pass-through (renders consumer's child)
Dialog.Portal   → pass-through (createPortal wrapper)
Dialog.Overlay  → adds data-fw-dialog-overlay
Dialog.Content  → adds data-fw-dialog-content + size prop
Dialog.Title    → adds data-fw-dialog-title
Dialog.Description → adds data-fw-dialog-description
Dialog.Close    → pass-through (renders consumer's child)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five of eight sub-components pass through untouched. The styled layer only touches what has pixels on screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tokens Drive Everything
&lt;/h2&gt;

&lt;p&gt;Every value in the CSS comes from the token pipeline. Not a single hardcoded color, spacing value, or font size in any stylesheet.&lt;/p&gt;

&lt;p&gt;The resolution chain from the first article is what makes this work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;button.json:     button.primary.bg = {color.interactive.default}
light.json:      color.interactive.default = {color.blue.500}
colors.json:     color.blue.500 = #217CF5

→ CSS output:    --fw-button-primary-bg: #217CF5
→ Button CSS:    --_bg: var(--fw-button-primary-bg)
→ Rendered:      background: #217CF5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Switch to dark mode — swap &lt;code&gt;data-theme="dark"&lt;/code&gt; on the root element — and the semantic layer remaps &lt;code&gt;color.interactive.default&lt;/code&gt; to &lt;code&gt;color.blue.400&lt;/code&gt; (#4C97FF). The component CSS doesn't change. The token pipeline resolves the new chain. Every button on the page updates.&lt;/p&gt;

&lt;p&gt;The styled layer doesn't know about themes. It doesn't know about light or dark. It references token custom properties, and the token pipeline handles the rest. That separation is what makes theme switching a single attribute change instead of a stylesheet swap.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Went Wrong During the Build
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Object.assign mutated the primitive.&lt;/strong&gt; The compound component pattern uses &lt;code&gt;Object.assign(Root, { Trigger, Content, ... })&lt;/code&gt; to create the dot-notation API. When the styled wrapper did &lt;code&gt;Object.assign(DialogPrimitive, { Content: StyledContent })&lt;/code&gt;, it replaced &lt;code&gt;Dialog.Content&lt;/code&gt; globally — including for consumers importing from &lt;code&gt;flintwork/primitives&lt;/code&gt;. A consumer using the headless layer would silently get styled sub-components.&lt;/p&gt;

&lt;p&gt;The fix: create a new root function instead of mutating the imported primitive.&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;// Wrong — mutates the primitive&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Dialog&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;DialogPrimitive&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="c1"&gt;// Right — new object, primitive untouched&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;StyledDialogRoot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DialogPrimitive&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Dialog&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;StyledDialogRoot&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;This is the kind of bug that doesn't show up in tests because tests import from one entry point. It shows up in production when two teams import from different entry points in the same app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Polymorphic typing collapsed.&lt;/strong&gt; The styled Button wraps the primitive Button, which supports &lt;code&gt;&amp;lt;Button as="a" href="..."&amp;gt;&lt;/code&gt;. But &lt;code&gt;React.ComponentPropsWithoutRef&amp;lt;typeof ButtonPrimitive&amp;gt;&lt;/code&gt; doesn't thread the generic through — TypeScript collapses it to the default &lt;code&gt;'button'&lt;/code&gt; case and &lt;code&gt;href&lt;/code&gt; becomes a type error.&lt;/p&gt;

&lt;p&gt;The fix: re-declare the full polymorphic type on the styled wrapper. Same pattern as the primitive — generic &lt;code&gt;T extends ElementType&lt;/code&gt;, &lt;code&gt;Omit&amp;lt;ComponentPropsWithoutRef&amp;lt;T&amp;gt;, keyof OwnProps&amp;gt;&lt;/code&gt;. The styled layer can't inherit polymorphic types automatically. It has to re-establish them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CSS files don't copy themselves.&lt;/strong&gt; tsup compiles TypeScript. It does not touch CSS files. After &lt;code&gt;tsup&lt;/code&gt; runs, &lt;code&gt;dist/styled/button.css&lt;/code&gt; doesn't exist even though &lt;code&gt;package.json&lt;/code&gt; exports point to it. Added an &lt;code&gt;onSuccess&lt;/code&gt; hook in the tsup config that copies CSS files to &lt;code&gt;dist/&lt;/code&gt; after every build.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Build Next
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Scroll lock on Dialog.&lt;/strong&gt; When the dialog opens, the page behind it should stop scrolling. Right now it doesn't. The fix is straightforward — set &lt;code&gt;overflow: hidden&lt;/code&gt; on &lt;code&gt;document.body&lt;/code&gt; when the dialog mounts, restore on unmount. Deferred because it's a CSS concern, not a behavior concern, and the focus trap already prevents keyboard scrolling. Mouse wheel scrolling on the overlay is the gap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CSS animations.&lt;/strong&gt; The overlay and content both appear and disappear instantly. A fade-in on the overlay and a scale-in on the content would make the transitions feel finished. The styled layer already has &lt;code&gt;data-state="open"&lt;/code&gt; and &lt;code&gt;data-state="closed"&lt;/code&gt; attributes from the primitive — CSS transitions can target those without any JavaScript changes. The animation is purely a presentation concern, which is exactly what the styled layer is for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;More components.&lt;/strong&gt; Three components prove the architecture. Ten components prove it scales. Tooltip, Select, Popover, Menu, Accordion — each one follows the same pattern: headless primitive with hooks, thin styled wrapper with data attributes, CSS file with intermediate variables, component tokens in JSON. The first component took days. Each subsequent one takes hours. That ratio is the return on the architectural investment.&lt;/p&gt;




&lt;p&gt;This is Part 3 of the &lt;strong&gt;Building flintwork&lt;/strong&gt; series. &lt;a href="https://dev.to/vmvenkatesh78/130-shades-of-gray-building-a-design-token-pipeline-that-killed-our-color-chaos-6j"&gt;Part 1&lt;/a&gt; covers the token pipeline. &lt;a href="https://dev.to/vmvenkatesh78/your-dialog-has-roledialog-that-doesnt-make-it-accessible-4lha"&gt;Part 2&lt;/a&gt; covers the headless primitives and accessibility. The full source is on &lt;a href="https://github.com/vmvenkatesh78/flintwork" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>designsystems</category>
      <category>css</category>
      <category>react</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Your Dialog Has role='dialog'. That Doesn't Make It Accessible.</title>
      <dc:creator>venkatesh m</dc:creator>
      <pubDate>Fri, 06 Mar 2026 13:51:13 +0000</pubDate>
      <link>https://forem.com/vmvenkatesh78/your-dialog-has-roledialog-that-doesnt-make-it-accessible-4lha</link>
      <guid>https://forem.com/vmvenkatesh78/your-dialog-has-roledialog-that-doesnt-make-it-accessible-4lha</guid>
      <description>&lt;h2&gt;
  
  
  The Attribute Isn't the Behavior
&lt;/h2&gt;

&lt;p&gt;Open any component library's Dialog implementation. You'll find &lt;code&gt;role="dialog"&lt;/code&gt; and &lt;code&gt;aria-modal="true"&lt;/code&gt; on the content panel. Check the box, ship it, call it accessible.&lt;/p&gt;

&lt;p&gt;Now try using it with a keyboard.&lt;/p&gt;

&lt;p&gt;Open the dialog. Press Tab. Where does focus go? If it goes behind the dialog to the page content, the dialog isn't accessible — a keyboard user is now interacting with elements they can't see, behind a modal overlay. Press Tab twelve more times. If focus never wraps back to the first element inside the dialog, it's not trapped. Press Escape. If the dialog doesn't close and return focus to the button that opened it, a keyboard user is stranded.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;role="dialog"&lt;/code&gt; tells a screen reader "this is a dialog." It doesn't trap focus. It doesn't handle Escape. It doesn't restore focus on close. It doesn't prevent clicks outside from reaching the page behind. It's a label, not a behavior.&lt;/p&gt;

&lt;p&gt;The gap between "has the right ARIA attributes" and "actually works for someone using a keyboard or screen reader" is where most component libraries cut corners. Not out of malice — out of complexity. A fully accessible modal dialog requires a focus trap with tabbable element detection, click-outside dismissal that doesn't false-positive on drag events, Escape key handling that doesn't leak to parent components, focus restoration to the trigger element, and initial focus placement with a priority chain. That's five independent behaviors converging on one component.&lt;/p&gt;

&lt;p&gt;I built all of it from scratch for &lt;a href="https://github.com/vmvenkatesh78/flintwork" rel="noopener noreferrer"&gt;flintwork&lt;/a&gt; — a headless design system where the primitives handle behavior and accessibility with zero styling. No Radix import, no Headless UI dependency. Every hook, every edge case, hand-written and tested.&lt;/p&gt;

&lt;p&gt;Here's what that taught me.&lt;/p&gt;




&lt;h2&gt;
  
  
  What a Screen Reader Actually Hears
&lt;/h2&gt;

&lt;p&gt;While studying existing implementations — reading the WAI-ARIA authoring practices alongside Radix's source code — I spent time with VoiceOver on macOS testing dialog behavior. The difference between a properly built dialog and a half-built one is immediately obvious — not visually, but audibly.&lt;/p&gt;

&lt;p&gt;A properly built dialog trigger announces: "Open settings, button, dialog popup." Three pieces of information from three attributes: the text content, the element role, and &lt;code&gt;aria-haspopup="dialog"&lt;/code&gt; telling the user that activating this button will open a dialog.&lt;/p&gt;

&lt;p&gt;When the dialog opens, VoiceOver announces: "Settings, dialog. Update your preferences." The title comes from &lt;code&gt;aria-labelledby&lt;/code&gt; pointing to the dialog's heading. The description comes from &lt;code&gt;aria-describedby&lt;/code&gt;. Without &lt;code&gt;aria-labelledby&lt;/code&gt;, VoiceOver just says "dialog" — no context, no purpose. The user has to navigate around inside the dialog to figure out what it's for.&lt;/p&gt;

&lt;p&gt;When the user presses Escape, the dialog closes and VoiceOver announces: "Open settings, button." Focus returned to the trigger. The user is back where they started. Without focus restoration, the user lands on &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt; or the first focusable element on the page — they've lost their place entirely.&lt;/p&gt;

&lt;p&gt;Every ARIA attribute is an audio experience. Once you hear the difference, writing &lt;code&gt;aria-labelledby&lt;/code&gt; stops being a spec compliance checkbox and starts being "the thing that makes the dialog announce its purpose."&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem With Monolithic Components
&lt;/h2&gt;

&lt;p&gt;A single-component Dialog looks clean at first:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;Dialog
  triggerText="Open"
  title="Confirm"
  onConfirm={handleConfirm}
/&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Then requirements arrive. Custom trigger? Add a &lt;code&gt;renderTrigger&lt;/code&gt; prop. Form content? Add &lt;code&gt;children&lt;/code&gt;. Multiple action buttons? Add &lt;code&gt;footer&lt;/code&gt;. Two close buttons? Now you need &lt;code&gt;renderFooter&lt;/code&gt;. Twelve props later, half of them render functions, and you still can't put the close button where you want it.&lt;/p&gt;

&lt;p&gt;Compound components solve this with composition:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;Dialog open={open} onOpenChange={setOpen}&amp;gt;
  &amp;lt;Dialog.Trigger&amp;gt;
    &amp;lt;button&amp;gt;Open settings&amp;lt;/button&amp;gt;
  &amp;lt;/Dialog.Trigger&amp;gt;
  &amp;lt;Dialog.Portal&amp;gt;
    &amp;lt;Dialog.Overlay /&amp;gt;
    &amp;lt;Dialog.Content&amp;gt;
      &amp;lt;Dialog.Title&amp;gt;Settings&amp;lt;/Dialog.Title&amp;gt;
      &amp;lt;Dialog.Description&amp;gt;
        Update your preferences.
      &amp;lt;/Dialog.Description&amp;gt;
      &amp;lt;Dialog.Close&amp;gt;
        &amp;lt;button&amp;gt;Done&amp;lt;/button&amp;gt;
      &amp;lt;/Dialog.Close&amp;gt;
    &amp;lt;/Dialog.Content&amp;gt;
  &amp;lt;/Dialog.Portal&amp;gt;
&amp;lt;/Dialog&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The consumer controls the structure. The compound components handle the behavior. &lt;code&gt;Dialog.Trigger&lt;/code&gt; automatically gets &lt;code&gt;aria-haspopup="dialog"&lt;/code&gt; and &lt;code&gt;aria-expanded&lt;/code&gt;. &lt;code&gt;Dialog.Content&lt;/code&gt; automatically gets &lt;code&gt;role="dialog"&lt;/code&gt; and &lt;code&gt;aria-modal="true"&lt;/code&gt;. The consumer doesn't think about ARIA. They get it for free.&lt;/p&gt;

&lt;p&gt;Seven sub-components, but the complexity concentrates in one. &lt;code&gt;Dialog.Content&lt;/code&gt; is where all three behavioral hooks converge — &lt;code&gt;useFocusTrap&lt;/code&gt; for Tab cycling, &lt;code&gt;useClickOutside&lt;/code&gt; for dismissal, and the Escape key handler. Every other sub-component either provides context, renders a portal, or attaches a click handler. Content does the heavy lifting.&lt;/p&gt;




&lt;h2&gt;
  
  
  Building the Focus Trap
&lt;/h2&gt;

&lt;p&gt;The focus trap is the hardest piece. It's also the piece that makes the dialog actually accessible versus just labeled as accessible.&lt;/p&gt;

&lt;h3&gt;
  
  
  What "Tabbable" Means
&lt;/h3&gt;

&lt;p&gt;Before you can trap focus, you need to know what focus can land on. The list is longer than most developers expect:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&amp;lt;a href&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;button&amp;gt;&lt;/code&gt; (not disabled), &lt;code&gt;&amp;lt;input&amp;gt;&lt;/code&gt; (not disabled, not &lt;code&gt;type="hidden"&lt;/code&gt;), &lt;code&gt;&amp;lt;select&amp;gt;&lt;/code&gt; (not disabled), &lt;code&gt;&amp;lt;textarea&amp;gt;&lt;/code&gt; (not disabled), &lt;code&gt;&amp;lt;summary&amp;gt;&lt;/code&gt;, &lt;code&gt;[tabindex="0"]&lt;/code&gt;, &lt;code&gt;&amp;lt;iframe&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;audio controls&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;video controls&amp;gt;&lt;/code&gt;, &lt;code&gt;[contenteditable]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;But matching the selector isn't enough. An element that matches &lt;code&gt;button:not(:disabled)&lt;/code&gt; can still be untabbable if it or an ancestor has &lt;code&gt;display: none&lt;/code&gt;, if it has &lt;code&gt;visibility: hidden&lt;/code&gt;, if it's inside a closed &lt;code&gt;&amp;lt;details&amp;gt;&lt;/code&gt; element (except the &lt;code&gt;&amp;lt;summary&amp;gt;&lt;/code&gt;), if it has &lt;code&gt;[inert]&lt;/code&gt; or lives inside an &lt;code&gt;[inert]&lt;/code&gt; ancestor, or if it has &lt;code&gt;tabindex="-1"&lt;/code&gt; (focusable via &lt;code&gt;.focus()&lt;/code&gt; but skipped by Tab).&lt;/p&gt;

&lt;p&gt;Each of those is a filter that runs on every candidate. The tabbable query re-runs on every Tab press — no caching — because content inside the dialog can change while it's open. A form that conditionally reveals a field, a loading state that resolves to a button, an error message with a retry link.&lt;/p&gt;

&lt;p&gt;One early bug: &lt;code&gt;&amp;lt;details&amp;gt;&lt;/code&gt; was in the selector, but &lt;code&gt;&amp;lt;details open&amp;gt;&lt;/code&gt; matched alongside its &lt;code&gt;&amp;lt;summary&amp;gt;&lt;/code&gt; child, returning three elements where the DOM only had two interactive ones. The fix was removing &lt;code&gt;details&lt;/code&gt; from the selector entirely — &lt;code&gt;&amp;lt;summary&amp;gt;&lt;/code&gt; is the interactive element users Tab to, &lt;code&gt;&amp;lt;details&amp;gt;&lt;/code&gt; is just the container.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Trap Lifecycle
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Activation.&lt;/strong&gt; Focus moves into the dialog. Priority chain: a specific element if provided → an element with &lt;code&gt;[data-autofocus]&lt;/code&gt; → the first tabbable element → the container itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Active.&lt;/strong&gt; Tab on the last element wraps to the first. Shift+Tab on the first wraps to the last. If focus somehow escapes — screen reader virtual cursor, programmatic focus change — a &lt;code&gt;focusin&lt;/code&gt; guard pulls it back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deactivation.&lt;/strong&gt; Focus returns to whatever was focused before the dialog opened. Usually the trigger button.&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;useEffect&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="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;enabled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&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;container&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;containerRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&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;container&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nx"&gt;previouslyFocused&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;activeElement&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;HTMLElement&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;placeInitialFocus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;keydown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;handleKeyDown&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="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;focusin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;handleFocusIn&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="k"&gt;return &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="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;keydown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;handleKeyDown&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="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;focusin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;handleFocusIn&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;toRestore&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;previouslyFocused&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&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="nx"&gt;toRestore&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;isConnected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;toRestore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;focus&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;span class="nx"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;requestAnimationFrame&lt;/code&gt;. An earlier version used rAF under the assumption that React might not have committed the portal to the DOM by the time the effect runs. This was wrong — &lt;code&gt;useEffect&lt;/code&gt; fires after the browser has painted. The container and its children are guaranteed to exist. The rAF added unnecessary async complexity and broke under test frameworks with fake timers. Removing it simplified both the implementation and the tests.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Tab Cycling Logic
&lt;/h3&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;handleKeyDown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;KeyboardEvent&lt;/span&gt;&lt;span class="p"&gt;)&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="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Tab&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&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;container&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;containerRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&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;container&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&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;tabbable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getTabbableElements&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;container&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="nx"&gt;tabbable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;event&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="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&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;active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;activeElement&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;HTMLElement&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;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tabbable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&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;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tabbable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&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="c1"&gt;// Focus is outside the container entirely — pull it back in&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;container&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;active&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;event&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;first&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;focus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&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="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shiftKey&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;active&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;event&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;last&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;focus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shiftKey&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;active&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;last&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;event&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;first&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;focus&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;The key insight: you only need to intercept Tab at the boundaries and when focus has escaped. When focus is on the last element and the user presses Tab, prevent the default and focus the first. When focus is on the first and the user presses Shift+Tab, focus the last. The &lt;code&gt;!container.contains(active)&lt;/code&gt; check handles the case where focus escaped via screen reader virtual cursor before the &lt;code&gt;focusin&lt;/code&gt; guard fired. Everywhere else, the browser's native Tab behavior works correctly within the container.&lt;/p&gt;




&lt;h2&gt;
  
  
  Building useClickOutside
&lt;/h2&gt;

&lt;p&gt;Clicking outside the dialog should close it. Simple concept, subtle implementation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why &lt;code&gt;mousedown&lt;/code&gt;, not &lt;code&gt;click&lt;/code&gt;.&lt;/strong&gt; A &lt;code&gt;click&lt;/code&gt; event fires after &lt;code&gt;mouseup&lt;/code&gt;. If a user presses inside the dialog, drags outside, and releases, a &lt;code&gt;click&lt;/code&gt; fires outside the dialog. The dialog would incorrectly dismiss. &lt;code&gt;mousedown&lt;/code&gt; captures intent at the moment of press. Same logic applies to &lt;code&gt;touchstart&lt;/code&gt; for mobile.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why capture phase.&lt;/strong&gt; The listener uses &lt;code&gt;addEventListener('mousedown', handler, true)&lt;/code&gt;. Capture fires before the event reaches child elements. If a child inside the dialog calls &lt;code&gt;stopPropagation()&lt;/code&gt;, a bubble-phase listener on &lt;code&gt;document&lt;/code&gt; would never see the event. Capture guarantees the check runs regardless.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The handler ref pattern.&lt;/strong&gt; The handler is stored in a ref so the effect doesn't re-attach listeners when the handler changes. The ref always holds the latest function — no stale closures — and the consumer doesn't need to memoize their callback with &lt;code&gt;useCallback&lt;/code&gt;.&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;handlerRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;handlerRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Roving Tabindex: Why Arrow Keys Matter
&lt;/h2&gt;

&lt;p&gt;A tab bar with five triggers, all with &lt;code&gt;tabindex="0"&lt;/code&gt;, requires pressing Tab five times to get past it. Roving tabindex solves this. Only the active trigger gets &lt;code&gt;tabindex="0"&lt;/code&gt;. All others get &lt;code&gt;tabindex="-1"&lt;/code&gt;. Tab enters, lands on the active trigger, next Tab exits. Arrow keys move between triggers.&lt;/p&gt;

&lt;p&gt;The hook handles orientation awareness (horizontal tabs ignore ArrowUp/Down, vertical menus ignore ArrowLeft/Right), loop wrapping, disabled item skipping, and Home/End keys.&lt;/p&gt;

&lt;p&gt;The part that surprised me was bridging focus and selection. When the hook moves focus to a new tab trigger, &lt;code&gt;onActiveChange&lt;/code&gt; fires. The callback reads &lt;code&gt;document.activeElement.dataset.value&lt;/code&gt; and updates the selected tab — making arrow keys both move focus AND select. This is automatic activation, the WAI-ARIA default.&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;useRovingTabIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;listRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;orientation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;loop&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;onActiveChange&lt;/span&gt;&lt;span class="p"&gt;:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;focused&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;activeElement&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;HTMLElement&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;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;focused&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&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="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;onValueChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&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;This works because &lt;code&gt;.focus()&lt;/code&gt; is synchronous — &lt;code&gt;document.activeElement&lt;/code&gt; updates immediately. But it's the kind of assumption that breaks silently if someone later makes the hook async. That's a comment in the code now.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Compound Component Wiring
&lt;/h2&gt;

&lt;p&gt;Dialog and Tabs both use the same pattern. The root is a pure context provider — no DOM output. Sub-components read from context. &lt;code&gt;Object.assign&lt;/code&gt; enables dot notation:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Dialog&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;DialogRoot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;Trigger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DialogTrigger&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;Portal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DialogPortal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;Overlay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DialogOverlay&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DialogContent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;Title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DialogTitle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DialogDescription&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;Close&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DialogClose&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;Both components support controlled and uncontrolled usage via a &lt;code&gt;useControllable&lt;/code&gt; hook. The mode detection checks &lt;code&gt;value !== undefined&lt;/code&gt;. Not &lt;code&gt;value !== null&lt;/code&gt;. Not &lt;code&gt;!!value&lt;/code&gt;. Specifically &lt;code&gt;undefined&lt;/code&gt;. This matters because &lt;code&gt;null&lt;/code&gt; is a valid controlled value — the parent is explicitly saying "nothing selected right now." &lt;code&gt;false&lt;/code&gt; is a valid controlled value — a dialog that's closed. &lt;code&gt;0&lt;/code&gt; is a valid controlled value — a tabs component where the first tab is selected by index. The only signal that a component is uncontrolled is when the parent never passed &lt;code&gt;value&lt;/code&gt; at all. In React, an omitted prop is &lt;code&gt;undefined&lt;/code&gt;. Every other falsy value is an intentional choice by the consumer.&lt;/p&gt;

&lt;p&gt;For Tabs, triggers and panels are linked by a string &lt;code&gt;value&lt;/code&gt; prop — not by index. Indices break when you reorder tabs, conditionally render them, or add them dynamically. Strings survive structural changes. The ARIA cross-references (&lt;code&gt;aria-controls&lt;/code&gt; on triggers, &lt;code&gt;aria-labelledby&lt;/code&gt; on panels) are derived deterministically from a base id:&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;getTriggerId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;baseId&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="nx"&gt;value&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="p"&gt;{&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="nx"&gt;baseId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-trigger-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;value&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="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getPanelId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;baseId&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="nx"&gt;value&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="p"&gt;{&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="nx"&gt;baseId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-panel-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;value&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No registration system where components mount and register their ids. No mount-order dependency — the trigger doesn't need to render before the panel for the ids to exist. Pure derivation from &lt;code&gt;baseId + value&lt;/code&gt;. Both functions live in the context file and are called by Trigger (to set &lt;code&gt;aria-controls={getPanelId(...)}&lt;/code&gt;) and Panel (to set &lt;code&gt;aria-labelledby={getTriggerId(...)}&lt;/code&gt;).&lt;/p&gt;




&lt;h2&gt;
  
  
  The ARIA You Don't See
&lt;/h2&gt;

&lt;p&gt;Every ARIA attribute is a real audio experience. This stopped being abstract after testing with VoiceOver.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On the trigger:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Attribute&lt;/th&gt;
&lt;th&gt;What the user hears&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;aria-haspopup="dialog"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;"dialog popup" after the button label&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;aria-expanded&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;"expanded" / "collapsed"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;aria-controls&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Used for virtual cursor navigation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;On the dialog panel:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Attribute&lt;/th&gt;
&lt;th&gt;What the user hears&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;role="dialog"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;"dialog"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;aria-modal="true"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Constrains virtual cursor to dialog&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;aria-labelledby&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reads the title text on open&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;aria-describedby&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reads description after title&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;On tabs:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Element&lt;/th&gt;
&lt;th&gt;What the user hears&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;role="tablist"&lt;/code&gt; + &lt;code&gt;role="tab"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;"Account, tab 1 of 3"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;aria-selected&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;"selected"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;tabindex="0"&lt;/code&gt; on panel&lt;/td&gt;
&lt;td&gt;Tab from tablist lands on panel content&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;tabindex="0"&lt;/code&gt; on the panel is easy to miss but important. After selecting a tab with arrow keys, pressing Tab should land on the panel content — not skip to some other focusable element elsewhere on the page. The panel itself needs to be a tab stop.&lt;/p&gt;

&lt;p&gt;None of this is visible. All of it is load-bearing.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Broke Along the Way
&lt;/h2&gt;

&lt;p&gt;Three things broke in ways I didn't expect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;jsdom doesn't do layout.&lt;/strong&gt; The focus trap's visibility check used &lt;code&gt;el.offsetParent === null&lt;/code&gt; — the standard browser approach. jsdom doesn't implement &lt;code&gt;offsetParent&lt;/code&gt;. It's always &lt;code&gt;null&lt;/code&gt;. Every element was being filtered out as "hidden." The fix was replacing &lt;code&gt;offsetParent&lt;/code&gt; with a &lt;code&gt;getComputedStyle&lt;/code&gt; ancestor walk checking for &lt;code&gt;display: none&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;jsdom lies about &lt;code&gt;tabIndex&lt;/code&gt;.&lt;/strong&gt; In a real browser, a &lt;code&gt;&amp;lt;button&amp;gt;&lt;/code&gt; without an explicit &lt;code&gt;tabindex&lt;/code&gt; attribute has &lt;code&gt;tabIndex === 0&lt;/code&gt;. In jsdom, it returns &lt;code&gt;-1&lt;/code&gt;. A check like &lt;code&gt;if (el.tabIndex &amp;lt; 0) return false&lt;/code&gt; silently filters out every button and input. The fix: only check &lt;code&gt;el.tabIndex&lt;/code&gt; when &lt;code&gt;el.hasAttribute('tabindex')&lt;/code&gt;. If the element matched the focusable selector, trust the selector.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;React 18 vs 19 context syntax.&lt;/strong&gt; The root component used &lt;code&gt;&amp;lt;DialogContext value={context}&amp;gt;&lt;/code&gt; — React 19 syntax. React 18 requires &lt;code&gt;&amp;lt;DialogContext.Provider value={context}&amp;gt;&lt;/code&gt;. Without &lt;code&gt;.Provider&lt;/code&gt;, context is &lt;code&gt;null&lt;/code&gt; and every compound component throws. A one-line fix, but Tabs used &lt;code&gt;.Provider&lt;/code&gt; from the start — lesson learned.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Scroll lock.&lt;/strong&gt; When the dialog opens, the page behind it should stop scrolling. I deferred this because the headless primitive shouldn't own the implementation — scroll lock involves &lt;code&gt;document.body&lt;/code&gt; style manipulation that varies by browser and layout. But the primitive should at least provide a hook or callback for consumers to attach their own. That API gap is the first thing I'd add.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;aria-describedby&lt;/code&gt; pointing to nothing.&lt;/strong&gt; If the consumer renders &lt;code&gt;Dialog.Content&lt;/code&gt; without &lt;code&gt;Dialog.Description&lt;/code&gt;, &lt;code&gt;aria-describedby&lt;/code&gt; still points to the generated id. Screen readers handle broken id references gracefully — they ignore them. But it's not clean. A v2 would conditionally add &lt;code&gt;aria-describedby&lt;/code&gt; only when Description is mounted, which means a registration mechanism between Content and Description. I chose the simpler approach for v1.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Animation support.&lt;/strong&gt; The compound component pattern gives consumers direct access to Overlay and Content, so they can animate with CSS transitions or any library. But there's no &lt;code&gt;onExitComplete&lt;/code&gt; callback — no way to tell the primitive "don't unmount yet, the exit animation is still running." Solving this cleanly requires either a render-even-when-closed prop or an animation-aware state machine. Both add complexity. I'd solve it before shipping to npm.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;var()&lt;/code&gt; chains in the token output.&lt;/strong&gt; This carries over from the &lt;a href="https://dev.to/vmvenkatesh78/130-shades-of-gray-building-a-design-token-pipeline-that-killed-our-color-chaos-6j"&gt;token pipeline article&lt;/a&gt;. When the styled layer wraps these headless primitives with token-driven CSS, the flat hex values in the generated output mean every semantic change requires a rebuild. &lt;code&gt;var()&lt;/code&gt; chains would cascade at runtime. For a published design system, that's the right trade-off — and the first thing I'll revisit in Phase 3.&lt;/p&gt;




&lt;p&gt;All three primitives, the hooks that power them, and 173 tests are in the &lt;a href="https://github.com/vmvenkatesh78/flintwork" rel="noopener noreferrer"&gt;flintwork repo&lt;/a&gt;. The next phase is a styled layer that consumes these primitives and applies token-driven CSS — connecting the design token pipeline to the components that use them.&lt;/p&gt;

&lt;p&gt;The thing I keep coming back to: accessibility isn't a feature you add to components. It's the architecture of the component. The focus trap isn't a wrapper around the dialog — it's what makes the dialog a dialog. The roving tabindex isn't an enhancement to the tab bar — it's what makes keyboard navigation usable. When you build the behavior layer first, the accessibility comes with it. When you build the visual layer first and add accessibility later, it never quite fits.&lt;/p&gt;

</description>
      <category>a11y</category>
      <category>react</category>
      <category>typescript</category>
      <category>frontend</category>
    </item>
    <item>
      <title>130 Shades of Gray: Building a Design Token Pipeline That Killed Our Color Chaos</title>
      <dc:creator>venkatesh m</dc:creator>
      <pubDate>Sun, 01 Mar 2026 14:09:48 +0000</pubDate>
      <link>https://forem.com/vmvenkatesh78/130-shades-of-gray-building-a-design-token-pipeline-that-killed-our-color-chaos-6j</link>
      <guid>https://forem.com/vmvenkatesh78/130-shades-of-gray-building-a-design-token-pipeline-that-killed-our-color-chaos-6j</guid>
      <description>&lt;h2&gt;
  
  
  The Codebase That Had 130 Grays
&lt;/h2&gt;

&lt;p&gt;I spent four years working in a production codebase that had 130 gray variables. I actually counted them once.&lt;/p&gt;

&lt;p&gt;Someone needs a border color, creates &lt;code&gt;$gray-v1&lt;/code&gt;. Someone else needs a slightly darker background,doesn't find the right gray in the file — or can't tell which of 130 is correct — creates &lt;code&gt;$gray-v2&lt;/code&gt;. Four years of that across 5 product modules and this is what you get:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight scss"&gt;&lt;code&gt;&lt;span class="nv"&gt;$gray-v12&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;#6B7280&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$gray-v13&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;#6E7581&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$gray-v14&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;#707682&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$gray-v38&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;#4A5163&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$gray-v39&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;#4B5264&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$gray-v47&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;#5C6370&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$gray-v89&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;#3D4450&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seven variables. Can you tell them apart by hex value? We couldn't either. &lt;code&gt;$gray-v38&lt;/code&gt; and &lt;code&gt;$gray-v39&lt;/code&gt; differ by one hex digit. They were used on different screens for the same purpose — disabled text. Created six months apart by two different developers who had no way of knowing the other one existed.&lt;/p&gt;

&lt;p&gt;And the grays weren't even the worst part — the utility classes were.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight scss"&gt;&lt;code&gt;&lt;span class="nc"&gt;.FontSm14GrayV52&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;14px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$gray-v52&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.FontRg16GrayV23&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;16px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$gray-v23&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.BgGrayV7Pd12&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="nv"&gt;$gray-v7&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;12px&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;Font size, color, and gray version baked into one class name. Need the same gray at a different font size? New class. Same font size, different gray? Another class.&lt;/p&gt;

&lt;p&gt;Now imagine a designer updates the disabled text color from &lt;code&gt;#6B7280&lt;/code&gt; to &lt;code&gt;#8B95A5&lt;/code&gt;. Go find every gray variable that was &lt;em&gt;being used as&lt;/em&gt; disabled text. Not labeled as disabled text — just happening to be that hex value, or one digit away from it, scattered across 130 numbered variables and 200+ utility classes. Good luck.&lt;/p&gt;

&lt;p&gt;Nobody built this mess on purpose. There was no architecture to prevent it. What was missing wasn't discipline — it was a system.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Happens
&lt;/h2&gt;

&lt;p&gt;The instinct is to blame developers. Better naming conventions. Stricter code review. More discipline.&lt;/p&gt;

&lt;p&gt;We tried all of that. It doesn't work — not at scale, not over time.&lt;/p&gt;

&lt;p&gt;Here's what actually happens. A developer needs a color for disabled text. They open the variables file, see 80 grays with names like &lt;code&gt;$gray-v52&lt;/code&gt; and &lt;code&gt;$gray-v61&lt;/code&gt;, and have no way to know which one is "the disabled text gray." Nothing in the name tells them. Nothing in the system maps intent to value. So they pick one that looks close enough, or they create a new one. Both choices are rational. Both make the problem worse.&lt;/p&gt;

&lt;p&gt;Code review doesn't catch this because the reviewer is looking at the same meaningless variable names. They can verify that a gray was used. They can't verify it was the &lt;em&gt;right&lt;/em&gt; gray. There's no source of truth to check against.&lt;/p&gt;

&lt;p&gt;Naming conventions help for about six months. Then the team grows, the conventions drift, someone joins who never read the doc, and you're back to &lt;code&gt;$gray-v131&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The problem isn't that developers make bad choices. It's that the system they're working in makes the right choice invisible. You can't consistently pick the correct gray if nothing in the codebase defines what "correct" means.&lt;/p&gt;




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

&lt;p&gt;Take one gray: &lt;code&gt;#6B7280&lt;/code&gt;. In the old system, that hex value could live in &lt;code&gt;$gray-v12&lt;/code&gt; or &lt;code&gt;$gray-v47&lt;/code&gt; or a utility class or hardcoded inline. No meaning attached, no intent captured. Just a color floating in a file somewhere.&lt;/p&gt;

&lt;p&gt;In a token system, that same gray passes through three layers before it ever reaches a component.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tier 1 — Global tokens.&lt;/strong&gt; These are the raw palette. Every color, every spacing value, every font size your system supports. Named by what they are, not what they do.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"color"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"gray"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"500"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"$value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"#6B7280"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"600"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"$value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"#4B5563"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"700"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"$value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"#374151"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;color.gray.500&lt;/code&gt; is a fact. It's a gray. It's the 500 weight in your scale. It says nothing about where it goes or why it exists. That's intentional — global tokens are the raw material, not the decisions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tier 2 — Semantic tokens.&lt;/strong&gt; This is where intent enters the system. Semantic tokens don't define colors — they define &lt;em&gt;purposes&lt;/em&gt; and point to a global token.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"color"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"primary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"$value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{color.gray.700}"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"secondary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"$value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{color.gray.500}"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"disabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"$value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{color.gray.500}"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"border"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"$value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{color.gray.500}"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;#6B7280&lt;/code&gt; has meaning. It's &lt;code&gt;color.text.secondary&lt;/code&gt;. It's &lt;code&gt;color.text.disabled&lt;/code&gt;. It's &lt;code&gt;color.border.default&lt;/code&gt;. Three different purposes, same underlying value — and that relationship is explicit, not accidental.&lt;/p&gt;

&lt;p&gt;Remember the designer changing the disabled text color? In the old system, that was an archaeology project across 130 variables. Here, you update &lt;code&gt;color.text.disabled&lt;/code&gt; to point to &lt;code&gt;color.gray.600&lt;/code&gt; instead of &lt;code&gt;color.gray.500&lt;/code&gt;. One change. Every disabled text element in the product updates. Nothing else is affected.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tier 3 — Component tokens.&lt;/strong&gt; These bind semantic tokens to specific component surfaces.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"button"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"disabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"$value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{color.text.disabled}"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"input"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"disabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"$value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{color.text.disabled}"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A developer building a button never picks a gray. They use &lt;code&gt;--fw-button-text-disabled&lt;/code&gt;. They don't need to know it resolves to &lt;code&gt;color.text.disabled&lt;/code&gt;, which resolves to &lt;code&gt;color.gray.500&lt;/code&gt;, which resolves to &lt;code&gt;#6B7280&lt;/code&gt;. The chain exists, but the developer at the end of it just sees a name that tells them exactly what it's for.&lt;/p&gt;

&lt;p&gt;This is what it looks like when the system makes the right choice the only choice. There's no wrong gray to pick because developers aren't picking grays. They're picking intentions — &lt;code&gt;--fw-button-text-disabled&lt;/code&gt;, &lt;code&gt;--fw-input-border-default&lt;/code&gt;, &lt;code&gt;--fw-card-bg-primary&lt;/code&gt; — and the token pipeline resolves those intentions to actual values.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And here's where theming comes in for free.&lt;/strong&gt; The entire resolution chain above is your light theme. Your dark theme is a second set of semantic mappings pointing to different global tokens:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"color"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"disabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"$value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{color.gray.400}"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same component tokens. Same developer API. Different underlying values. Swap a single attribute on the root element:&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;data-theme=&lt;/span&gt;&lt;span class="s"&gt;"dark"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="c"&gt;&amp;lt;!-- every component re-resolves automatically --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No separate stylesheet. No conditional logic in components. The architecture handles it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Build
&lt;/h2&gt;

&lt;p&gt;The full pipeline is in the &lt;a href="https://github.com/vmvenkatesh78/flintwork" rel="noopener noreferrer"&gt;flintwork repo&lt;/a&gt; if you want every line. Here I'll walk through the parts that do the actual work.&lt;/p&gt;

&lt;p&gt;The input is a folder of JSON files organized by tier — &lt;code&gt;global/&lt;/code&gt; for the raw palette, &lt;code&gt;semantic/&lt;/code&gt; for theme mappings, &lt;code&gt;component/&lt;/code&gt; for component-level bindings. The output is CSS files with custom properties scoped to &lt;code&gt;data-theme&lt;/code&gt; attributes. One build script, no Style Dictionary, no runtime dependencies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Flattening nested tokens.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Token files are nested JSON following the W3C Design Tokens format. A token has a &lt;code&gt;$value&lt;/code&gt; — everything else is a group container. The first step is flattening that tree into a flat map of dot-notation paths to values:&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;flattenTokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;group&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TokenGroup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;prefix&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="dl"&gt;''&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;FlatTokenMap&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;map&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FlatTokenMap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;group&lt;/span&gt;&lt;span class="p"&gt;))&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="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&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="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// skip metadata&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;prefix&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="nx"&gt;prefix&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="nx"&gt;key&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="nx"&gt;key&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="nf"&gt;isTokenValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;object&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;value&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="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nested&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;flattenTokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;TokenGroup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;nestedPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;nestedValue&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;nested&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nestedPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;nestedValue&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;map&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;{ color: { gray: { 500: { $value: "#6B7280" } } } }&lt;/code&gt; becomes &lt;code&gt;Map { "color.gray.500" =&amp;gt; "#6B7280" }&lt;/code&gt;. Keys starting with &lt;code&gt;$&lt;/code&gt; are W3C metadata (&lt;code&gt;$type&lt;/code&gt;, &lt;code&gt;$description&lt;/code&gt;) and get skipped — they're documentation, not output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Resolving references.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The core problem: tokens point to other tokens. &lt;code&gt;{color.text.disabled}&lt;/code&gt; points to &lt;code&gt;{color.gray.500}&lt;/code&gt; which points to &lt;code&gt;#6B7280&lt;/code&gt;. The resolver follows those chains using regex replacement, so a single value can contain multiple references:&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;resolveValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;value&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="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
  &lt;span class="nx"&gt;lookups&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FlatTokenMap&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
  &lt;span class="nx"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&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="nx"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`Circular reference detected while resolving: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;value&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="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;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;value&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="sr"&gt;/&lt;/span&gt;&lt;span class="se"&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;+&lt;/span&gt;&lt;span class="se"&gt;)\}&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;refPath&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &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;lookup&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;lookups&lt;/span&gt;&lt;span class="p"&gt;)&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;resolved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lookup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;refPath&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="nx"&gt;resolved&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;resolveValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lookups&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&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="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Unresolved token reference: {&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;refPath&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="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 function handles three value types — arrays for font family stacks that get joined with commas, numbers that pass through as strings, and string values that might contain references. The depth guard at 10 catches circular references instead of blowing the call stack. The &lt;code&gt;throw&lt;/code&gt; on unresolved references is deliberate — I want the build to fail loudly if a token points to nothing. A silent fallback means a missing color in production that nobody catches until a user reports it. Fail at build time, not at render time.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;lookups&lt;/code&gt; parameter is an array of flat token maps, searched in order. The build passes &lt;code&gt;[componentTokens, semanticTokens, typographyTokens, globalTokens]&lt;/code&gt; so component-level overrides resolve before falling back to semantic, then global.&lt;/p&gt;

&lt;p&gt;The order encodes the architecture.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CSS output.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Token paths convert to custom properties with a &lt;code&gt;--fw-&lt;/code&gt; prefix to avoid collisions in consumer apps. &lt;code&gt;color.text.primary&lt;/code&gt; becomes &lt;code&gt;--fw-color-text-primary&lt;/code&gt;. The theme scoping is straightforward — light tokens go under &lt;code&gt;:root&lt;/code&gt;, dark tokens go under &lt;code&gt;[data-theme="dark"]&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="nd"&gt;:root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--fw-color-text-primary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#374151&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--fw-color-text-disabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#6B7280&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--fw-button-text-disabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#6B7280&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&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="py"&gt;--fw-color-text-primary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#F9FAFB&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--fw-color-text-disabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#9CA3AF&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--fw-button-text-disabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#9CA3AF&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 pipeline generates a combined &lt;code&gt;tokens.css&lt;/code&gt; plus separate &lt;code&gt;light.css&lt;/code&gt; and &lt;code&gt;dark.css&lt;/code&gt; for consumers who only need one theme. Three files, 213 tokens resolved, light and dark themes ready. The whole build runs in about 40ms.&lt;/p&gt;




&lt;h2&gt;
  
  
  Theme Switching in Practice
&lt;/h2&gt;

&lt;p&gt;The old codebase never had dark mode. Now I understand why — imagine adding it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight scss"&gt;&lt;code&gt;&lt;span class="nc"&gt;.card&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$gray-v3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$gray-v71&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-mode&lt;/span&gt; &lt;span class="nc"&gt;.card&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$gray-v88&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$gray-v14&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;That's one component. Now multiply it across every surface, every text style, every border and background in the product. Each one needs a &lt;code&gt;.dark-mode&lt;/code&gt; override block. Each one requires someone to pick the right gray from a list of 130. Nobody was willing to start that project. I don't blame them.&lt;/p&gt;

&lt;p&gt;With tokens, dark mode already exists. Not because I built it separately — because the architecture makes it free:&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;.card&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;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;--fw-color-surface-primary&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;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;--fw-color-text-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;No override block. The component references intentions, not values. Switch one attribute on the root:&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;data-theme=&lt;/span&gt;&lt;span class="s"&gt;"dark"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="c"&gt;&amp;lt;!-- every token re-resolves automatically --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same component code. Same custom property names. Different values underneath. No stylesheet swap, no JavaScript toggling classes on individual elements, no new CSS files.&lt;/p&gt;

&lt;p&gt;Dark mode went from "a project nobody wanted to touch" to a single attribute change. That's the difference between having a system and not having one.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;If I rebuilt this pipeline tomorrow, two things would change immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Token validation before resolution.&lt;/strong&gt; Right now a typo in a JSON file passes silently. &lt;code&gt;$valeu&lt;/code&gt; instead of &lt;code&gt;$value&lt;/code&gt;, a hex code like &lt;code&gt;#6B728&lt;/code&gt; with five digits — the pipeline won't catch it until something downstream breaks or the CSS output looks wrong. For a solo project with 213 tokens, I can eyeball the output. For a team where five people are editing token files in the same sprint, this is the first thing that breaks. A JSON schema validation step before resolution even starts — checking that every token has a valid &lt;code&gt;$value&lt;/code&gt;, that hex codes are well-formed, that references point to paths that actually exist — would catch these at the source instead of at the symptom.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Flat values vs. &lt;code&gt;var()&lt;/code&gt; chains.&lt;/strong&gt; The pipeline resolves every reference to its final value. &lt;code&gt;--fw-button-text-disabled&lt;/code&gt; outputs &lt;code&gt;#6B7280&lt;/code&gt;, not &lt;code&gt;var(--fw-color-text-disabled)&lt;/code&gt;. I chose this because flat values are easier to debug — you inspect an element and see the actual color, not a chain of three custom property references. But the tradeoff is real. If a semantic token changes, I rebuild and every downstream value updates. A &lt;code&gt;var()&lt;/code&gt; chain would let that cascade at runtime without a rebuild. For a shipped design system with consumers who import your CSS, the &lt;code&gt;var()&lt;/code&gt; approach is probably the right call. I went with flat values because I was optimizing for "can I see what's happening in DevTools" during development. I'd revisit that decision before publishing to npm.&lt;/p&gt;

&lt;p&gt;Both of these are solvable. Neither required rearchitecting anything — they're additive improvements to a pipeline that already works. The gaps are at the edges, not the foundation.&lt;/p&gt;




&lt;p&gt;I built this pipeline for flintwork, not for that codebase. But everything I learned about what breaks — and why — came from four years of living inside a system without one. That's the thing about building a system from scratch: you have to know exactly what the absence of one costs.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The second article in this series — on building the headless primitives that consume these tokens — is &lt;a href="https://dev.to/vmvenkatesh78/your-dialog-has-roledialog-that-doesnt-make-it-accessible-4lha"&gt;now live&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>designsystems</category>
      <category>css</category>
      <category>typescript</category>
      <category>frontend</category>
    </item>
    <item>
      <title>I replaced our chaotic WhatsApp sports groups with a zero-login web app</title>
      <dc:creator>venkatesh m</dc:creator>
      <pubDate>Fri, 27 Feb 2026 14:11:03 +0000</pubDate>
      <link>https://forem.com/vmvenkatesh78/i-replaced-our-chaotic-whatsapp-sports-groups-with-a-zero-login-web-app-597g</link>
      <guid>https://forem.com/vmvenkatesh78/i-replaced-our-chaotic-whatsapp-sports-groups-with-a-zero-login-web-app-597g</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/weekend-2026-02-28"&gt;DEV Weekend Challenge: Community&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Community
&lt;/h2&gt;

&lt;p&gt;Every Friday around 5 PM, my WhatsApp groups light up.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Cricket tomorrow 7 AM anyone?"&lt;br&gt;
"I'm in"&lt;br&gt;
"Me too"&lt;br&gt;
"Where?"&lt;br&gt;
"Same place bro"&lt;br&gt;
"How many people?"&lt;br&gt;
&lt;em&gt;...17 unread messages later...&lt;/em&gt;&lt;br&gt;
"So are we playing or not?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I live in Chennai, India, where pickup cricket, basketball, and badminton are a daily thing. But the coordination happens through scattered WhatsApp groups where messages get buried under memes, nobody confirms until game time, you never know how many people are actually coming, and plans fall apart because "only 4 people confirmed."&lt;/p&gt;

&lt;p&gt;There's no single place to see what's happening, who's in, and whether there's space. This app is for every person who's ever rage-scrolled a WhatsApp group trying to figure out if the game is still on.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;pickup&lt;/strong&gt; — a web app for organizing pickup sports games with zero friction.&lt;/p&gt;

&lt;p&gt;The flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Create a game&lt;/strong&gt; — pick a sport (cricket, basketball, badminton, football), set time, place, and max players&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Share the link&lt;/strong&gt; — one-tap WhatsApp share with a pre-filled message&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;People join&lt;/strong&gt; — tap the link, type your name, done. Under 10 seconds.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No login. No app download. No sign-up form. Someone taps a link in their WhatsApp group and they're in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key features:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Support for 5 sport types with smart defaults (22 for cricket, 10 for basketball, etc.)&lt;/li&gt;
&lt;li&gt;Real-time player list — everyone sees who joins/leaves instantly via WebSocket subscriptions&lt;/li&gt;
&lt;li&gt;WhatsApp-first sharing with Web Share API and clipboard fallback&lt;/li&gt;
&lt;li&gt;Short, readable game URLs (&lt;code&gt;/game/k7m3px&lt;/code&gt;) instead of UUID soup&lt;/li&gt;
&lt;li&gt;Live progress bar showing spots filled&lt;/li&gt;
&lt;li&gt;"My Games" page tracking games you've created or joined&lt;/li&gt;
&lt;li&gt;Fully mobile-responsive — tested at 375px because that's where 90% of users will be&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The deliberate no-auth decision:&lt;/strong&gt; The target user taps a WhatsApp link on their phone. Any login wall — even "Sign in with Google" — kills the flow. The cost of a fake name joining is low. The cost of friction is high. I use sessionStorage for game-session identity and localStorage for cross-session tracking. Not bulletproof, but the right tradeoff for pickup games with 10-22 people.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Live app: &lt;a href="https://getpickup.vercel.app" rel="noopener noreferrer"&gt;getpickup.vercel.app&lt;/a&gt;&lt;/strong&gt;&lt;/p&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%2F6n3nayav6jlrjcg91dpk.png" 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%2F6n3nayav6jlrjcg91dpk.png" alt=" "&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Home page — zero-friction landing&lt;/em&gt;&lt;/p&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%2F8umy5m5wmbrb429lecod.png" 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%2F8umy5m5wmbrb429lecod.png" alt=" "&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Browse — look through games and join the one you're excited in and share with others to have your gang over&lt;/em&gt;&lt;/p&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%2Fsgaluxfloitty1ilv2ng.png" 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%2Fsgaluxfloitty1ilv2ng.png" alt=" "&gt;&lt;/a&gt;&lt;br&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%2Futktmagt8qmtqxy321k9.png" 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%2Futktmagt8qmtqxy321k9.png" alt=" "&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Create a game — 30 seconds to set up&lt;/em&gt;&lt;/p&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%2Fec0n8r6r7ujwme3fxcop.png" 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%2Fec0n8r6r7ujwme3fxcop.png" alt=" "&gt;&lt;/a&gt;&lt;br&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%2Fa7uftjbrxf5q870yb6ut.png" 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%2Fa7uftjbrxf5q870yb6ut.png" alt=" "&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Have a look through at the games you have created, who's joined and let more people know by sharing the link&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Try it yourself — create a game, copy the link, open it in an incognito tab, and join as a different player. Watch the player list update in real-time.&lt;/p&gt;
&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;

&lt;/p&gt;
&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/vmvenkatesh78" rel="noopener noreferrer"&gt;
        vmvenkatesh78
      &lt;/a&gt; / &lt;a href="https://github.com/vmvenkatesh78/pickup" rel="noopener noreferrer"&gt;
        pickup
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Organize pickup sports games in seconds. No login, no app download — just create, share, and play.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;pickup 🏐&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;The simplest way to organize and join pickup sports games. No login. No app download. Just create, share, and play.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Built for the &lt;a href="https://dev.to/challenges/weekend-2026-02-28" rel="nofollow"&gt;DEV Weekend Challenge&lt;/a&gt; — "Build for Your Community"&lt;/p&gt;
&lt;/blockquote&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;The problem&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Coordinating pickup sports happens through scattered WhatsApp groups where messages get buried, nobody confirms until game time, and plans fall apart at the last minute. There's no single place to see what's happening, who's in, and whether there's space.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;The solution&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Create a game in 30 seconds, share the link on WhatsApp, and people join with just their name. Live player count, auto-close when full, and a browse page to discover games nearby.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Features&lt;/h2&gt;

&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Create a game&lt;/strong&gt; — pick a sport, set time/place/max players, get a shareable link&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Join with zero friction&lt;/strong&gt; — no login, no signup, just your name&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-time player list&lt;/strong&gt; — see who's in and how many spots are left, live&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WhatsApp&lt;/strong&gt;…&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/vmvenkatesh78/pickup" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;




&lt;p&gt;&lt;strong&gt;Project structure highlights:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;src/lib/api.ts&lt;/code&gt; — all Supabase queries and real-time subscriptions&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;src/lib/utils.ts&lt;/code&gt; — share codes, date formatting, WhatsApp sharing&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;src/pages/GamePage.tsx&lt;/code&gt; — the core experience with real-time player updates&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;supabase/schema.sql&lt;/code&gt; — database schema with RLS policies&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ARCHITECTURE.md&lt;/code&gt; — full technical documentation&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Tech stack:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Framework&lt;/td&gt;
&lt;td&gt;React + TypeScript&lt;/td&gt;
&lt;td&gt;Type safety with fast iteration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build&lt;/td&gt;
&lt;td&gt;Vite&lt;/td&gt;
&lt;td&gt;Instant HMR, zero config&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Styling&lt;/td&gt;
&lt;td&gt;Tailwind CSS v4&lt;/td&gt;
&lt;td&gt;Utility-first, mobile-first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backend&lt;/td&gt;
&lt;td&gt;Supabase&lt;/td&gt;
&lt;td&gt;Postgres + REST API + real-time subscriptions in one service&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hosting&lt;/td&gt;
&lt;td&gt;Vercel&lt;/td&gt;
&lt;td&gt;One-click deploy from GitHub&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Real-time: the feature that makes it click
&lt;/h3&gt;

&lt;p&gt;When someone joins or leaves, every person viewing the game page sees the change instantly. I subscribe to Postgres changes on the &lt;code&gt;players&lt;/code&gt; table filtered by &lt;code&gt;game_id&lt;/code&gt;, and re-fetch the full player list on any event:&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;channel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`game-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;gameId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-players`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;postgres_changes&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;event&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;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;public&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;table&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;players&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`game_id=eq.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;gameId&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="p"&gt;},&lt;/span&gt;
    &lt;span class="k"&gt;async &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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;players&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="nf"&gt;select&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;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;game_id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;gameId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;joined_at&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;ascending&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;callback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&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="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Re-fetching the full list on every change isn't the most efficient approach, but for max 22 players it's completely fine and way simpler than merging individual row events.&lt;/p&gt;

&lt;h3&gt;
  
  
  Share codes over UUIDs
&lt;/h3&gt;

&lt;p&gt;Game URLs use 6-character codes (&lt;code&gt;k7m3px&lt;/code&gt;) instead of UUIDs. Ambiguous characters (0/O, 1/l/I) are stripped out. Around 700 million combinations — more than enough.&lt;/p&gt;

&lt;h3&gt;
  
  
  Edge cases I caught
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stale sessions&lt;/strong&gt; — if your player record was deleted but sessionStorage still says "joined," the page detects the mismatch and resets&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Game full race condition&lt;/strong&gt; — client-side count check before the insert catches most simultaneous joins&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Double-submit&lt;/strong&gt; — useRef locks prevent spam-clicking from firing multiple API calls&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What I'd build next
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;OTP phone verification for spam prevention&lt;/li&gt;
&lt;li&gt;Waitlist with auto-promote when someone leaves&lt;/li&gt;
&lt;li&gt;Recurring games ("Every Saturday 7 AM at Marina Beach")&lt;/li&gt;
&lt;li&gt;Push notifications an hour before game time&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;The constraint of a weekend made this product better, not worse. "Should I add auth?" became "Does my user need auth to get value?" — and the answer was no.&lt;/p&gt;

&lt;p&gt;If you're in Chennai (or anywhere with pickup sports), give it a try: &lt;strong&gt;&lt;a href="https://getpickup.vercel.app" rel="noopener noreferrer"&gt;getpickup.vercel.app&lt;/a&gt;&lt;/strong&gt; 🏏🏀🏸&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
