Benjamin Read's code garden.

Polymorphic Elements in Astro

Published on

This article is about: javascriptastro


I’m building a component library in Astro, and one of the things I needed to do was to build a component that could render as either a button or an anchor tag. Here’s an example of the outcome I wanted to achieve:


 // renders an <a> tag
<Button href="https://some-link">This is an anchor tag</Button>
 // renders a <button> tag
<Button type="button">Submit</Button>

With Astro you’re writing the best parts of JSX there could be;  for example, there are no abstraction leaks like having to add `key`s to iterables. But also what you’re writing is very obviously much closer to HTML than JSX is, there not being a virtual DOM, or styled components, so this puzzled me for a while. I could either have 2 components and duplicate my styles, or find a way to render either depending on the use case.

Thankfully with some clever prop handling, we can have the best of both worlds:

// button.astro

const { href } = Astro.props

---
<>
  {
    href ? (
      <a href={href}><slot /></a>
    ) : (
      <button
        type="button"
      >
        <slot />
      </button>
    )
  }
</>

With this code, the default output is a <button> element, but if you pass a href, it will instead render an <a> tag. What I love about this is that we are also using another trick first implemented by React, the Fragment. By wrapping the component in fragments (empty tags, or <> </>), you’re still returning one element, which is a requirement for Astro components, but eliminating the wrapper in the compiled code. This way you don’t have an extra `div` which could cause styling issues later on.

But what if you wanted more polymorphic elements? Say you had a container that could sometimes be a section, and sometimes you’d want to render it as a plain ol’ div element. Here’s a solution for that:

---
interface Props {
  as?: "section";
}

const { as } = Astro.props;

---

<>
  {as === 'section' && (
      <section>
        <slot />
      </section>
  )}
  {!as && (
    <div>
      <slot />
    </div>
  )}
</>

By specifying the types, we give whoever is using this component the hints to show them what elements they can render this component to.

It’s nicely extensible too: if you wanted to have another tag, say an <aside>, you could just add that to the types and to the return:

<>
  {as === 'aside' && (
      <aside>
        <slot />
      </aside>
  )}
  {as === 'section' && (
      <section class:list={classList}>
        <slot />
      </section>
  )}
  {!as && (
    <div>
      <slot />
    </div>
  )}
</>

I really like this because the person using this code doesn’t have to worry about polymorphism at all, the default return is a div, which they don’t have to specify. But what about passing styles to all of these different elements?

Styling polymorphic elements

For my component I wanted to pass in a default set of Tailwind classes, but also allow the person using the component to be able to override them by passing in an arbitrary string. So I used Astro’s class:list helper:

const { overrideClasses, as } = Astro.props;

const classList = new Set([
  "tw-mx-auto tw-max-w-7xl tw-px-2 sm:px-6 tw-lg:px-8",
  overrideClasses,
]);
---
<>
  {as === 'section' && (
      <section class:list={classList}>
        <slot />
      </section>
  )}
  {!as && (
    <div class:list={classList}>
      <slot />
    </div>
  )}
</>

By using polymorphic elements I’m keeping code dry, making it reusable, and providing the best possible experience for my colleagues.”

Read more articles about: javascriptastro

Comments

No comments yet. Be the first to comment!


“Wisest are they who know they do not know.”

— Jostein Gaarder