@bento/slots
v0.3.0
Published
The slots implementation of Bento
Readme
Slots
The @bento/slots package provides a way to customize components using slots.
Allowing developers full control over every part of the component. It
automatically intercepts the slot and slots props of the components
and introduces the requested changes.
This package should be used in conjunction with the @bento/use-props
package.
Installation
npm install --save @bento/slots @bento/use-propswithSlots
The withSlots function is used to create a new component that supports slots.
import { useProps } from '@bento/use-props';
import { withSlots } from '@bento/slots';
export const Button = withSlots('BentoButton', function BentoButton(args) {
const { props, apply } = useProps(args);
// Do your magic ✨
});The withSlots function accepts three arguments:
The name argument does not need to match the name of under which you export
your component but it is recommended to use the same name to avoid confusion.
The reason why we require the name to be unique is because it's used to identify
the component. This allows the component to be targeted with a global slot
override, but it will also be used as displayName to ensure a readable
component name in the React DevTools.
Data Attributes
When a slot prop is added to a slottable component, a data-slot attribute is automatically added to the rendered DOM element. This allows developers to target specific slots using CSS selectors or for debugging purposes.
<Button slot="submit">Submit</Button>
// Renders: <button data-slot="submit">Submit</button>This is particularly useful for:
- CSS Targeting: Style specific slots without additional classes.
- Debugging: Quickly identify which slot a component belongs to in DevTools.
- Testing: Select elements by their slot name in tests.
/* Target all elements with a slot name "submit" */
[data-slot="submit"] {
background-color: blue;
}
/* Note: Each component only receives its direct slot prop value as data-slot,
not a concatenated namespace. For example, if you have nested slots like
"form.submit", the submit button will have data-slot="submit", not data-slot="form.submit". */
/* Target nested slots using attribute selectors */
[data-slot="form"] [data-slot="submit"] {
margin-top: 1rem;
}Forwarding Refs
Components created with withSlots automatically support ref forwarding. The withSlots function:
- Detects if your component accepts a ref parameter (by checking if it has 2 parameters)
- Automatically wraps it with
React.forwardRefin React 18 (if not already wrapped) - In React 19+, it relies on the native ref-as-prop behavior (no wrapping needed)
When using useProps, you can access the merged ref that combines:
- The ref passed directly to the component
- Any refs supplied through slots
- The forwarded ref from parent components
import { useProps } from '@bento/use-props';
import { withSlots } from '@bento/slots';
import React from 'react';
export const Button = withSlots(
'BentoButton',
React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(function BentoButton(args, forwardedRef) {
const { props, apply, ref } = useProps(args, {}, forwardedRef);
return <button {...apply({}, ['ref'])} ref={ref}>{ props.children }</button>;
})
);The ForwardRefExample demonstrates how refs coming from the top-level consumer and from slot overrides are merged into a single handle.
React 19 Compatibility
The ref forwarding implementation is designed to be React 19-compatible:
- React 18: Components with 2 parameters are automatically wrapped with
forwardRef - React 19: Components use the native ref-as-prop behavior, no wrapping needed
This means your components will work seamlessly when upgrading from React 18 to React 19 without any code changes.
Modifiers
Modifiers are functions that are ran when the supplied component is rendered. They can be used to modify the component or received props. This allows common modifications to be shared between components.
Each modifier is a function that takes an object with the following properties:
The modification happen based on the return value of the modifier. If nothing
is returned, the component will be rendered as is. If you want to introduce
a new Component to be rendered, you can return an object with a Component
property. If you want to modify or introduce props that are passed to the
component, you can return an object with a props property. The same applies to
context.
For the props and contex the returned object is merged with the existing
values of their respective properties. So you don't need to merge the existing
values yourself. The Component is always replaced by the returned Component.
In the example below, the modifier function introduces a new prop to the
component:
export function modifier({ Component, context, props }) {
return {
props: {
newProp: 'new value'
}
}
}This modifier should then be passed in the third argument to withSlots:
import { replaces } from '@bento/slots/modifiers/replaces';
import { override } from '@bento/slots/modifiers/override';
import { withSlots } from '@bento/slots';
import { modifier } from './modifier';
const Button = withSlots('MyButton', function MyButton(props) {
return <button {...props} />;
}, [override, replaces, modifier]);
// Input: <Button>Hello</Button>
// Output: <button newProp="new value">Hello</button>If you don't want any modifiers to be applied to your component, you can supply
an empty array as the third argument to withSlots:
import { withSlots } from '@bento/slots';
export const Example = withSlots('Example', function Example(props) {
return (
<div {...props}>
<Header slot="header" />
<Body slot="content" />
<Footer slot="footer" />
</div>
);
}, [/* Supply an empty array if you don't want to use the modifers */]);The following modifiers are applied to the created component by default:
override
The override modifier is used to introduce a data-override prop to the
component when it detects that certain overrides are applied to the component.
This attribute makes it easier to determine where the difference between the
original Component and the current rendered component are originating from.
For you as a developer, this means you can easily see which components can be easily upgrade to the new version without any problems, and which components would require some additional attention and which areas specifically.
The data-override attribute is a space-separated list of the names that
indicates the following modifications:
style: Custom styling has been applied to the component using the `style prop.className: Custom className has been applied to the component using theclassNameprop.slot: The component has been modified usingslots. When the slots make changes tostyleorclassName, these are also included in the resultingdata-overrideattribute.context: The whole component or parent component has been modified using the replaces modifier and a different component has been rendered in its place.
import { override } from '@bento/slots/modifiers/override';replaces
The replaces modifier is used to introduce "global" override of components
in your application. The slot functionality that this package provides is
great for making small changes to a few components, but there might be use-case
where you need to make changes to every instance of a Component, e.g., in the
case of experimentation.
import { replaces } from '@bento/slots/modifiers/replaces';Slot Merging
Slot merging allows slots to be progressively enhanced as they flow through a component tree. When multiple components define slots for the same slot name, these slots are merged together.
Object Slot Merging
When both parent and child define object slots (props), they are merged with parent taking precedence:
// Child defines props
<Component slots={{ label: { className: 'child', id: 'child-id' } }} />
// Parent adds more props
<Child slots={{ label: { className: 'parent', title: 'parent-title' } }} />
// Result: { className: 'parent', id: 'child-id', title: 'parent-title' }Function Slot Merging
When function slots are merged, the parent function becomes active but receives access to all previous implementations via the previous parameter:
import { MergedFunction } from './examples/merged-function.tsx';
// Each enhancement level adds its own function
const FirstEnhancement = () => (
<BaseComponent slots={{
container: function firstWrapper() {
return <div>First Enhancement</div>;
}
}} />
);
const SecondEnhancement = () => (
<FirstEnhancement slots={{
container: function secondWrapper() {
return <div>Second Enhancement</div>;
}
}} />
);
// Final component can access all previous functions
<ThirdEnhancement slots={{
container: function wrapper({ previous }) {
return (
<div>
{previous[0]()} {/* First Enhancement */}
{previous[1]()} {/* Second Enhancement */}
{previous[2]()} {/* Third Enhancement */}
</div>
);
}
}} />Function Slot Parameters
Function slots receive an object with:
props: The component's propsoriginal: The original React elementprevious: Array of previous slot implementations (child → parent order)
Key Rules
- Object slots: Parent props override child props for same keys
- Function slots: Parent function is called, previous functions available in
previousarray - Mixed types: Function slots take precedence over object slots
- Previous array: Ordered from closest child to furthest ancestor
Validation
Compositional components often depend on specific child slots to function
correctly. For example, an Overlay component needs content to display, or a
Dialog component requires both a title and body. Without validation, missing
required slots would cause runtime errors or silent failures that are difficult
to debug.
contains
The contains utility validates that required slot assignments are present in
children. This is useful for compositional components that depend on specific
child slots to function correctly.
import { contains } from '@bento/slots';
import { BentoError } from '@bento/error';
import { Container } from '@bento/container';
export const Dialog = withSlots('Dialog', function Dialog(args) {
const { children } = args;
// Validate required slots are present
if (!contains(['title', 'content'], children)) {
throw new BentoError({
name: 'dialog',
method: 'Dialog',
message: 'Dialog requires children with slot="title" and slot="content"'
});
}
return <Container>{children}</Container>;
});The contains function recursively searches through React children to find
components with matching slot props. It only validates components wrapped with
withSlots() - raw HTML elements with slot props are ignored since they are not
part of the Bento slot system.
Namespaced Slot Validation
You can validate deeply nested slot structures using dot notation. When
searching for 'submit.icon', contains will look for a component with
slot="submit" that has a child component with slot="icon":
import { Button } from '@bento/button';
import { Icon } from '@bento/icon';
import { BentoError } from '@bento/error';
// Check for a nested slot path
if (!contains(['submit.icon'], children)) {
throw new BentoError({
name: 'form',
method: 'Form',
message: 'Submit button must have an icon'
});
}
// This will find:
// <Button slot="submit">
// <Icon slot="icon">→</Icon>
// </Button>