<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Sinisa Kusic</title>
    <description>The latest articles on DEV Community by Sinisa Kusic (@ku5ic).</description>
    <link>https://dev.to/ku5ic</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%2F268473%2Fc0812de5-8dcf-4c22-8e87-1c596b84d391.jpeg</url>
      <title>DEV Community: Sinisa Kusic</title>
      <link>https://dev.to/ku5ic</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ku5ic"/>
    <language>en</language>
    <item>
      <title>The Janitor Pattern</title>
      <dc:creator>Sinisa Kusic</dc:creator>
      <pubDate>Tue, 07 Apr 2026 08:31:29 +0000</pubDate>
      <link>https://dev.to/ku5ic/the-janitor-pattern-2m9l</link>
      <guid>https://dev.to/ku5ic/the-janitor-pattern-2m9l</guid>
      <description>&lt;p&gt;There is a role that does not appear in any job description but exists on almost every frontend team. You know it when you are living it. You spend more time navigating someone else's decisions than making your own. Every new feature requires archaeology. Every bug fix risks destabilizing something three layers removed. You are not building anymore. You are maintaining the illusion that the codebase is still under control.&lt;/p&gt;

&lt;p&gt;This is the Janitor Pattern. And it is not caused by bad developers.&lt;/p&gt;

&lt;p&gt;It is caused by a structural assumption that gets made early, often before the first line of code is written, and almost never gets questioned: that the frontend is a UI layer. A skin over the real system. Something you bolt on at the end, staff with whoever is available, and optimize last if at all.&lt;/p&gt;

&lt;p&gt;That assumption is wrong. And the codebase you inherit six months later is the proof.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the Assumption Takes Hold
&lt;/h2&gt;

&lt;p&gt;It rarely starts as a conscious decision. It starts as a staffing model.&lt;/p&gt;

&lt;p&gt;The backend engineers get senior architects. They get ADRs, design reviews, and ownership over the data layer. The frontend gets whoever is left, or whoever was cheapest, or whoever interviewed well enough to clear the bar for "can ship components." The implicit message is clear even when the explicit message is not: this part does not require serious engineering.&lt;/p&gt;

&lt;p&gt;From there, the assumption compounds. No architectural investment means no architectural standards. No standards means every engineer makes their own local decisions. Local decisions, made independently over time, produce a system with no coherent shape. State leaks across boundaries that should not share state. Components grow to accommodate every use case that ever touched them. A component that was written to render a data table gradually absorbs filtering logic, then sorting state, then a network call, then an inline error handler, until it is four hundred lines long and nobody can confidently change it without running the full application to see what breaks. Business logic migrates into the render tree because there is nowhere else to put it and nobody with the authority to put it somewhere better. The codebase becomes a record of every compromise that was made under pressure, and none of the intentions that preceded them.&lt;/p&gt;

&lt;p&gt;Leadership looks at the result and concludes that frontend is just messy by nature. It is not. It is messy because no one was given the mandate, the time, or the authority to keep it otherwise.&lt;/p&gt;

&lt;h2&gt;
  
  
  What It Actually Costs
&lt;/h2&gt;

&lt;p&gt;The first cost is velocity, and it compounds in a way that does not show up cleanly on any roadmap.&lt;/p&gt;

&lt;p&gt;Early on, the team ships fast. The codebase is small, everyone knows where everything is, and there is no meaningful technical debt because there is not yet enough code to have debt. Leadership sees the speed and attributes it to the team. What they are actually seeing is the absence of consequences. The debt is being taken on, not yet being repaid.&lt;/p&gt;

&lt;p&gt;Six months later, the same features take twice as long. A year later, they take four times as long. But the team is also larger now, which obscures the signal. The productivity loss per engineer is invisible because it is distributed across more engineers. What leadership perceives as a scaling problem, we need to hire more, is actually an architectural problem: the system is resisting change because it was never designed to accommodate it.&lt;/p&gt;

&lt;p&gt;The second cost is engineer quality. Senior engineers tolerate janitor work for a while. They do not tolerate it indefinitely. The ones who leave are almost never the ones who cause the mess. They are the ones who recognize it, who tried to address it, and who eventually concluded that the organization had no interest in addressing it with them. What remains tends toward the engineers who either do not see the problem or have stopped caring. Neither produces a better codebase.&lt;/p&gt;

&lt;p&gt;The third cost is invisible and therefore the most dangerous: the features you do not build. Every hour spent managing accumulated complexity is an hour not spent on the product. No one tracks this. It does not appear in sprint velocity or quarterly reports. It exists only as a gap between what the team could have shipped and what it actually shipped, and leadership usually fills that gap with explanations that have nothing to do with the real cause.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frontend Is a System
&lt;/h2&gt;

&lt;p&gt;The framing of "frontend" as a UI layer is not just organizationally convenient. It is technically incorrect.&lt;/p&gt;

&lt;p&gt;A modern frontend application manages state, enforces business rules, handles asynchronous coordination, defines data contracts with backend services, implements security boundaries in the form of auth flows and guarded routes, and owns the full error surface that users ever actually see. It makes rendering decisions, caching decisions, and navigation decisions. In a sufficiently complex product, the frontend state machine is as sophisticated as anything running on the server.&lt;/p&gt;

&lt;p&gt;Treating that as a "layer" is not a simplification. It is a category error.&lt;/p&gt;

&lt;p&gt;The same engineering discipline that applies to backend systems applies here: clear separation of concerns, explicit data flow, bounded ownership, and components that do one thing well. The difference is that on the backend, these principles are treated as table stakes. On the frontend, they are treated as optional, or worse, as overhead.&lt;/p&gt;

&lt;p&gt;SOLID is not a backend concept. A component that renders a form, validates input, manages submission state, handles error display, and fires analytics events is violating the single responsibility principle just as clearly as any god object in a Java service layer. It is harder to test, harder to change, and harder to reason about. The fact that it is written in JSX rather than Java does not make the violation less real.&lt;/p&gt;

&lt;p&gt;KISS applies too. Not in the reductive sense of "write simple code," but in the sense of resisting the pull toward abstraction before abstraction has earned its complexity budget. Frontend codebases are full of "flexible" systems that no one uses flexibly, generic components that handle twelve variants when they needed to handle two, and configuration-driven rendering logic so abstracted that adding a new case requires reading three files before writing one line.&lt;/p&gt;

&lt;p&gt;YAGNI is where frontend most consistently goes wrong. The instinct to generalize too early, to build the reusable version before the requirements are even stable, produces abstractions that are wrong in ways you cannot see until you try to use them. The cost of premature abstraction in a UI codebase is not just the code that was written. It is the constraints that code imposes on every decision that comes after it.&lt;/p&gt;

&lt;p&gt;Component design, specifically, benefits from a discipline most frontend teams never formalize: keep components as dumb as possible for as long as possible. A component that receives props and renders output is easy to test, easy to compose, and easy to change. A component that also manages its own network state, its own business logic, and its own error recovery is none of those things. The only state a component should own by default is the state that is genuinely local to its rendering, whether a dropdown is open, whether a tooltip is visible. Everything else belongs somewhere else, managed by something whose job is to manage it.&lt;/p&gt;

&lt;p&gt;This is not a new idea. It is not even a frontend idea. It is what engineering discipline looks like when it is applied consistently. The problem is not that frontend engineers do not know this. It is that the organizations they work in do not create the conditions for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Disciplined Frontend Architecture Looks Like
&lt;/h2&gt;

&lt;p&gt;So what does the alternative actually look like? Not as a set of principles, but as working code you can open and read.&lt;/p&gt;

&lt;p&gt;I built a &lt;a href="https://github.com/ku5ic/battleship" rel="noopener noreferrer"&gt;Battleship game&lt;/a&gt; as a public project specifically to demonstrate what frontend architecture looks like when the same standards applied to backend systems are applied to the frontend without compromise. The game itself is not the point. The architecture is.&lt;/p&gt;

&lt;p&gt;The domain layer, hit detection, sunk-ship resolution, turn management, game-over detection, lives in pure TypeScript functions under &lt;code&gt;engine/&lt;/code&gt; and &lt;code&gt;services/&lt;/code&gt;. No React imports anywhere in that layer. The same reducers that power the browser UI also drive a standalone CLI runner that you can invoke with &lt;code&gt;npm run cli&lt;/code&gt;. That is not a demo added afterward. It is proof the layer boundaries are real: a second consumer in a completely different runtime environment, Node terminal versus browser DOM, works without changing a single line of domain code. If the domain had leaked React, the CLI would not compile.&lt;/p&gt;

&lt;p&gt;State is minimal by design. Each game hook persists only what cannot be derived from other state: the shots map and the last shot result. Everything else, whether a cell is hit, whether a ship is sunk, whether the game is over, is computed via &lt;code&gt;useMemo&lt;/code&gt; from those two facts. There is one source of truth per fact. The alternative, persisting derived values alongside the raw state, creates a second source of truth that must be kept consistent with the first. When those two diverge, and they will, the UI renders incorrect state. Deriving is consistent by construction.&lt;/p&gt;

&lt;p&gt;Shot state transitions are atomic. Firing a shot must update the shots map and the last result together. Two &lt;code&gt;useState&lt;/code&gt; calls would create a window between the first and second update where the component has inconsistent state: the shot is recorded but the result is still the previous one, or vice versa. An &lt;code&gt;aria-live&lt;/code&gt; region reading during that window announces the wrong result. A single &lt;code&gt;useReducer&lt;/code&gt; dispatch eliminates the window entirely. One dispatch, one new state object, no intermediate renders.&lt;/p&gt;

&lt;p&gt;When a second game mode was added, it required a new engine module, a new hook, and a new wiring component. No existing rule logic was touched. Services, utilities, and the original single-player engine were extended, not rewritten. That is the practical payoff of clear layer boundaries: new requirements have a predictable place to go, and change is local rather than global.&lt;/p&gt;

&lt;p&gt;Components are dumb by default. The &lt;code&gt;Board&lt;/code&gt; component knows how to render a grid and manage keyboard navigation. It does not know what a ship is, what a shot is, or what the rules are. It could render a Minesweeper grid without changing its implementation. The only components that call hooks are the two wiring components at the top of the game tree, and those components contain no logic of their own. This is not a heroic constraint. It is what components look like when someone decided early what they are for.&lt;/p&gt;

&lt;p&gt;None of this required a new framework, a state management library, or a methodology. It required deciding, before the first component was written, that the frontend was a system with the same expectations applied to any other system, and that those expectations would be enforced.&lt;/p&gt;

&lt;p&gt;A production codebase is harder. The requirements are less clear, the team is larger, the deadlines are real. But the failure mode is not that production constraints make architecture impossible. The failure mode is that no one was asked to do it, or given the authority to enforce it, or protected from the pressure to skip it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Needs to Change
&lt;/h2&gt;

&lt;p&gt;For engineering leadership, the starting point is recognizing that the staffing model is the architecture.&lt;/p&gt;

&lt;p&gt;If you staff the frontend with engineers who are not expected to own the architecture, no architecture will be owned. If you do not create review processes that include frontend design decisions alongside backend ones, frontend design will not be reviewed. If you treat frontend performance, frontend accessibility, and frontend code quality as secondary concerns to be addressed "after we ship," they will remain secondary concerns indefinitely, because there is always something else to ship.&lt;/p&gt;

&lt;p&gt;The investment required is not enormous. It is a senior engineer with genuine ownership and the authority to make and enforce architectural decisions. It is design review that includes the frontend. It is the same expectation of discipline that you already apply to the systems you have decided matter.&lt;/p&gt;

&lt;p&gt;For senior frontend engineers, the work is not just building better systems. It is making the cost of bad systems legible to the people who control the conditions under which systems are built. That means tracking the time lost to architectural debt explicitly, not absorbing it silently into estimates. It means framing refactoring work in terms of shipping velocity, not code quality, not because code quality does not matter, but because the argument has to land somewhere that registers. It means being willing to push back on the framing that frontend is inherently messy, because that framing is false and accepting it makes the problem permanent.&lt;/p&gt;

&lt;p&gt;The Janitor Pattern persists because it is self-obscuring. The people creating the conditions for it rarely see the consequences directly. The engineers absorbing the consequences rarely have the standing to name the cause. Breaking that loop requires both sides to be honest about what is actually happening.&lt;/p&gt;

&lt;p&gt;The frontend is not a UI layer. It is a system. It deserves to be treated like one.&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>architecture</category>
      <category>webdev</category>
      <category>career</category>
    </item>
    <item>
      <title>Why I separated `variant` from `intent` in my component API</title>
      <dc:creator>Sinisa Kusic</dc:creator>
      <pubDate>Mon, 06 Apr 2026 10:09:16 +0000</pubDate>
      <link>https://dev.to/ku5ic/why-i-separated-variant-from-intent-in-my-component-api-56k0</link>
      <guid>https://dev.to/ku5ic/why-i-separated-variant-from-intent-in-my-component-api-56k0</guid>
      <description>&lt;p&gt;Every component library starts the same way. You add a Button. It needs a primary style and a danger style, so you reach for a &lt;code&gt;variant&lt;/code&gt; prop. Simple enough.&lt;/p&gt;

&lt;p&gt;Then someone needs a ghost button that also signals danger. You add &lt;code&gt;variant="ghost-danger"&lt;/code&gt;. Then outline-success. Then link-warning. Then secondary-danger. Each new combination feels reasonable in isolation. Six months later you have a prop that accepts seventeen strings, half of which your consumers will never discover because nothing in the type system points them there.&lt;/p&gt;

&lt;p&gt;This is not a tooling problem. It is a modeling problem. The &lt;code&gt;variant&lt;/code&gt; prop is doing two unrelated jobs, and conflating them is what causes the explosion.&lt;/p&gt;




&lt;h2&gt;
  
  
  Two orthogonal concerns
&lt;/h2&gt;

&lt;p&gt;When you look at what &lt;code&gt;variant&lt;/code&gt; is actually encoding in most button APIs, it is carrying two distinct signals:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Visual weight&lt;/strong&gt;: how much attention the component demands. Primary buttons are loud. Ghost buttons are quiet. Outline sits between them. This is a presentation decision.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Semantic meaning&lt;/strong&gt;: what the action communicates. Danger means destructive. Success means confirmation. Warning means proceed with caution. This is a communication decision.&lt;/p&gt;

&lt;p&gt;These are orthogonal axes. A ghost button can be dangerous. An outline button can confirm success. There is no inherent relationship between how loud a button is and what it means. Cramming both signals into a single prop forces a false coupling that grows more expensive with every variant you add.&lt;/p&gt;

&lt;p&gt;The fix is to give each concern its own prop.&lt;/p&gt;




&lt;h2&gt;
  
  
  The split
&lt;/h2&gt;

&lt;p&gt;In nuka-ui, my open-source React component library built on Tailwind v4, the API separates these into two independent props:&lt;/p&gt;

&lt;p&gt;A note on the project itself. I built nuka-ui because I kept repeating the same patterns across every project I started, personal, hobby, showcase, whatever. Rebuilding accessible, production-ready components from scratch every time is a real burden, and I got tired of it. The library exists to solve that repetition once, with accessibility and solid UX baked in from the start rather than bolted on later. It is not on npm yet. Navigation and Composites are the remaining pieces before the first publish.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;variant&lt;/code&gt; controls visual weight: &lt;code&gt;primary&lt;/code&gt;, &lt;code&gt;secondary&lt;/code&gt;, &lt;code&gt;outline&lt;/code&gt;, &lt;code&gt;ghost&lt;/code&gt;, &lt;code&gt;link&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;intent&lt;/code&gt; controls semantic meaning: &lt;code&gt;default&lt;/code&gt;, &lt;code&gt;danger&lt;/code&gt;, &lt;code&gt;success&lt;/code&gt;, &lt;code&gt;warning&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every combination is valid. The consumer API looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"primary"&lt;/span&gt; &lt;span class="na"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"default"&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="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"ghost"&lt;/span&gt; &lt;span class="na"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"danger"&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="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"outline"&lt;/span&gt; &lt;span class="na"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"success"&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="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"secondary"&lt;/span&gt; &lt;span class="na"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"warning"&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;Compare that to the flat approach:&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"ghost-danger"&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="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"outline-success"&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="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"secondary-warning"&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 flat approach is not unreadable. The problem is discoverability and scale. A consumer looking at &lt;code&gt;variant&lt;/code&gt;'s type has no way to know which combinations exist, which are intentional, and which ones you simply never got around to building. The two-prop model makes the full space explicit and uniform. Every variant works with every intent. There are no gaps.&lt;/p&gt;




&lt;h2&gt;
  
  
  How CVA implements the intersection
&lt;/h2&gt;

&lt;p&gt;Separating the props does not make the styling simpler. It makes it honest. You still need to define what every combination looks like. That work is done through CVA's &lt;code&gt;compoundVariants&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;buttonVariants&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cva&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;baseClasses&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;variants&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
      &lt;span class="na"&gt;secondary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
      &lt;span class="na"&gt;outline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
      &lt;span class="na"&gt;ghost&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
      &lt;span class="na"&gt;link&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;danger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;compoundVariants&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;primary&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;default&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bg-[var(--nuka-accent-bg)]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text-[var(--nuka-text-inverse)]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hover:bg-[var(--nuka-accent-bg-hover)]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;primary&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;danger&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bg-[var(--nuka-danger-base)]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text-[var(--nuka-text-inverse)]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hover:brightness-90&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ghost&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;danger&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text-[var(--nuka-danger-text)]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hover:bg-[var(--nuka-danger-bg)]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="c1"&gt;// one entry per variant x intent combination&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;defaultVariants&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;primary&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;default&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;md&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;5 variants multiplied by 4 intents gives you 20 compound variant entries for Button alone. You write all 20 explicitly. That is the cost of a complete, deliberate API, and it is a one-time authoring cost. Once they are written, the intersection is fully covered. Adding a consumer use case requires no library changes, just passing the two props you already have.&lt;/p&gt;

&lt;p&gt;TypeScript enforces the contract at compile time:&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="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;ButtonProps&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nx"&gt;ButtonVariantProps&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;primary&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="s2"&gt;secondary&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="s2"&gt;outline&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="s2"&gt;ghost&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="s2"&gt;link&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="nx"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;default&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="s2"&gt;danger&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="s2"&gt;success&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="s2"&gt;warning&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;Invalid prop values do not survive a build. The type system reflects the actual API surface, not a historical accident of how variants accumulated.&lt;/p&gt;




&lt;h2&gt;
  
  
  What you gain at scale
&lt;/h2&gt;

&lt;p&gt;The pattern compounds across a library. In nuka-ui it applies to Button, Alert, Badge, Tag, Code, Input, and Checkbox. Each component defines its own variant and intent axes and handles the intersections through compound variants. The mental model is consistent everywhere. A consumer who understands how Button works understands how Alert works.&lt;/p&gt;

&lt;p&gt;Adding a new intent, say &lt;code&gt;info&lt;/code&gt;, requires adding N compound variant entries per component, where N is the number of that component's variants. It is more work than adding a single flat variant string, but the scope is bounded and predictable. You know exactly what needs to be done, and you cannot accidentally miss a combination because the grid is explicit.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tradeoffs, and when not to apply this
&lt;/h2&gt;

&lt;p&gt;The verbosity is real. Twenty compound variant entries per component is a lot of lines. If your library is small and your variant space is stable, the flat approach is less overhead and probably fine. This pattern earns its cost at scale, when the alternative is a &lt;code&gt;variant&lt;/code&gt; prop with an ever-growing string union that no one can hold in their head.&lt;/p&gt;

&lt;p&gt;Consumers also have to understand two props instead of one. For most senior engineers this is a non-issue. The separation is intuitive once named. For a component library targeting less experienced consumers, the additional concept may need more documentation investment.&lt;/p&gt;

&lt;p&gt;The pattern also does not apply universally. Banner in nuka-ui uses &lt;code&gt;intent&lt;/code&gt; alone. There is no &lt;code&gt;variant&lt;/code&gt; prop because Banner has one visual weight. Applying the full grid to a component with a single presentation mode would be mechanical pattern application, not design. The question to ask is whether the component genuinely has independent visual weight and semantic axes. If it does not, use the simpler model.&lt;/p&gt;

&lt;p&gt;Two alternatives worth naming explicitly, because I considered both before landing here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Flat variants&lt;/strong&gt; are simpler to implement initially. The explosion problem only becomes painful as the library grows, which is exactly when you have the least appetite to refactor the API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CSS data attributes&lt;/strong&gt; (&lt;code&gt;data-intent="danger"&lt;/code&gt;) avoid prop surface area but lose TypeScript type safety and make the API implicit. The props approach is more explicit and more tool-friendly.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where to see it in practice
&lt;/h2&gt;

&lt;p&gt;The full implementation is in the nuka-ui repository at &lt;a href="https://github.com/ku5ic/nuka-ui" rel="noopener noreferrer"&gt;https://github.com/ku5ic/nuka-ui&lt;/a&gt;. The live Storybook at &lt;a href="https://ku5ic.github.io/nuka-ui" rel="noopener noreferrer"&gt;https://ku5ic.github.io/nuka-ui&lt;/a&gt; shows every variant and intent combination across all components. If you want to follow along as the library moves toward its first npm publish, starring the repo is the easiest way to keep up with progress.&lt;/p&gt;

&lt;p&gt;Most component API problems are modeling problems in disguise. This one just happens to be easy to see once you know where to look.&lt;/p&gt;

</description>
      <category>react</category>
      <category>typescript</category>
      <category>designsystems</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
