Loading...
Loading...
Creates unstyled compound components that separate business logic from styles. Use when building headless UI primitives, creating component libraries, implementing Radix-style namespaced components, or when the user mentions "compound components", "headless", "unstyled", "primitives", or "render props".
npx skill4agent add tambo-ai/tambo building-compound-components// 1. Create context for shared state
const StepsContext = React.createContext<StepsContextValue | null>(null);
// 2. Create Root that provides context
const StepsRoot = ({ children, className, ...props }) => {
const [steps] = useState(["Step 1", "Step 2"]);
return (
<StepsContext.Provider value={{ steps }}>
<div className={className} {...props}>
{children}
</div>
</StepsContext.Provider>
);
};
// 3. Create consumer components
const StepsItem = ({ children, className, ...props }) => {
const { steps } = useStepsContext();
return (
<div className={className} {...props}>
{children}
</div>
);
};
// 4. Export as namespace
export const Steps = {
Root: StepsRoot,
Item: StepsItem,
};my-component/
├── index.tsx # Namespace export
├── root/
│ ├── component-root.tsx
│ └── component-context.tsx
├── item/
│ └── component-item.tsx
└── content/
└── component-content.tsx// component-context.tsx
import * as React from "react";
interface ComponentContextValue {
data: unknown;
isOpen: boolean;
toggle: () => void;
}
const ComponentContext = React.createContext<ComponentContextValue | null>(
null,
);
export function useComponentContext() {
const context = React.useContext(ComponentContext);
if (!context) {
throw new Error("Component parts must be used within Component.Root");
}
return context;
}
export { ComponentContext };// component-root.tsx
import { Slot } from "@radix-ui/react-slot";
import * as React from "react";
import { ComponentContext } from "./component-context";
interface ComponentRootProps extends React.HTMLAttributes<HTMLDivElement> {
asChild?: boolean;
defaultOpen?: boolean;
}
export const ComponentRoot = React.forwardRef<
HTMLDivElement,
ComponentRootProps
>(({ asChild, defaultOpen = false, children, ...props }, ref) => {
const [isOpen, setIsOpen] = React.useState(defaultOpen);
const Comp = asChild ? Slot : "div";
return (
<ComponentContext.Provider
value={{ isOpen, toggle: () => setIsOpen(!isOpen) }}
>
<Comp ref={ref} data-state={isOpen ? "open" : "closed"} {...props}>
{children}
</Comp>
</ComponentContext.Provider>
);
});
ComponentRoot.displayName = "Component.Root";// index.tsx
import { ComponentRoot } from "./root/component-root";
import { ComponentTrigger } from "./trigger/component-trigger";
import { ComponentContent } from "./content/component-content";
export const Component = {
Root: ComponentRoot,
Trigger: ComponentTrigger,
Content: ComponentContent,
};
// Re-export types
export type { ComponentRootProps } from "./root/component-root";
export type { ComponentContentProps } from "./content/component-content";// Component
const Content = ({ children, className, ...props }) => {
const { data } = useContext();
return (
<div className={className} {...props}>
{children}
</div>
);
};
// Usage
<Component.Content className="my-styles">
<p>Static content here</p>
</Component.Content>;// Component
interface ContentProps {
render?: (props: { data: string; isLoading: boolean }) => React.ReactNode;
children?: React.ReactNode;
}
const Content = ({ render, children, ...props }) => {
const { data, isLoading } = useContext();
const content = render ? render({ data, isLoading }) : children;
return <div {...props}>{content}</div>;
};
// Usage
<Component.Content
render={({ data, isLoading }) => (
<div className={isLoading ? "opacity-50" : ""}>{data}</div>
)}
/>;// Parent provides array context
const Steps = ({ children }) => {
const { reasoning } = useMessageContext();
return (
<StepsContext.Provider value={{ steps: reasoning }}>
{children}
</StepsContext.Provider>
);
};
// Item provides individual step context
const Step = ({ children, index }) => {
const { steps } = useStepsContext();
return (
<StepContext.Provider value={{ step: steps[index], index }}>
{children}
</StepContext.Provider>
);
};
// Content reads from nearest context
const StepContent = ({ className }) => {
const { step } = useStepContext();
return <div className={className}>{step}</div>;
};
// Usage - maximum flexibility
<ReasoningInfo.Steps className="space-y-4">
{steps.map((_, i) => (
<ReasoningInfo.Step key={i} index={i}>
<div className="custom-wrapper">
<ReasoningInfo.StepContent className="text-sm" />
</div>
</ReasoningInfo.Step>
))}
</ReasoningInfo.Steps>;<div
data-state={isOpen ? "open" : "closed"}
data-disabled={disabled || undefined}
data-loading={isLoading || undefined}
data-slot="component-trigger"
{...props}
>[data-state="open"] {
/* open styles */
}
[data-slot="component-trigger"]:hover {
/* hover styles */
}import { Slot } from "@radix-ui/react-slot";
interface Props extends React.HTMLAttributes<HTMLButtonElement> {
asChild?: boolean;
}
const Trigger = ({ asChild, ...props }) => {
const Comp = asChild ? Slot : "button";
return <Comp {...props} />;
};
// Usage
<Component.Trigger asChild>
<a href="/link">I'm a link now</a>
</Component.Trigger>;export const Component = React.forwardRef<HTMLDivElement, Props>(
(props, ref) => {
return <div ref={ref} {...props} />;
},
);
Component.displayName = "Component";export interface ComponentRootProps extends React.HTMLAttributes<HTMLDivElement> {
asChild?: boolean;
defaultOpen?: boolean;
}
export interface ComponentContentRenderProps {
data: string;
isLoading: boolean;
}data-state="open"Component.RootComponent.ItemComponentPropsRenderProps| Scenario | Pattern | Why |
|---|---|---|
| Static content | Direct children | Simplest, most flexible |
| Need internal state | Render prop | Explicit state access |
| List/iteration | Sub-context | Each item gets own context |
| Element polymorphism | asChild | Change underlying element |
| CSS-only styling | Data attributes | No JS needed for style variants |