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.
tsx1// WRONG - re-implementing what base already does 2const StyledInput = ({ children, className }) => { 3 const { value, setValue, submit } = useTamboThreadInput(); // Duplicated! 4 const [isDragging, setIsDragging] = useState(false); // Duplicated! 5 const handleDrop = useCallback(/* ... */); // Duplicated! 6 7 return ( 8 <form onDrop={handleDrop} className={className}> 9 {children} 10 </form> 11 ); 12}; 13 14// CORRECT - compose the base component 15const StyledInput = ({ children, className, variant }) => { 16 return ( 17 <BaseInput.Root className={cn(inputVariants({ variant }), className)}> 18 <BaseInput.Content className="rounded-xl data-[dragging]:border-dashed"> 19 {children} 20 </BaseInput.Content> 21 </BaseInput.Root> 22 ); 23};
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
tsx1import { 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:
tsx1// Before 2const MessageInput = ({ children, variant }) => { 3 return ( 4 <MessageInputInternal variant={variant}>{children}</MessageInputInternal> 5 ); 6}; 7 8// After 9const MessageInput = ({ children, variant, className }) => { 10 return ( 11 <MessageInputBase.Root className={cn(variants({ variant }), className)}> 12 {children} 13 </MessageInputBase.Root> 14 ); 15};
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
tsx1// BEST - data-* classes for styling, render props only for behavior 2// Note: use `data-[dragging]:*` syntax (v3-compatible), not `data-dragging:*` (v4 only) 3const StyledContent = ({ children }) => ( 4 <BaseComponent.Content 5 className={cn( 6 "group rounded-xl border", 7 "data-[dragging]:border-dashed data-[dragging]:border-emerald-400", 8 )} 9 > 10 {({ elicitation, resolveElicitation }) => ( 11 <> 12 {/* Drop overlay uses group-data-* for styling */} 13 <div className="hidden group-data-[dragging]:flex absolute inset-0 bg-emerald-50/90"> 14 <p>Drop files here</p> 15 </div> 16 17 {elicitation ? ( 18 <ElicitationUI 19 request={elicitation} 20 onResponse={resolveElicitation} 21 /> 22 ) : ( 23 children 24 )} 25 </> 26 )} 27 </BaseComponent.Content> 28); 29 30// OK - styled sub-components can use context hook for deep access 31const StyledTextarea = ({ placeholder }) => { 32 const { value, setValue, handleSubmit, editorRef } = useMessageInputContext(); 33 return ( 34 <CustomEditor 35 ref={editorRef} 36 value={value} 37 onChange={setValue} 38 onSubmit={handleSubmit} 39 placeholder={placeholder} 40 /> 41 ); 42};
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
tsx1// Submit button 2const SubmitButton = ({ className, children }) => ( 3 <BaseComponent.SubmitButton className={cn("w-10 h-10 rounded-lg", className)}> 4 {({ showCancelButton }) => 5 children ?? (showCancelButton ? <Square /> : <ArrowUp />) 6 } 7 </BaseComponent.SubmitButton> 8); 9 10// Error 11const Error = ({ className }) => ( 12 <BaseComponent.Error className={cn("text-sm text-destructive", className)} /> 13); 14 15// Staged images - base pre-computes props array, just iterate 16const StagedImages = ({ className }) => ( 17 <BaseComponent.StagedImages className={cn("flex gap-2", className)}> 18 {({ images }) => 19 images.map((imageProps) => ( 20 <ImageBadge key={imageProps.image.id} {...imageProps} /> 21 )) 22 } 23 </BaseComponent.StagedImages> 24);
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.Root` used as wrapper
- [ ] `data-*` classes used for styling (with `group-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:
tsx1// Base hook accepts optional icon factory 2export function useCombinedResourceList( 3 providers: ResourceProvider[] | undefined, 4 search: string, 5 createMcpIcon?: (serverName: string) => React.ReactNode, 6) { 7 /* ... */ 8} 9 10// Styled layer provides the factory 11const resources = useCombinedResourceList(providers, search, (serverName) => ( 12 <McpServerIcon name={serverName} className="w-4 h-4" /> 13));
CSS Variants
tsx1const inputVariants = cva("w-full", { 2 variants: { 3 variant: { 4 default: "", 5 solid: "[&>div]:shadow-xl [&>div]:ring-1", 6 bordered: "[&>div]:border-2", 7 }, 8 }, 9});
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:
tsx1// Base context may have RefObject<T | null> 2// Styled component may need RefObject<T> 3<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