Polymorphic, typesafe UI components with React (and Tailwind and Radix Slot)
Let's explore how to make a UI component that can change the rendered tag whilst using the same styles. What do I mean? Imagine a Button component that renders a <button> element, but with your fancy button styles. It might have an API like this:
<Button variantColor="primary" onClick={submitHandler}>
Click me
</Button>
If want to render the Button
component as an a
element we might have an API like:
<Button variantColor="primary" href="https://blog.makerx.com.au/" as="a">
Click me
</Button>
This looks nice, but polymorphism implemented in this way can lead to slow typescript performance and therefore slow intellisense hints.1, 2, 3
Instead we are going to build a component that supports polymorphism via the children
property and an asChild
boolean. The same Button
component rendering an a
tag will look like:
<Button variantColor="primary" asChild>
<a href="https://blog.makerx.com.au/">
Click me
</a>
</Button>
This renders just the a
tag with all of the Button
properties applied:
<a class='text-white bg-blue rounded ....' href="" >Click Me</a>
Sorry there's no pictures! The point of this is to have button or an anchor tag that look the same.
Show me the code!
The key aspect to this technique is to conditionally use a Radix UI Slot
which will merge its props (i.e. className
, ref
etc.) onto its immediate child component.
import { Slot } from '@radix-ui/react-slot'
type ButtonProps = React.ComponentPropsWithoutRef<'button'> & {
variantColor: 'primary' | 'secondary' | 'danger'
asChild?: boolean
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>((props, forwardedRef) => {
const { variantColor, asChild, ...buttonProps } = props
const Component = (asChild ? Slot : 'button') as 'button'
return (
<Component
{...buttonProps}
ref={forwardedRef}
className={clsx(
// ...
)}
/>
)
}
Note, we were able to have:
- typechecking and intellisense for the
<button>
attributes when using the component as a button e.g.<Button variantColor="primary" onClick={submitHandler}>
- typechecking and intellisense for the
<a>
attributes when using the component as a link e.g.<Button asChild><a href="example.com">
- shared styling (and other functionality) whilst avoiding duplicating
className
and other properties
Using with NextJS Link component
If you use NextJS, this polymophic Button component will work with the Link component used for internal navigation. The passHref prop is required when using a custom component that wraps the a
element.
<Link href="/about" passHref>
<Button variantColor="primary" asChild>
<a>About</a>
</Button>
</Link>
Alternatives
Using as
attribute instead of asChild
We haven't tried it, but react-polymorphic-box can be used to get the <Button ... as="a">
syntax.
Using CSS-in-JS libraries
Many CSS-in-JS libraries have this sort of polymorphic behaviour built in. See Stitches and Emotion.
Footnotes
- 1: Discussion of "as" polymorphism performance in stitches CSS lib.
- 2: Issue raising slow editor intellisense hints using "as" polymorphism in (now depreciated) Radix Polymorphic component.
- 3: Polymorphic / "as" depreciated in favour of Slot / "asChild" component.