{"id":"l-ds-3-1","courseId":"design-systems","lang":"en","kind":"lesson","summary":"Design component APIs with enum variants instead of boolean flags, consistent prop vocabulary, native attributes passed through, and slots for content, using Northwind’s Button as the example.","html":"<p>Northwind’s 2021 Button had nine props: <code dir=\"ltr\">primary</code>, <code dir=\"ltr\">secondary</code>, <code dir=\"ltr\">danger</code>, <code dir=\"ltr\">small</code>, <code dir=\"ltr\">large</code>, <code dir=\"ltr\">outline</code>, <code dir=\"ltr\">block</code>, <code dir=\"ltr\">icon</code> and <code dir=\"ltr\">onPress</code>. The seven booleans alone allow 128 combinations, and teams found most of them. The production app contained <code dir=\"ltr\">&lt;Button primary danger small large&gt;</code>. It rendered something, and nobody could say what it was meant to be. A component’s API is a contract with every team that uses it, and like any contract, the hard part is what it allows.</p>\n<h2 id=\"sec-1\" tabindex=\"-1\">Enums for choices, booleans for states</h2>\n<p>The first rule is about the shape of props. If options are <strong>mutually exclusive</strong>, they belong in one enum prop. If something is <strong>on or off</strong>, it’s a boolean.</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"tsx\" data-index=\"0\"><div class=\"code-head\"><span class=\"code-title\">Button.tsx</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">type</span><span style=\"color:#953800;--shiki-dark:#FFA657\"> ButtonProps</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#953800;--shiki-dark:#FFA657\"> React</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#953800;--shiki-dark:#FFA657\">ComponentProps</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">&#x3C;</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'button'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">> </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">&#x26;</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#953800;--shiki-dark:#FFA657\">  variant</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">?:</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> 'primary'</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> |</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> 'secondary'</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> |</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> 'danger'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#953800;--shiki-dark:#FFA657\">  size</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">?:</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> 'sm'</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> |</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> 'md'</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> |</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> 'lg'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#953800;--shiki-dark:#FFA657\">  loading</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">?:</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> boolean</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#953800;--shiki-dark:#FFA657\">  startIcon</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">?:</span><span style=\"color:#953800;--shiki-dark:#FFA657\"> React</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#953800;--shiki-dark:#FFA657\">ReactNode</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#953800;--shiki-dark:#FFA657\">  endIcon</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">?:</span><span style=\"color:#953800;--shiki-dark:#FFA657\"> React</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#953800;--shiki-dark:#FFA657\">ReactNode</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">};</span></span></code></pre></div>\n<p>With <code dir=\"ltr\">variant</code> as an enum, <code dir=\"ltr\">primary</code> and <code dir=\"ltr\">danger</code> can’t both be true; the type system rejects it before code review. <code dir=\"ltr\">loading</code> stays a boolean because a button is either loading or not, independently of its variant.</p>\n<h2 id=\"sec-2\" tabindex=\"-1\">One vocabulary across the system</h2>\n<p>The second rule is consistency across components. If Button uses <code dir=\"ltr\">size=&quot;sm&quot;</code>, so does Input, Select, Tabs and Badge, with the same three values. If one component calls its change handler <code dir=\"ltr\">onChange</code> with the new value, they all do. Northwind keeps a one-page API glossary of shared prop names, and new components reuse those names unless there is a strong reason not to.</p>\n<p>This matters more than any single prop decision. An engineer who has used three Northwind components should be able to guess the fourth’s API correctly. Every inconsistency is a small tax paid by everyone, every day.</p>\n<h2 id=\"sec-3\" tabindex=\"-1\">Don’t block the platform</h2>\n<p>The third rule: the component must never take away what HTML already gives. A Button is a <code dir=\"ltr\">&lt;button&gt;</code>, so it should accept everything a <code dir=\"ltr\">&lt;button&gt;</code> accepts, <code dir=\"ltr\">aria-*</code> attributes included, and pass it through:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"tsx\" data-index=\"1\"><div class=\"code-head\"><span class=\"code-title\">Button.tsx</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">export</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> function</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> Button</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">({</span></span>\n<span class=\"line\"><span style=\"color:#953800;--shiki-dark:#FFA657\">  variant</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> 'secondary'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">,</span></span>\n<span class=\"line\"><span style=\"color:#953800;--shiki-dark:#FFA657\">  size</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> 'md'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">,</span></span>\n<span class=\"line\"><span style=\"color:#953800;--shiki-dark:#FFA657\">  loading</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> false</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">,</span></span>\n<span class=\"line\"><span style=\"color:#953800;--shiki-dark:#FFA657\">  startIcon</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">,</span></span>\n<span class=\"line\"><span style=\"color:#953800;--shiki-dark:#FFA657\">  endIcon</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">,</span></span>\n<span class=\"line\"><span style=\"color:#953800;--shiki-dark:#FFA657\">  type</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> 'button'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">,</span></span>\n<span class=\"line\"><span style=\"color:#953800;--shiki-dark:#FFA657\">  children</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">,</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  ...</span><span style=\"color:#953800;--shiki-dark:#FFA657\">rest</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">}</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">:</span><span style=\"color:#953800;--shiki-dark:#FFA657\"> ButtonProps</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) {</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  return</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    &#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">button</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">      {...</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">rest</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">      type</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">type</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">      className</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">[</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'nw-button'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, rest.className].</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">filter</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(Boolean).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">join</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">' '</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">      data-variant</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">variant</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">      data-size</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">size</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">      aria-busy</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">loading </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">||</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> undefined</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    ></span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">      {</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">startIcon</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">      &#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">span</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> className</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"nw-button__label\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">{</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">children</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">&#x3C;/</span><span style=\"color:#116329;--shiki-dark:#7EE787\">span</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">      {</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">endIcon</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    &#x3C;/</span><span style=\"color:#116329;--shiki-dark:#7EE787\">button</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  );</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">}</span></span></code></pre></div>\n<p>Because <code dir=\"ltr\">ref</code> is a regular prop for function components in React 19, it travels through <code dir=\"ltr\">...rest</code> with everything else, so focus management and tooltips work without extra wrapping. <code dir=\"ltr\">onClick</code>, <code dir=\"ltr\">disabled</code>, <code dir=\"ltr\">form</code> and <code dir=\"ltr\">aria-describedby</code> work for the same reason.</p>\n<aside class=\"callout callout-mistake\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3\"/><path d=\"M12 9v4\"/><path d=\"M12 17h.01\"/></svg><span><span class=\"callout-kind\">Common mistake</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>Inheriting the native default for type</span></p>\n<p>A native <code dir=\"ltr\">&lt;button&gt;</code> inside a form defaults to <code dir=\"ltr\">type=&quot;submit&quot;</code>. Northwind lost a week to dispatch forms submitting when people clicked “Add stop”. The system’s Button now defaults to <code dir=\"ltr\">type=&quot;button&quot;</code>, and teams pass <code dir=\"ltr\">type=&quot;submit&quot;</code> when they mean it. Defaults are design decisions: pick the one that is safe when people don’t read the docs.</p>\n</aside>\n<h2 id=\"sec-4\" tabindex=\"-1\">Slots for content</h2>\n<p>A <strong>slot</strong> is a place where the consumer passes content instead of configuration. <code dir=\"ltr\">children</code> is the main slot; <code dir=\"ltr\">startIcon</code> and <code dir=\"ltr\">endIcon</code> are named slots. Compare two ways to add an icon:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"tsx\" data-index=\"2\"><div class=\"code-head\"><span class=\"code-title\">TSX</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">&#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">Button</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> icon</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"truck\"</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> iconPosition</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"left\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">>Assign&#x3C;/</span><span style=\"color:#116329;--shiki-dark:#7EE787\">Button</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">>        </span><span style=\"color:#6E7781;--shiki-dark:#8B949E\">// configuration</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">&#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">Button</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> startIcon</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">&#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">TruckIcon</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> /></span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">>Assign&#x3C;/</span><span style=\"color:#116329;--shiki-dark:#7EE787\">Button</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">>               </span><span style=\"color:#6E7781;--shiki-dark:#8B949E\">// slot</span></span></code></pre></div>\n<p>The configuration version needs the Button to know every icon name and every position, and it grows a prop each time someone wants something new. The slot version accepts any element, so teams can pass a different icon set or a small avatar without a new release. The next lesson takes this idea much further.</p>\n<h2 id=\"sec-5\" tabindex=\"-1\">Variants in CSS: map props to attributes</h2>\n<p>The Button writes its props into <code dir=\"ltr\">data-variant</code> and <code dir=\"ltr\">data-size</code> attributes, so the CSS mirrors the API one to one. Each variant changes only component tokens:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"css\" data-index=\"3\"><div class=\"code-head\"><span class=\"code-title\">CSS</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">.nw-button</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">[</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">data-variant</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"primary\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">] {</span></span>\n<span class=\"line\"><span style=\"color:#953800;--shiki-dark:#FFA657\">  --nw-button-bg</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">var</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">--nw-color-action-bg</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#953800;--shiki-dark:#FFA657\">  --nw-button-text</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">var</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">--nw-color-action-text</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#953800;--shiki-dark:#FFA657\">  --nw-button-border</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">transparent</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">}</span></span></code></pre></div>\n<p>Data attributes keep the styling hooks readable in DevTools, where you see <code dir=\"ltr\">data-variant=&quot;primary&quot;</code> instead of a hashed class name, and they can’t be combined into nonsense the way classes like <code dir=\"ltr\">.primary.danger</code> can.</p>\n<aside class=\"callout callout-tip\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5\"/><path d=\"M9 18h6\"/><path d=\"M10 22h4\"/></svg><span><span class=\"callout-kind\">Tip</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>Choose the default variant deliberately</span></p>\n<p>Northwind’s default is <code dir=\"ltr\">secondary</code>. Making <code dir=\"ltr\">primary</code> an explicit choice nudges teams toward one primary action per view, which is a pattern our principles ask for anyway. A default isn’t neutral; it’s the variant you’ll see most often in production.</p>\n</aside>\n<h2 id=\"sec-6\" tabindex=\"-1\">Review the API before you build it</h2>\n<p>Northwind writes the usage code before the component code. Every new component proposal starts with three snippets: the most common usage, the most complicated usage anyone has asked for, and one usage that <em>should</em> be impossible. The review then asks four questions. Do the prop names match the API glossary? Can any combination of props describe something meaningless? Does the component pass through everything its native element supports? And what will the second and third teams to use it need that the first team didn’t?</p>\n<p>That review takes about thirty minutes. Changing an API after twenty teams depend on it takes a major release, a migration guide and months of follow-up, which is what Section 4 is about.</p>\n<p>In the exercise, you write the variant and size rules for this Button using only component tokens. Next, you see what happens when a component outgrows props entirely.</p>\n","toc":[{"id":"sec-1","text":"Enums for choices, booleans for states","level":2},{"id":"sec-2","text":"One vocabulary across the system","level":2},{"id":"sec-3","text":"Don’t block the platform","level":2},{"id":"sec-4","text":"Slots for content","level":2},{"id":"sec-5","text":"Variants in CSS: map props to attributes","level":2},{"id":"sec-6","text":"Review the API before you build it","level":2}],"takeaways":["Use one enum prop such as <code dir=\"ltr\">variant</code> for mutually exclusive options; separate booleans allow combinations that make no sense.","Keep prop names and values identical across components, so <code dir=\"ltr\">size=&quot;sm&quot;</code> means the same thing on a Button, an Input and a Select.","Pass native attributes and <code dir=\"ltr\">ref</code> through to the underlying element, so the component never blocks what HTML already does.","Defaults are design decisions; choose the safe, common option, such as <code dir=\"ltr\">type=&quot;button&quot;</code> and a secondary variant."],"further":[{"title":"<button>: The Button element (MDN)","url":"https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/button"},{"title":"forwardRef, and ref as a prop in React 19 (React docs)","url":"https://react.dev/reference/react/forwardRef"},{"title":"Using data attributes (MDN)","url":"https://developer.mozilla.org/en-US/docs/Web/HTML/How_to/Use_data_attributes"}],"quiz":[{"q":"<p>Northwind’s old Button accepts <code dir=\"ltr\">primary</code>, <code dir=\"ltr\">danger</code> and <code dir=\"ltr\">outline</code> booleans. A team writes <code dir=\"ltr\">&lt;Button primary danger&gt;</code>. What’s the underlying API problem?</p>","options":[{"text":"The booleans should be named <code dir=\"ltr\">isPrimary</code> and <code dir=\"ltr\">isDanger</code>.","why":"Renaming doesn’t stop the impossible combination; the issue is the shape of the API, not the names."},{"text":"Booleans can’t be passed without values in JSX.","why":"Bare boolean attributes are valid JSX and mean <code dir=\"ltr\">true</code>."},{"text":"Mutually exclusive options are modeled as independent flags, so invalid combinations are expressible.","why":"Correct. A single <code dir=\"ltr\">variant</code> enum makes <code dir=\"ltr\">primary</code> and <code dir=\"ltr\">danger</code> impossible to combine."},{"text":"The component should throw an error when both are set.","why":"A runtime error is better than silence, but the API should make the mistake impossible to write in the first place."}],"answer":2},{"q":"<p>A Button inside a <code dir=\"ltr\">&lt;form&gt;</code> keeps submitting the form when people click “Add stop”. The Button renders <code dir=\"ltr\">&lt;button&gt;</code> with no <code dir=\"ltr\">type</code>. What’s the best fix in the design system?</p>","options":[{"text":"Document that teams must always pass <code dir=\"ltr\">type=&quot;button&quot;</code>.","why":"Documentation helps, but a default that causes bugs will keep causing them for anyone who skims."},{"text":"Remove the form element from the product.","why":"The form is legitimate; the component’s default is what’s wrong."},{"text":"Stop the submit event inside the Button’s click handler.","why":"That breaks the cases where teams do want a submit button."},{"text":"Default <code dir=\"ltr\">type</code> to <code dir=\"ltr\">&quot;button&quot;</code> and let teams pass <code dir=\"ltr\">type=&quot;submit&quot;</code> when they mean it.","why":"Correct. The native default is <code dir=\"ltr\">submit</code>, which surprises people; the system can choose the safer default."}],"answer":3},{"q":"<p>Which prop design is most consistent across a system’s components?</p>","options":[{"text":"Button uses <code dir=\"ltr\">size=&quot;sm&quot;</code>, Input uses <code dir=\"ltr\">small</code>, Select uses <code dir=\"ltr\">compact</code>.","why":"Three spellings for one idea force every team to check the docs every time."},{"text":"Button, Input and Select all use <code dir=\"ltr\">size</code> with the values <code dir=\"ltr\">sm</code>, <code dir=\"ltr\">md</code>, <code dir=\"ltr\">lg</code>.","why":"Correct. One vocabulary means learning it once and guessing right on the next component."},{"text":"Each component chooses whatever reads best in its own context.","why":"Local readability across forty teams turns into global confusion."}],"answer":1}],"exercise":{"kind":"web","mode":"dom","starter":{"html":"<div class=\"row\">\n  <button class=\"nw-button\" type=\"button\" id=\"b-default\">Cancel</button>\n  <button class=\"nw-button\" type=\"button\" data-variant=\"primary\" id=\"b-primary\">Assign driver</button>\n  <button class=\"nw-button\" type=\"button\" data-variant=\"danger\" id=\"b-danger\">Delete route</button>\n  <button class=\"nw-button\" type=\"button\" data-variant=\"primary\" data-size=\"sm\" id=\"b-small\">Add stop</button>\n</div>","css":":root {\n  --nw-color-surface: #ffffff;\n  --nw-color-text: #1f2933;\n  --nw-color-border: #c5ccd3;\n  --nw-color-action-bg: #1f5ae0;\n  --nw-color-action-text: #ffffff;\n  --nw-color-danger-bg: #c62828;\n  --nw-color-danger-text: #ffffff;\n}\n\n/* Base = the secondary variant */\n.nw-button {\n  --nw-button-bg: var(--nw-color-surface);\n  --nw-button-text: var(--nw-color-text);\n  --nw-button-border: var(--nw-color-border);\n  --nw-button-padding-y: 8px;\n  --nw-button-padding-x: 16px;\n\n  background: var(--nw-button-bg);\n  color: var(--nw-button-text);\n  border: 1px solid var(--nw-button-border);\n  padding: var(--nw-button-padding-y) var(--nw-button-padding-x);\n  border-radius: 6px;\n  font: inherit;\n}\n.row { display: flex; gap: 8px; flex-wrap: wrap; }\n\n/* Add the primary, danger and sm rules below.\n   Change component tokens only. */\n","js":""},"solution":{"html":"<div class=\"row\">\n  <button class=\"nw-button\" type=\"button\" id=\"b-default\">Cancel</button>\n  <button class=\"nw-button\" type=\"button\" data-variant=\"primary\" id=\"b-primary\">Assign driver</button>\n  <button class=\"nw-button\" type=\"button\" data-variant=\"danger\" id=\"b-danger\">Delete route</button>\n  <button class=\"nw-button\" type=\"button\" data-variant=\"primary\" data-size=\"sm\" id=\"b-small\">Add stop</button>\n</div>","css":":root {\n  --nw-color-surface: #ffffff;\n  --nw-color-text: #1f2933;\n  --nw-color-border: #c5ccd3;\n  --nw-color-action-bg: #1f5ae0;\n  --nw-color-action-text: #ffffff;\n  --nw-color-danger-bg: #c62828;\n  --nw-color-danger-text: #ffffff;\n}\n\n/* Base = the secondary variant */\n.nw-button {\n  --nw-button-bg: var(--nw-color-surface);\n  --nw-button-text: var(--nw-color-text);\n  --nw-button-border: var(--nw-color-border);\n  --nw-button-padding-y: 8px;\n  --nw-button-padding-x: 16px;\n\n  background: var(--nw-button-bg);\n  color: var(--nw-button-text);\n  border: 1px solid var(--nw-button-border);\n  padding: var(--nw-button-padding-y) var(--nw-button-padding-x);\n  border-radius: 6px;\n  font: inherit;\n}\n.row { display: flex; gap: 8px; flex-wrap: wrap; }\n\n.nw-button[data-variant=\"primary\"] {\n  --nw-button-bg: var(--nw-color-action-bg);\n  --nw-button-text: var(--nw-color-action-text);\n  --nw-button-border: transparent;\n}\n.nw-button[data-variant=\"danger\"] {\n  --nw-button-bg: var(--nw-color-danger-bg);\n  --nw-button-text: var(--nw-color-danger-text);\n  --nw-button-border: transparent;\n}\n.nw-button[data-size=\"sm\"] {\n  --nw-button-padding-y: 4px;\n  --nw-button-padding-x: 12px;\n}\n","js":""},"checks":[{"id":"secondary-default","test":"return style('#b-default', 'background-color') === 'rgb(255, 255, 255)' && style('#b-default', 'border-top-color') === 'rgb(197, 204, 211)';"},{"id":"primary","test":"const b = $('#b-primary'); return style(b, 'background-color') === 'rgb(31, 90, 224)' && style(b, 'color') === 'rgb(255, 255, 255)' && style(b, 'border-top-color') === 'rgba(0, 0, 0, 0)';"},{"id":"danger","test":"const b = $('#b-danger'); return style(b, 'background-color') === 'rgb(198, 40, 40)' && style(b, 'color') === 'rgb(255, 255, 255)' && style(b, 'border-top-color') === 'rgba(0, 0, 0, 0)';"},{"id":"small","test":"const b = $('#b-small'); return style(b, 'padding-top') === '4px' && style(b, 'padding-left') === '12px' && style(b, 'background-color') === 'rgb(31, 90, 224)' && style('#b-primary', 'padding-top') === '8px';"},{"id":"tokens-only","test":"const rules = [...document.styleSheets].flatMap((s) => [...s.cssRules]).filter((r) => r.selectorText && /data-(variant|size)/.test(r.selectorText)); return rules.length >= 3 && rules.every((r) => [...r.style].every((p) => p.startsWith('--nw-button-')));"}],"title":"Style the Button's variants with component tokens","prompt":"<p>The Button renders its props as <code dir=\"ltr\">data-variant</code> and <code dir=\"ltr\">data-size</code> attributes. The base <code dir=\"ltr\">.nw-button</code> rule is the secondary variant. Add three rules that <strong>only set <code dir=\"ltr\">--nw-button-…</code> component tokens</strong> (no <code dir=\"ltr\">background</code>, <code dir=\"ltr\">color</code> or <code dir=\"ltr\">padding</code> declarations):</p>\n<ul>\n<li><code dir=\"ltr\">data-variant=&quot;primary&quot;</code>: action background and text from the semantic tokens, transparent border.</li>\n<li><code dir=\"ltr\">data-variant=&quot;danger&quot;</code>: danger background and text from the semantic tokens, transparent border.</li>\n<li><code dir=\"ltr\">data-size=&quot;sm&quot;</code>: padding 4px vertically and 12px horizontally.</li>\n</ul>\n<p>The small primary button must be both small and primary.</p>\n","hints":["Select with the attribute: <code dir=\"ltr\">.nw-button[data-variant=&quot;primary&quot;] { … }</code>.","Inside, override the tokens the base rule already uses, such as <code dir=\"ltr\">--nw-button-bg: var(--nw-color-action-bg);</code>.","Set <code dir=\"ltr\">--nw-button-border: transparent;</code> for primary and danger, and the two padding tokens for <code dir=\"ltr\">sm</code>."],"explanation":"<p>Every variant is now a handful of token overrides. The base rule owns <em>how</em> tokens become CSS properties, and variants only change <em>which</em> values flow in. Because size and variant touch different tokens, <code dir=\"ltr\">data-variant=&quot;primary&quot; data-size=&quot;sm&quot;</code> combines with no extra rule, and a team that needs a one-off tweak can override a single component token instead of fighting the cascade.</p>\n","checkLabels":{"secondary-default":"A button with no variant stays secondary (white with a grey border)","primary":"<code dir=\"ltr\">data-variant=&quot;primary&quot;</code> uses the action colors with a transparent border","danger":"<code dir=\"ltr\">data-variant=&quot;danger&quot;</code> uses the danger colors with a transparent border","small":"<code dir=\"ltr\">data-size=&quot;sm&quot;</code> shrinks the padding and combines with primary","tokens-only":"The variant and size rules set only <code dir=\"ltr\">--nw-button-…</code> tokens"}},"words":748,"readMinutes":4}