This content originally appeared on DEV Community and was authored by Weam Adel
Introduction
Ever tried using a shadcn/ui Button as a link while also including icons or other JSX inside? If so, you may have hit a frustrating issue: the styles break, or worse, your icon ends up outside the clickable area.
In this article, you’ll learn why that happens—and how to fix it.
The Basics: Using asChild
The Button component from Shadcn supports an asChild
prop, which swaps the rendered button element for something else (like a Link or a tag). Under the hood, it uses @radix-ui/react-slot’s Slot component to forward props and styles to the element.
Here’s a typical Button implementation:
//components/ui/button.tsx
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
We can then use our button as a button, link or anything else:
<Button className="me-3">Button</Button>
{/* Or */}
<Button asChild>
<Link to="/">Link</Link>
</Button>
Awesome, but what if you want to add an icon (or any other JSX)?
Simple, you can just import your icon and use it inside your element:
<Button className="me-3">
<CheckIcon className="me-2 size-5" /> Button
</Button>
{/* Or */}
<Button asChild>
<Link to="/">
<CheckIcon className="me-2 size-5" />
Link
</Link>
</Button>
It works perfectly as expected:
The Problem: Multiple Children Inside the Slot
Now let’s say that you want to move your icon (or any other piece of JSX) inside your component for abstraction and to avoid duplication (all your buttons have a check icon )
You might update the Button to look like this:
// components/ui/button.tsx
import { Slot } from "@radix-ui/react-slot";
import CheckIcon from "@/assets/icons/check.svg?react";
function Button({
className,
variant,
size,
asChild = false,
children,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
>
<>
<CheckIcon className="me-2 size-5" />
{children}
</>
</Comp>
);
}
export { Button, buttonVariants };
Looks innocent, right? But when you render it:
<Button className="me-3">
Button
</Button>
<Button asChild>
<Link to="/">Link</Link>
</Button>
For your surprise, the button works fine, but the link
When you open the DevTools, you will realize that your icon is outside the a
element and the styles are not applied at all:
Even wrapping the contents in a div
doesn’t help:
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
>
<div>
<CheckIcon className="me-2 size-5" />
{children}
</div>
</Comp>
Now the structure is worse: styles break, and only the text is clickable.
The Solution: Use Slottable
Despite there can be other workarounds to make this work, but the straight forward solution is to use a Radix Component called Slottable
.
When your component (that uses Slot
) has multiple children, Slottable
ensures that the props are passed to the right component.
Here’s the updated Button component:
import { Slot, Slottable } from "@radix-ui/react-slot";
import CheckIcon from "@/assets/icons/check.svg?react";
function Button({
className,
variant,
size,
asChild = false,
children,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
>
<CheckIcon className="me-2 size-5" />
<Slottable>{children}</Slottable>
</Comp>
);
}
Result
…and the link is fully clickable as the text & icon are inside the a
element
This content originally appeared on DEV Community and was authored by Weam Adel