Composition
Slot, Slottable, and as-child style composition.
Composition
The composition utilities in Utilinent solve one specific architectural problem: A parent component wants to contribute props or behavior, but the child should remain the actual DOM element.
This pattern, often called "asChild", allows you to build flexible component libraries that don't pollute the DOM with unnecessary wrappers while keeping your styling and accessibility logic intact.
Slot
Slot is the primary tool for this pattern. Instead of rendering a new element, it clones its child and merges its own props into that child.
import { Slot } from '@ilokesto/utilinent';
function Button({ asChild, ...props }: { asChild?: boolean } & React.ButtonHTMLAttributes<HTMLButtonElement>) {
const Component = asChild ? Slot : 'button';
return <Component className="btn" {...props} />;
}
// Usage: Renders a single <a> tag with "btn" class and href behavior
<Button asChild>
<a href="/login">Login</a>
</Button>Slottable
If your component has a fixed structure (like an icon plus a label) but still needs to support asChild, use Slottable to mark which part of the children should be considered the "main" element to be merged.
import { Slot, Slottable } from '@ilokesto/utilinent';
function ButtonWithIcon({ children, asChild }: { children: React.ReactNode, asChild?: boolean }) {
const Component = asChild ? Slot : 'button';
return (
<Component className="btn-with-icon">
<span aria-hidden>★</span>
<Slottable>{children}</Slottable>
</Component>
);
}
// Usage: the <a> element becomes the merge target at the Slottable position
<ButtonWithIcon asChild>
<a href="/">Click me</a>
</ButtonWithIcon>With this pattern, the star remains its own sibling and the slotted <a> receives the merged props. Slot does not automatically wrap every sibling into the slotted child.
Runtime Internals
Slot follows two specific runtime paths when processing children:
- First-child default: If no
Slottableis present,Slotlooks at its first child. If that child is a valid React element, it becomes the merge target. Slottabletarget: If aSlottablecomponent is found among the children, its own children are treated as the target. This allows you to "teleport" the merge logic into a nested part of your component's JSX.
Prop Merge Rules (mergeProps)
mergeProps starts from the child props and then applies slot props with a few special cases. The practical precedence rules are:
- Event Handlers: If both sides provide a handler like
onClick, the child handler runs first and the slot handler runs second. style: Style objects are merged as{ ...childStyle, ...slotStyle }, so the slot wins on overlapping keys.className: Class names are concatenated in child-first order.- Other props: For non-special props like
id,title,role, ordata-*, the slot value overwrites the child value.
Ref Composition
Refs are automatically composed. If both the Slot and the child element have refs, Utilinent creates a "composed ref" that ensures both refs are updated correctly with the same DOM node. This is critical for libraries that rely on useImperativeHandle or manual DOM measurements.
Edge Cases
- Multiple children: If
Slotreceives multiple children without aSlottable, it will only merge into the first one. nullreturn: If no valid element target is found (e.g., if children is just a string ornull),Slotreturnsnullto avoid runtime errors during cloning.
Decision Guidance
- Use
Slotwhen: You are building a reusable primitive (Button, Link, Input) and want to support theasChildpattern to avoid wrapper hell. - Avoid
Slotwhen: You need to maintain a strict internal DOM structure that the user should not be able to override, or if you need to useReact.Childrenlogic that expects a specific element type.
Common Mistakes
- Passing multiple elements to
Slot: WithoutSlottable, only the first element gets the props. The others might be rendered but won't behave as intended. - Merging non-element children: Trying to use
asChildwith a plain string like<Button asChild>Click</Button>. This will render nothing because a string has no props to merge into. - Handler conflicts: Forgetting that both handlers will fire. If you want to stop the parent's handler from firing, you must call
event.stopPropagation()in the child handler.