How to create an asChild prop in Svelte 5

by Sibbe — 8 minutes

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.

meerdivotion

Cases

Blogs

Event