slot-jsx
v1.0.2
Published
Custom JSX pragma for slottable components with asChild pattern.
Readme
🎰 slot-jsx
A custom JSX pragma that enables declarative slottable components for powering asChild or render function prop patterns.
Features
- 🪆 Nested Slottables: Supports deeply nested slottable components
- 🔥 No
React.cloneElement: Transforms tree at creation time, outside render phase - ✨ React Server Components: Fully compatible with RSC and SSR
- ⏳ Async Components: Can slot onto async server components
- 🧹 Streamlined React tree: No more
SlotClonecomponents in devtools - 🧩 Composable: Can be composed with other JSX pragmas
- 🛡️ Type-Safe: Full TypeScript support
Installation
pnpm add slot-jsxUpdate your tsconfig.json:
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "slot-jsx/react"
}
}Important: Always import from
slot-jsx/react, not from the package root.
Quick Start
1. Create a Slottable Component
import { Slot } from 'slot-jsx/react';
interface ButtonProps extends React.ComponentProps<'button'> {
asChild?: boolean;
}
export function Button({ asChild, children, ...props }: ButtonProps) {
const Comp = asChild ? Slot : 'button';
return <Comp {...props}>{children}</Comp>;
}Note: The prop name
asChildis a convention from Radix UI, but you can name it whatever you want.
2. Use It
<Button asChild>
<a href="/home">Go Home</a>
</Button>
// Result: <a href="/home">Go Home</a>How It Works
When Slot is rendered, the component's root element is replaced by its child element, while preserving the component's internal structure.
Simple Case (No Slottable needed)
<Button asChild onClick={handleClick}>
<a href="/foo">Click me</a>
</Button>Button internally renders:
<Slot {...props}>{children}</Slot>The pragma transforms this to:
<a href="/foo" onClick={handleClick}>
Click me
</a>Complex Case (With Slottable)
When you have siblings to the children (like icons or wrappers), use Slottable to specify where the child's content goes:
<IconButton asChild onClick={handleClick}>
<a href="/foo">Click me</a>
</IconButton>IconButton internally renders:
<Slot {...props}>
<Icon />
<Slottable>{props.children}</Slottable>
</Slot>The pragma transforms this to:
<a href="/foo" onClick={handleClick}>
<Icon />
Click me
</a>Nested slottables?:
<Slot {...props}>
<Icon />
{/* nested in a span */}
<span>
<Slottable>{children}</Slottable>
</span>
</Slot>The pragma transforms this to:
<a href="/foo" onClick={handleClick}>
<Icon />
<span>Click me</span>
</a>With Render Prop
If you want to define a render prop API like Ariakit or Base UI, use the as prop on Slottable.
Example:
export function Button({ render, ...props }) {
const Comp = render ? Slot : 'button';
return (
<Comp {...props}>
<Slottable as={render}>{props.children}</Slottable>
</Comp>
);
}Usage:
<Button render={<a href="/foo" />}>Click me</Button>or:
<Button render={(props) => <a {...props} href="/foo" />}>Click me</Button>This pattern gives consumers full control over the rendered element while still preserving the slot mechanics.
When using the function pattern, Slot will not merge props for you, to give you control over prop forwarding and composition.
Note: render functions cannot be passed to a client comp from an RSC, so bear that in mind if you decide to use this API.
Ejecting JSX Pragma
You can eject the pragma to configure it, or compose it with other custom pragmas for styling or other transformations.
To eject, create your own custom JSX runtime files in your project:
app/jsx-runtime/jsx-runtime.ts:
import { jsx as baseJsx, jsxs as baseJsxs, Fragment } from 'react/jsx-runtime';
import { withSlot, withSlotJsxs, Options } from 'slot-jsx/react';
import { withCss, withCssJsxs } from '@some-lib/css-pragma';
// optionally define your own custom merge props behaviour
export const mergeProps: Options['mergeProps'] = (outerProps, hostProps) => {
return { ...outerProps, ...hostProps };
};
export const jsx = withCss(withSlot(baseJsx, { mergeProps }));
export const jsxs = withCssJsxs(withSlotJsxs(baseJsxs, { mergeProps }));
export { Fragment };app/jsx-runtime/jsx-dev-runtime.ts:
import { jsxDEV as baseJsxDEV, Fragment } from 'react/jsx-dev-runtime';
import { withSlotDev } from 'slot-jsx/react';
import { withCssDev } from '@some-lib/css-pragma';
import { mergeProps } from './jsx-runtime';
export const jsxDEV = withCssDev(withSlotDev(baseJsxDEV, { mergeProps }));
export { Fragment };Update your tsconfig.json:
{
"compilerOptions": {
"jsxImportSource": "./src/jsx-runtime"
}
}Important: Use
"jsxImportSource": "@/jsx-runtime"in NextJS projects.
Prop Merging
When slotting occurs, props are merged intelligently:
- className: Concatenates both classes
- style: Merges style objects (host wins on conflicts)
- ref: Safely composes both refs (React 17+)
- Event handlers: Calls both handlers in sequence
- Other props: Host props take precedence
Rules & Edge Cases
- Slottable is optional: If you don't have wrappers or siblings to the children, you don't need
Slottable - Single Slottable: Only one
SlottableperSlot(if you use it) - Direct child:
Slottablemust be a direct child ofSlot; use its render function to build nested markup - Single Host Element: The child you're slotting onto must be exactly one React element
- Prop Merging: Host props override component props (except for className, style, ref, and event handlers)
Examples
Check out the demo app for working examples.
Inspiration
This implementation is inspired by Radix UI's Slot utility.
