Styling Compound Wrappers
Create styled wrapper components that compose headless base compound components. This skill complements building-compound-components (which builds the base primitives) by focusing on how to properly consume and wrap them with styling and additional behavior.
Real-world example: See references/real-world-example.md for a complete before/after MessageInput refactoring.
Core Principle: Compose, Don't Duplicate
Styled wrappers should compose base components, not re-implement their logic.
// WRONG - re-implementing what base already does const StyledInput = ({ children, className }) => { const { value, setValue, submit } = useTamboThreadInput(); // Duplicated! const [isDragging, setIsDragging] = useState(false); // Duplicated! const handleDrop = useCallback(/* ... */); // Duplicated!
return ( <form onDrop={handleDrop} className={className}> {children} </form> ); };
// CORRECT - compose the base component const StyledInput = ({ children, className, variant }) => { return ( <BaseInput.Root className={cn(inputVariants({ variant }), className)}> <BaseInput.Content className="rounded-xl data-[dragging]:border-dashed"> {children} </BaseInput.Content> </BaseInput.Root> ); };
Refactoring Workflow
Copy this checklist and track progress:
Styled Wrapper Refactoring:
- Step 1: Identify duplicated logic
- Step 2: Import base components
- Step 3: Wrap with Base Root
- Step 4: Apply state-based styling and behavior
- Step 5: Wrap sub-components with styling
- Step 6: Final verification
Step 1: Identify Duplicated Logic
Look for patterns that indicate logic should come from base:
-
SDK hooks (useTamboThread , useTamboThreadInput , etc.)
-
Context creation (React.createContext )
-
State management that mirrors base component state
-
Event handlers (drag, submit, etc.) that base components handle
Step 2: Import Base Components
import { MessageInput as MessageInputBase } from "@tambo-ai/react-ui-base/message-input";
Step 3: Wrap with Base Root
Replace custom context/state management with the base Root:
// Before const MessageInput = ({ children, variant }) => { return ( <MessageInputInternal variant={variant}>{children}</MessageInputInternal> ); };
// After const MessageInput = ({ children, variant, className }) => { return ( <MessageInputBase.Root className={cn(variants({ variant }), className)}> {children} </MessageInputBase.Root> ); };
Step 4: Apply State-Based Styling and Behavior
State access follows a hierarchy — use the simplest option that works:
-
Data attributes (preferred for styling) — base components expose data-* attributes
-
Render props (for behavior changes) — use when rendering different components
-
Context hooks (for sub-components) — OK for styled sub-components needing deep context access
// BEST - data-* classes for styling, render props only for behavior
// Note: use data-[dragging]:* syntax (v3-compatible), not data-dragging:* (v4 only)
const StyledContent = ({ children }) => (
<BaseComponent.Content
className={cn(
"group rounded-xl border",
"data-[dragging]:border-dashed data-[dragging]:border-emerald-400",
)}
{({ elicitation, resolveElicitation }) => (
<>
{/* Drop overlay uses group-data-* for styling */}
<div className="hidden group-data-[dragging]:flex absolute inset-0 bg-emerald-50/90">
<p>Drop files here</p>
</div>
{elicitation ? (
<ElicitationUI
request={elicitation}
onResponse={resolveElicitation}
/>
) : (
children
)}
</>
)}
</BaseComponent.Content> );
// OK - styled sub-components can use context hook for deep access const StyledTextarea = ({ placeholder }) => { const { value, setValue, handleSubmit, editorRef } = useMessageInputContext(); return ( <CustomEditor ref={editorRef} value={value} onChange={setValue} onSubmit={handleSubmit} placeholder={placeholder} /> ); };
When to use context hooks vs render props:
-
Render props: when the parent wrapper needs state for behavior changes
-
Context hooks: when a styled sub-component needs values not exposed via render props
Step 5: Wrap Sub-Components
// Submit button const SubmitButton = ({ className, children }) => ( <BaseComponent.SubmitButton className={cn("w-10 h-10 rounded-lg", className)}> {({ showCancelButton }) => children ?? (showCancelButton ? <Square /> : <ArrowUp />) } </BaseComponent.SubmitButton> );
// Error const Error = ({ className }) => ( <BaseComponent.Error className={cn("text-sm text-destructive", className)} /> );
// Staged images - base pre-computes props array, just iterate const StagedImages = ({ className }) => ( <BaseComponent.StagedImages className={cn("flex gap-2", className)}> {({ images }) => images.map((imageProps) => ( <ImageBadge key={imageProps.image.id} {...imageProps} /> )) } </BaseComponent.StagedImages> );
Step 6: Final Verification
Final Checks:
- No duplicate context creation
- No duplicate SDK hooks in root wrappers
- No duplicate state management or event handlers
- Base namespace imported and
Base.Rootused as wrapper -
data-*classes used for styling (withgroup-data-*for children) - Render props used only for rendering behavior changes
- Base sub-components wrapped with styling
- Icon factories passed from styled layer to base hooks
- Visual sub-components and CSS variants stay in styled layer
What Belongs in Styled Layer
Icon Factories
When base hooks need icons, pass a factory function:
// Base hook accepts optional icon factory export function useCombinedResourceList( providers: ResourceProvider[] | undefined, search: string, createMcpIcon?: (serverName: string) => React.ReactNode, ) { /* ... */ }
// Styled layer provides the factory const resources = useCombinedResourceList(providers, search, (serverName) => ( <McpServerIcon name={serverName} className="w-4 h-4" /> ));
CSS Variants
const inputVariants = cva("w-full", { variants: { variant: { default: "", solid: "[&>div]:shadow-xl [&>div]:ring-1", bordered: "[&>div]:border-2", }, }, });
Layout Logic, Visual Sub-Components, Custom Data Fetching
These all stay in the styled layer. Base handles behavior; styled handles presentation.
Type Handling
Handle ref type differences between base and styled components:
// Base context may have RefObject<T | null> // Styled component may need RefObject<T> <TextEditor ref={editorRef as React.RefObject<TamboEditor>} />
Anti-Patterns
-
Re-implementing base logic - if base handles it, compose it
-
Using render props for styling - prefer data-* classes; render props are for behavior changes
-
Duplicating context in wrapper - use base Root which provides context
-
Hardcoding icons in base hooks - use factory functions to keep styling in styled layer