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

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