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
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>}
/>
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>
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,
});
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>
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
// ...
}
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,
});
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>
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
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;
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
variantprop is fine. Don't over-engineer. - **Deeply
Top comments (0)