Modern Component Design: Scaling Beyond Prop-Heavy Architectures

Overview

Building a component library is a constant tug-of-war between two opposing forces: the desire for an opinionated, easy-to-use API and the inevitable requirement for hyper-flexibility. Many developers default to a "prop-heavy" approach, where a single component handles every possible permutation of labels, icons, and layouts through a growing list of configuration properties. However, this pattern eventually collapses under its own weight.

This guide explores a shift toward composable component architectures using modern CSS features like

, the
has
selector, and
CSS variables
. By moving logic out of JavaScript props and into the stylesheet, we can create UI elements that are responsive by nature and far more resilient to changing requirements.

Prerequisites

To get the most out of this tutorial, you should be comfortable with:

  • HTML/CSS Fundamentals: Understanding the DOM tree and standard layout properties.
  • Tailwind CSS: Familiarity with utility-first styling and the basics of
    Tailwind CSS
    .
  • Modern JavaScript: While the concepts apply to any framework, examples use
    React
    for structure.
  • Layout Models: A baseline understanding of CSS Grid and Flexbox.

Key Libraries & Tools

  • Tailwind CSS
    : A utility-first CSS framework. The v4 alpha is utilized here for its automatic CSS variable generation from theme values.
  • React
    : Used as the UI library to demonstrate component composition.
  • Modern Browser Engines: Required for features like subgrid and :has() (Standard in recent versions of Chrome, Firefox, and Safari).

The Failure of the Single-Component API

Most developers start with a component that looks like a "God Object." You might have an <Input /> that takes label, description, iconLeft, and error props. It feels clean initially.

Then reality hits. You need the description above the input for one specific form. You need to constrain the input width on desktop but keep it full-width on mobile. You need a second icon on the right with a tooltip. To support these, you add descriptionPlacement, inputClassName, and iconRight props. Soon, your component is a mess of conditional logic.

A better approach is composition. Instead of one monolithic component, we use a set of smaller, specialized components that work together:

<Field>
  <Label>Asking Price</Label>
  <Description>Enter the total amount.</Description>
  <InputGroup>
    <Icon name="dollar" />
    <Input />
    <Icon name="help" />
  </InputGroup>
</Field>

Data Slots and Contextual Spacing

When you move to a composable API, you lose the ability for a single parent to easily manage the spacing between its children. If you use space-y-2 on a container, the gap between a Label and a Description might be too large, while the gap between the Label and Input is just right.

We solve this using Data Slots. By marking children with a data-slot attribute, the parent can style them based on their presence and order.

// Inside the Field component
const Field = ({ children }) => (
  <div className="[&>[data-slot=label]+[data-slot=description]]:-mt-1">
    {children}
  </div>
);

In

, we use arbitrary variants to target these slots. This allows the Field component to say: "If a description immediately follows a label, reduce the top margin." This keeps the individual components clean and puts the layout responsibility on the wrapper.

Advanced Layout with CSS Grid and Subgrid

One of the hardest problems in UI design is aligning elements across different components. In a dropdown menu, you want the text of every item to align perfectly, even if some items have icons and others don't.

Historically, this required fixed widths. Today, we use

. By defining a grid on the parent menu and letting each item inherit that grid, the entire menu adapts to the largest icon present.

/* The Menu Container */
.menu {
  display: grid;
  grid-template-columns: auto 1fr;
}

/* The Menu Item */
.menu-item {
  display: grid;
  grid-column: span 2;
  grid-template-columns: subgrid; /* Inherits parent columns */
}

When one item includes an icon, the auto column expands for the entire menu. If no items have icons, the column collapses to zero. This creates a relationship between siblings that was previously only possible with heavy JavaScript calculations.

Responsive Props via CSS Variables

Standard React or Vue props are static; they cannot change based on a media query. If you have an Avatar with a size="md" prop, you can't easily tell it to become size="lg" at the lg breakpoint without writing complex window-resize listeners.

The solution is to map props to CSS Variables. By passing a variable into the component's style attribute, we can override that variable using Tailwind's responsive utilities.

// Implementation
<div style={{ '--gutter': 'var(--spacing-6)' }} 
     className="sm:[--gutter:var(--spacing-10)]">
  <Table gutter="var(--gutter)" />
</div>

Inside the Table component, the CSS uses var(--gutter) for margins and padding. This transforms a static configuration into a responsive one that respects the design system's breakpoints.

Syntax Notes

  • Arbitrary Variants: Syntax like [&_[data-slot=icon]] allows for deep targeting without leaving the utility-first workflow.
  • The :has() Selector: A "parent selector" that styles an element based on its descendants. Crucial for adding padding to an input only when an icon is present.
  • Isolation: The isolate utility in Tailwind (CSS isolation: isolate) creates a new stacking context. This is the ultimate fix for z-index issues, preventing elements from bleeding over sticky navigation bars.

Practical Examples

  • Touch Targets: Use an absolute-positioned span with a 44px minimum size inside a small button. Combine this with the @media (pointer: fine) query to hide the enlarged target for mouse users while keeping it active for touch devices.
  • Full-Width Bleed: Use calc() and CSS variables to allow a table to sit flush against the screen edges on mobile while maintaining horizontal alignment with the page's container on desktop.

Tips & Gotchas

  • Avoid Z-Index Inflation: Instead of increasing z-index to 9999, use the isolate property on your component wrappers to sandbox their layers.
  • Class Name Merging: If you expose className on your components, treat it as a "sharp knife." It is best used for layout-related properties (margins, max-width) rather than internal visuals (colors, borders).
  • Browser Support: While :has() and subgrid are widely supported now, always verify your target audience's browser versions, as these are relatively recent additions to the CSS spec.
5 min read