DEV Community

Ugur Aslim
Ugur Aslim

Posted on • Originally published at uguraslim.com

Tailwind CSS Component Slots in React 19: Building Flexible, Reusable UI Without Prop Drilling Hell

Tailwind CSS Component Slots in React 19: Building Flexible, Reusable UI Without Prop Drilling Hell

I've shipped nine AI features across CitizenApp, and every single one has a card, modal, or layout wrapper. By feature six, I realized I was writing the same component three times with slightly different style props. className, headerClassName, bodyClassName, footerClassName—it becomes a nightmare. The component API explodes. Tailwind becomes a liability instead of an asset.

Then I discovered the slot pattern, and it changed how I think about React component design entirely.

The slot pattern lets you define named regions in your component that consumers can fill with their own JSX. No style props. No wrapper divs. No prop drilling. Just composition. It's what Vue does natively, and React 19 makes it ergonomic enough that I now prefer it over render props or compound components for most UI.

Why Slots Beat Style Props (And Render Props)

Let's be honest: style props are a trap. Here's what happens:

// ❌ This is how it starts...
<Card className="..." headerClassName="..." bodyClassName="..." />

// ❌ Then you need one more customization
<Card 
  className="..." 
  headerClassName="..." 
  bodyClassName="..." 
  footerClassName="..."
  headerIconClassName="..."
  borderClassName="..."
/>

// Now the component has 15 props and zero flexibility
Enter fullscreen mode Exit fullscreen mode

This is prop-drilling's uglier cousin. The component doesn't own its styling problem—it just pushes it onto consumers.

Render props help:

<Card
  header={({ className }) => <div className={className}>Title</div>}
  body={({ className }) => <div className={className}>Content</div>}
/>
Enter fullscreen mode Exit fullscreen mode

But now you're passing className callbacks. It's overly functional and forces consumers to think about internals.

Slots are different. You define structural regions, and consumers fill them:

<Card>
  <Card.Header>AI Response Title</Card.Header>
  <Card.Body>Streaming response here</Card.Body>
  <Card.Footer>Action buttons</Card.Footer>
</Card>
Enter fullscreen mode Exit fullscreen mode

The component defines where things go and how much space they get. Consumers define what goes there. Clean separation.

Building a Slot-Based Card Component

Here's the Card from CitizenApp. It powers everything from AI feature outputs to settings panels:

// components/Card/Card.tsx
import React from 'react';

interface CardContextType {
  isLoading?: boolean;
}

const CardContext = React.createContext<CardContextType>({});

export function useCardContext() {
  return React.useContext(CardContext);
}

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

function CardRoot({
  children,
  isLoading = false,
  variant = 'default',
  className = '',
}: CardProps) {
  const variants = {
    default: 'bg-white border border-gray-200 rounded-lg shadow-sm',
    elevated: 'bg-white rounded-lg shadow-md',
    outlined: 'bg-white border-2 border-blue-500 rounded-lg',
  };

  return (
    <CardContext.Provider value={{ isLoading }}>
      <div className={`${variants[variant]} ${className}`}>
        {children}
      </div>
    </CardContext.Provider>
  );
}

interface CardHeaderProps {
  children: React.ReactNode;
  className?: string;
  icon?: React.ReactNode;
}

function CardHeader({ children, className = '', icon }: CardHeaderProps) {
  return (
    <div className={`px-6 py-4 border-b border-gray-100 flex items-center gap-3 ${className}`}>
      {icon && <div className="flex-shrink-0">{icon}</div>}
      <h3 className="text-lg font-semibold text-gray-900">{children}</h3>
    </div>
  );
}

interface CardBodyProps {
  children: React.ReactNode;
  className?: string;
  isLoading?: boolean;
}

function CardBody({ children, className = '', isLoading }: CardBodyProps) {
  const { isLoading: contextLoading } = useCardContext();
  const loading = isLoading ?? contextLoading;

  return (
    <div
      className={`px-6 py-4 ${loading ? 'opacity-50 pointer-events-none' : ''} ${className}`}
    >
      {children}
    </div>
  );
}

interface CardFooterProps {
  children: React.ReactNode;
  className?: string;
  align?: 'left' | 'center' | 'right' | 'between';
}

function CardFooter({ children, className = '', align = 'right' }: CardFooterProps) {
  const aligns = {
    left: 'justify-start',
    center: 'justify-center',
    right: 'justify-end',
    between: 'justify-between',
  };

  return (
    <div
      className={`px-6 py-3 border-t border-gray-100 flex ${aligns[align]} gap-3 ${className}`}
    >
      {children}
    </div>
  );
}

// Compound component assembly
export const Card = Object.assign(CardRoot, {
  Header: CardHeader,
  Body: CardBody,
  Footer: CardFooter,
});
Enter fullscreen mode Exit fullscreen mode

Now consumers use it without touching a single style prop:

// In an AI feature component
<Card variant="elevated">
  <Card.Header icon={<SparklesIcon />}>
    Claude 3.5 Sonnet Analysis
  </Card.Header>
  <Card.Body isLoading={isAnalyzing}>
    <div className="prose prose-sm">
      {analysisText}
    </div>
  </Card.Body>
  <Card.Footer align="between">
    <button className="text-gray-600 text-sm">Copy</button>
    <button className="bg-blue-600 text-white px-4 py-2 rounded">
      Use in Report
    </button>
  </Card.Footer>
</Card>
Enter fullscreen mode Exit fullscreen mode

Notice: zero style props on the Card itself. The consumer composes the content. The Card handles structure and spacing.

The Context Hook Pattern

I use useCardContext throughout my components. It lets child slots read Card-level state (like loading) without prop drilling through components:

function CardBody({ children, className = '', isLoading }: CardBodyProps) {
  const { isLoading: contextLoading } = useCardContext();
  const loading = isLoading ?? contextLoading; // Child can override or inherit
  // ...
}
Enter fullscreen mode Exit fullscreen mode

This is crucial. Without it, you'd have to pass isLoading through every slot manually. Context solves that elegantly.

Advanced Slot Example: Modal with Scrollable Body

Here's how slots shine with complex layouts:

interface ModalProps {
  children: React.ReactNode;
  isOpen: boolean;
  onClose: () => void;
}

function Modal({ isOpen, onClose, children }: ModalProps) {
  if (!isOpen) return null;

  return (
    <div className="fixed inset-0 bg-black/50 flex items-center justify-center">
      <div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] flex flex-col">
        {children}
      </div>
    </div>
  );
}

function ModalHeader({ children }: { children: React.ReactNode }) {
  return <div className="px-6 py-4 border-b">{children}</div>;
}

function ModalBody({ children }: { children: React.ReactNode }) {
  return <div className="px-6 py-4 overflow-y-auto flex-1">{children}</div>;
}

function ModalFooter({ children }: { children: React.ReactNode }) {
  return <div className="px-6 py-4 border-t flex gap-3 justify-end">{children}</div>;
}

export const Modal = Object.assign(Modal, {
  Header: ModalHeader,
  Body: ModalBody,
  Footer: ModalFooter,
});
Enter fullscreen mode Exit fullscreen mode

Usage:

<Modal isOpen={open} onClose={onClose}>
  <Modal.Header>Generate Report</Modal.Header>
  <Modal.Body>
    <form className="space-y-4">
      {/* Your form content - no Modal knows about it */}
    </form>
  </Modal.Body>
  <Modal.Footer>
    <button onClick={onClose}>Cancel</button>
    <button className="bg-blue-600 text-white">Generate</button>
  </Modal.Footer>
</Modal>
Enter fullscreen mode Exit fullscreen mode

The Modal doesn't care what goes in the body. It just manages the scrolling, spacing, and layout. The consumer fills it.

Gotcha: TypeScript and Compound Components

The Object.assign pattern loses TypeScript information:

// ❌ TypeScript doesn't know Card.Header exists
<Card.Header /> // Error: property 'Header' does not exist
Enter fullscreen mode Exit fullscreen mode

Fix it with explicit typing:

interface CardComponent extends React.FC<CardProps> {
  Header: typeof CardHeader;
  Body: typeof CardBody;
  Footer: typeof CardFooter;
}

export const Card = Object.assign(CardRoot, {
  Header: CardHeader,
  Body: CardBody,
  Footer: CardFooter,
}) as CardComponent;
Enter fullscreen mode Exit fullscreen mode

Now TypeScript knows exactly what methods exist. I learned this the hard way after merging half a feature before realizing autocomplete was broken.

When NOT to Use Slots

Slots are powerful, but they're not universal:

  • Simple buttons/badges: A single variant prop is fine. Don't over-engineer.
  • **Deeply

Top comments (0)