How to create an asChild prop in Svelte 5
What is the asChild
prop?
A common pattern in the React eco-system is to provide components with a prop named asChild
which indicates that rendering the component should be delegated to the children passed to the component. As such:
// MyButton.tsx
export function MyButton() {
return (
<Button asChild>
<a href={href}>I'm an anchor button</a>
</Button>
);
}
Typically developers will use the Slot
component provided by radix-ui to achieve this:
// Button.tsx
import { Slot } from "@radix-ui/react-slot";
export function Button({ asChild, children, ...attrs }) {
const Comp = asChild ? Slot : 'button';
return (
<Comp {...attrs}>{children}</Comp>
);
}
Personally I think this is a very nice pattern, in cases where components consist of a single root element. It allows consumers of the component to fully customize the HTML structure and visual appearance of a component, while re-using the behavior of the component. On top of this it prevent a lot of complexity and opinionated implementation details for the component itself.
How can I create an asChild
prop with Svelte?
In Svelte 3 and 4 this was always a bit tricky to implement, due to the lack of reactive slots and the complexity behind passing props down to children. In Svelte 5 the slot API has been completely revamped to use snippets, one of the features, I am most excited about for Svelte 5. Since snippets are passed as props, they are now reactive, and not just that, they are plain JavaScript functions!
The renewed API makes it easy to implement the same API in Svelte, and the most basic version of it looks something like this:
<!-- Button.svelte -->
<script>
let { asChild = false, children, ...attrs } = $props();
</script>
{#if asChild}
{@render children(attrs)}
{:else}
<button {...attrs}>{@render children()}</button>
{/if}
This Button
component accepts the same asChild
prop as used in the React component above. When it is set to true
no html is rendered, and the rest props (attrs
) are passed to the children
when they are rendered. When no asChild
prop is passed, or it is set to false
a fallback/default is rendered. To use this all you would have to do is the following:
<!-- MyButton.svelte -->
<script>
import Button from './Button.svelte';
</script>
<Button asChild>
{#snippet children(props)}
<a href="/home" {...props}>I'm an anchor button!</a>
{/snippet}
</Button>
Creating an abstraction
There are a couple issues with the approach above, though it is nice an simple, it would require implementing that same logic in many components. In it's current state it is also difficult to pass extra props to the children from the Button
component, as they're applied in two places which can be prone to error. Creating a re-usable component can help solve these problems:
<!-- Proxy.svelte -->
<script>
let { as = false, children, ...props } = $props();
</script>
{#if as}
{@render as(props, children)}
{:else}
{@render children?.(props)}
{/if}
We've created a component called Proxy
, it's purpose is to "proxy" The component above accepts an as
prop, which can either be false, or a Snippet
. If the as
prop is provided it will render it and pass the provided props and children to it. We could then use it as follows:
<script>
let { asChild, children, ...attrs } = $props();
</script>
{#if asChild}
<Proxy as={children} {...attrs} />
{:else}
<button {...attrs}>{@render children()}</button>
{/if}
We still however have the problem that we need to duplicate the props passed to the element/component. One way to solve that would be to create a snippet, and pass that to the Proxy
component:
<!-- Button.svelte -->
<script>
let { asChild, children, attrs } = $props();
</script>
{#snippet fallback(props, children)}
<button {...props}>{@render children()}</button>
{/snippet}
<Proxy as={asChild ? children : fallback} {children} {...attrs} />
This would work like a charm, but to me the syntax feels a little funky. A big downside is that if we would have a Button
component that has some child elements we would have to recreate that manually somehow. What if our Proxy
component accepted a fallback
property, one that it would render when no as
prop is provided?
<!-- Proxy.svelte -->
<script>
let { as = false, fallback, children, ...props } = $props();
</script>
{#if as}
{@render as(props, children)}
{:else}
{@render fallback(props, children)}
{/if}
The proxy now accept an extra prop fallback
which is rendered when the as
prop is not provided. The props
and children
are passed down to both the as
and fallback
snippets, so they can use them. Now we can update our Button
component to look like this:
<!-- Button.svelte -->
<script lang="ts">
let { asChild, children, ...attrs } = $props();
</script>
<Proxy as={asChild ? children : false} {...attrs}>
{#snippet fallback(props, children)}
<button {...props}>{@render children()}</button>
{/snippet}
{@render children()}
</Proxy>
The fallback
is now scoped to the Proxy
component, it is not available outside it, which makes it more clear that it "belongs" to the Proxy
. You might also notice we have {@render children()}
in there twice, what happens here is that the first time we render the children snippet as the bottom, it is passed to the Proxy
component. In turn the Proxy
component passes it to the fallback, which makes it possible to add some extra templating to our button which can be re-used by consumers.
Getting rid of the asChild
prop
What? Weren't we here to implement the asChild
prop in Svelte? Well, we were, sort of, but really we were here to implement the same logic, and the asChild
prop was mostly just an example, or reference, of what we wanted to achieve. It is totally fine to stop here, and use the component like this, however, what I would like to do, is remove the asChild
prop all together. At this point the asChild
prop is starting to become a bit redundant, and even though I like the idea behind it, I've always found it funky to render the component "as its child".
What if the Button
component accepted the same as
prop as the Proxy
component, and we simply passed that down?
<!-- Button.svelte -->
<script lang="ts">
let { as, children, ...attrs } = $props();
</script>
<Proxy {as} {...attrs}>
{#snippet fallback(props, children)}
<button {...props}>{@render children()}</button>
{/snippet}
{@render children()}
</Proxy>
When we implement this change in the MyButton
component things are really starting to come together:
<!-- MyButton.svelte -->
<script>
import Button from './Button.svelte';
</script>
<Button>
{#snippet as(props)}
<a href="/home" {...props}>I'm an anchor button!</a>
{/snippet}
</Button>
This is starting to feel like something I want to use. It is concise, and at the same time explicit about what is happening. We want to render a Button
, and we want to render it as
an an <a>
with an href
. We don't have to remember the connection between the asChild
prop and whatever is rendered inside the button, as sadly props/attributes tend to harder to read when there are a lot of them.
Bonus: accepting a string to render elements
As in the example above, we often only want to change the element that is rendered, and some might find the {#snippet as(props, children)}
syntax a bit to explicit. Whilst I like the explicit nature of how snippets work, there is some sugar that we can apply here to facilitate this use-case. What we want to achieve here, is the ability to do the following:
<!-- MyButton.svelte -->
<Button as="a" href="/home">
I'm an anchor button!
</Button>
This would do the same as the example above, and though it is less explicit, it is also more concise. There is definitely something to be said for this. All we would have to do to make this work is make a small adjustment to our Proxy
component:
<!-- Proxy.svelte -->
<script>
let { as = false, fallback, children, ...props } = $props();
</script>
{#if as}
{@render as(props, children)}
{:else if typeof as === 'string'}
<svelte:element this={as} {...props}>{@render children?.()}</svelte:element>
{:else}
{@render fallback(props, children)}
{/if}
Now our Proxy
component also accepts string values in the as
prop and renders them as an element. It would be fantastic if we could do the same with components, so instead of passing a string, being able to pass a component. Sadly at this time, as Svelte 5 is still in development, there is no way to differentiate between snippets and components. There is an open issue where improvements to the snippet API are being discussed: https://github.com/sveltejs/svelte/issues/9774, once a solution is agreed upon I will come back, and update this post to include the changes.
Conclusion
Svelte 5 introduces a powerful new API with snippets, it makes slots reactive, and extremely flexible compared to the old API. Having the ability to pass them down to deeper components, and even augmenting them along the is an incredibly powerful tool. There are still some improvements to be made, and I can understand if some may dislike the syntax, or the explicit nature of it. For me personally the templating in Svelte is one of the things that make it great, and it just became a whole lot better.
Since I started using Svelte 5 in side-projects I've been using this Proxy
component all over. The simplicity of the component itself, and the ergonomics provided to consumers make it a blessing to use and maintain. It is especially useful for UI components, where the main responsibility is presentation, but I find myself discovering new use-cases everyday.