Skip to main content
All posts

React 19 Compound Components with Tailwind CSS: Building Flexible Multi-Feature AI Dashboards Without Wrapper Div Sprawl

10 June 2026

React 19 Compound Components with Tailwind CSS: Building Flexible Multi-Feature AI Dashboards Without Wrapper Div Sprawl

I’m going to be direct: compound components are the most underrated pattern in modern React, and React 19 makes them so clean that ignoring them is a mistake. I learned this the hard way while rebuilding CitizenApp’s dashboard—we had 9 AI features, each needing different card layouts, and I kept reaching for prop drilling hell until I realized the pattern I needed was right in front of me.

Here’s the problem: when you’re building a multi-tenant SaaS dashboard with heterogeneous AI widgets, you end up with cards that need different internal structures. One widget needs a two-column grid inside the card body. Another needs custom spacing. A third needs the footer to collapse on mobile. You either:

  1. Create a Card component with 47 props (headerClassName, bodyPadding, footerBg, etc.)
  2. Use CSS-in-JS to dynamically generate class names
  3. Accept wrapper divs and prop drilling nightmare

I prefer compound components because they let each part of the card be independently styled while sharing minimal internal state. This is the React 19 way.

Why Compound Components Win for Dashboards

The compound component pattern treats a component family (Card, CardHeader, CardBody, CardFooter) as a cohesive unit that manages shared state internally via context, but delegates all styling to the consumer. This is powerful for dashboards because:

This matters for multi-tenant AI dashboards because tenant A’s “Sentiment Analysis” widget might need a heatmap in the card body, while tenant B’s “Document Classifier” needs a table. Same card structure, completely different content and layout.

Building the Compound Card System

Here’s how I structure this in CitizenApp:

// CardContext.ts
import { createContext, useContext } from 'react';

interface CardContextType {
  isLoading?: boolean;
  variant?: 'default' | 'elevated' | 'outlined';
  isCompact?: boolean;
}

const CardContext = createContext<CardContextType | undefined>(undefined);

export function useCardContext() {
  const context = useContext(CardContext);
  if (!context) {
    throw new Error('Card compound components must be used within <Card>');
  }
  return context;
}

export { CardContext };

Why I define context this way: I keep it minimal. Only state that’s truly shared across the card family lives here. Individual styling—padding, colors, borders—all stay in Tailwind classes at the consumption site.

// Card.tsx
import { ReactNode } from 'react';
import { CardContext } from './CardContext';

interface CardProps {
  children: ReactNode;
  isLoading?: boolean;
  variant?: 'default' | 'elevated' | 'outlined';
  isCompact?: boolean;
  className?: string;
}

export function Card({
  children,
  isLoading = false,
  variant = 'default',
  isCompact = false,
  className = '',
}: CardProps) {
  const contextValue = {
    isLoading,
    variant,
    isCompact,
  };

  // Variant base styles—these are *always* applied
  const variantClasses = {
    default: 'bg-white border border-gray-200',
    elevated: 'bg-white shadow-lg',
    outlined: 'bg-gray-50 border-2 border-gray-300',
  }[variant];

  return (
    <CardContext.Provider value={contextValue}>
      <div
        className={`rounded-lg overflow-hidden transition-opacity ${
          isLoading ? 'opacity-60 pointer-events-none' : 'opacity-100'
        } ${variantClasses} ${className}`}
        role={isLoading ? 'status' : undefined}
        aria-busy={isLoading}
      >
        {children}
      </div>
    </CardContext.Provider>
  );
}

Notice: the Card component is dumb about internal layout. It doesn’t assume how many children or what order. It provides context and applies only the universal styles (border, shadow, loading state). Everything else is delegated to Tailwind at the consumption site.

// CardHeader.tsx
import { ReactNode } from 'react';
import { useCardContext } from './CardContext';

interface CardHeaderProps {
  children: ReactNode;
  className?: string;
}

export function CardHeader({ children, className = '' }: CardHeaderProps) {
  const { isCompact } = useCardContext();

  const compactPadding = isCompact ? 'px-4 py-2' : 'px-6 py-4';

  return (
    <div className={`border-b border-gray-200 ${compactPadding} ${className}`}>
      {children}
    </div>
  );
}

Here’s the subtle magic: CardHeader uses context to read isCompact, which changes padding. The parent Card doesn’t pass this as a prop. The header pulls what it needs. This is why compound components are superior to prop drilling—each component is self-aware.

// CardBody.tsx
import { ReactNode } from 'react';
import { useCardContext } from './CardContext';

interface CardBodyProps {
  children: ReactNode;
  className?: string;
}

export function CardBody({ children, className = '' }: CardBodyProps) {
  const { isCompact, isLoading } = useCardContext();

  const compactPadding = isCompact ? 'p-4' : 'p-6';

  return (
    <div
      className={`${compactPadding} ${isLoading ? 'blur-sm' : ''} ${className}`}
    >
      {children}
    </div>
  );
}
// CardFooter.tsx
import { ReactNode } from 'react';

interface CardFooterProps {
  children: ReactNode;
  className?: string;
}

export function CardFooter({ children, className = '' }: CardFooterProps) {
  return (
    <div
      className={`border-t border-gray-200 px-6 py-4 bg-gray-50 flex justify-end gap-2 ${className}`}
    >
      {children}
    </div>
  );
}

Now here’s real usage from CitizenApp’s Sentiment Analysis widget:

// SentimentDashboard.tsx
import { Card, CardHeader, CardBody, CardFooter } from '@/components/Card';
import { Button } from '@/components/Button';
import { HeatmapChart } from './HeatmapChart';

export function SentimentWidget() {
  const [isLoading, setIsLoading] = useState(false);

  return (
    <Card isLoading={isLoading} variant="elevated" className="h-full">
      <CardHeader className="flex justify-between items-center">
        <h2 className="text-lg font-semibold text-gray-900">
          Sentiment Trends
        </h2>
        <span className="text-sm text-gray-500">Last 30 days</span>
      </CardHeader>

      <CardBody className="space-y-4">
        <div className="grid grid-cols-3 gap-4">
          <div className="bg-green-50 p-4 rounded">
            <p className="text-xs text-gray-600">Positive</p>
            <p className="text-2xl font-bold text-green-600">68%</p>
          </div>
          <div className="bg-yellow-50 p-4 rounded">
            <p className="text-xs text-gray-600">Neutral</p>
            <p className="text-2xl font-bold text-yellow-600">24%</p>
          </div>
          <div className="bg-red-50 p-4 rounded">
            <p className="text-xs text-gray-600">Negative</p>
            <p className="text-2xl font-bold text-red-600">8%</p>
          </div>
        </div>

        <HeatmapChart />
      </CardBody>

      <CardFooter>
        <Button
          variant="secondary"
          onClick={() => setIsLoading(true)}
          onComplete={() => setIsLoading(false)}
        >
          Refresh Analysis
        </Button>
      </CardFooter>
    </Card>
  );
}

See what happened? The consumer entirely controls the layout of CardBody’s interior. We didn’t need a bodyClassName prop passed down. We just wrote the grid directly. This is composability.

The Gotcha: Context Overhead Isn’t Zero

Here’s what burned me initially: I thought compound components would be faster than prop drilling, but in a heavily re-rendering dashboard (AI features polling for updates), context updates on the root Card still cause all children to re-render.

The fix? Memoize aggressively:

const CardHeaderMemo = memo(CardHeader);
const CardBodyMemo = memo(CardBody);
const CardFooterMemo = memo(CardFooter);

export { CardHeaderMemo as CardHeader, CardBodyMemo as CardBody, CardFooterMemo as CardFooter };

And in your widget, memoize the Card’s children if they’re expensive:

<Card isLoading={isLoading}>
  <CardHeader>{/* ... */}</CardHeader>
  <CardBodyMemo>{/* Heavy chart rendering */}</CardBodyMemo>
  <CardFooter>{/* ... */}</CardFooter>
</Card>

Why Not Just Props?

Someone will ask: “Why not just make Card accept headerClassName, bodyClassName, etc.?” The answer is maintainability at scale. At

You might also like

Building something like this?

I build production-grade Python + React applications. Let's talk about your project.

Get in touch