When to use an Astro component over a Web Component.

December 14, 2023 (Syndicated From dev.to)

For my personal website (built with Astro) I wanted a way to abstract out something I call a heading fragment, it combines the concept of a heading tag (h1, h2, h3, ect) and a fragment link #reference-to-id-on-the-page. This seems like a no-brainer for any blog or website to provide permalinks to specific sections of a blog post or article.

In an ideal world this looks like this:

<h-frag>Hello World</h-frag>

This would:

  • Wrap it all in an anchor
  • Kebab-case the text and add it as the id somewhere near this element.
  • For extra measure I wanted hover behavior so that when you hover you get a link icon that shows up to the left of the text.

Now, the question is should this be an astro component? Or a web component?

What is an Astro component?

Astro is a web framework with a main-focus on static-site generation (ssg). It allows you to have .astro files that are a bit different than your average .js / .ts file.

---
const text = 'hello world'
---
<div>{text}</div>

Astro files are a hybrid of Javascript / Typescript, JSX-like syntax, and HTML. Asto has a bunch of tricks up its sleeve to optimize all of these things, especially <style> and <script> tags.

What does HeadingFragment look like in Astro?

This code below might be new to you if you’ve never seen an astro component, but it’s really simple. It just uses html, js, and css, that’s it.

---
const { as: As = 'h1' } = Astro.props;
const text = await Astro.slots.render('default')

function toKebabCase(str: string): string {
    return str
        .match(/[A-Z]?[a-z]+|[0-9]+/g)!
        .map(word => word.toLowerCase())
        .join('-');
}

const id = toKebabCase(text)
---

<As class="heading items-center" id={id}>
  <a class="flex" href={`#${id}`}>
    <span class="icon w-0 overflow-hidden" style="transition: width 0.3s;">🔗</span>
    <span>{text}</span>
  </a>
</As>

<script>
  document.querySelectorAll('.heading').forEach(heading => {
    const icon = heading.querySelector('.icon')

    heading.addEventListener('mouseover', () => {
      icon.classList.add('hovered');
    });
    heading.addEventListener('mouseout', () => {
      icon.classList.remove('hovered');
    });
  });
</script>

<style>
  .hovered {
    width: 32px;
  }
</style>

What does heading-fragment look like as a web component?

The web component is a bit more involved. Disclaimer: it does have one “extra feature” which is to find the font-size of the tag used and compensate for the “icon” width dynamically (ignore this).

class HeadingFragment extends HTMLElement {
  
  static kabob (value: string) {
    return value
      .match(/[A-Z]?[a-z]+|[0-9]+/g)!
      .join('-')
      .toLowerCase()
  }

  static elmWidth (tag: string, text: string) {
    const elm = document.createElement(tag)
    elm.style.display = 'inline'
    elm.style.visibility = 'hidden'
    elm.innerHTML = text
    document.body.append(elm)
    const width = elm.offsetWidth
    elm.remove()
    return width
  }

  defaultEmoji = '🔗'
  emoji: string
  emojiWidth: number = 0
  constructor() {
    super()
    this.as = this.getAttribute('as') || 'h1'
    this.emoji = this.getAttribute('emoji') || this.defaultEmoji
    this.emojiWidth = HeadingFragment.elmWidth(this.as, this.emoji)
  }

  as: string
  text: string
  id: string
  connectedCallback() {
    this.text = this.textContent || ''
    this.id = HeadingFragment.kabob(this.text)

    const heading = this.heading
    const anchor = this.anchor
    const icon = this.icon
    const main = this.main

    heading.addEventListener('mouseenter', () => {
      icon.style.width = (this.emojiWidth + 5) + 'px'
    })

    heading.addEventListener('mouseleave', () => {
      icon.style.width = '0px'
    })

    anchor.appendChild(icon)
    anchor.appendChild(main)
    heading.appendChild(anchor)

    this.innerHTML = ''
    this.appendChild(heading)
  }

  get heading () {
    const e = document.createElement(this.as)
    e.setAttribute('id', this.id)
    return e
  }

  get anchor () {
    const e = document.createElement('a')
    e.style.display = 'flex'
    e.setAttribute('href', `#${this.id}`)
    return e
  }

  get icon () {
    const e = document.createElement('span')
    e.style.overflow = 'hidden'
    e.style.width = '0'
    e.style.whiteSpace = 'nowrap'
    e.style.transition = 'width 0.3s'
    e.textContent = this.emoji
    return e
  }


  get main () {
    const e = document.createElement('span')
    e.textContent = this.text
    return e
  }
}

customElements.define('heading-fragment', HeadingFragment)

Conclusion

Now that I showed both ways which is better? I think the Astro component is better, here’s why:

  1. It’s simpler
  2. It’s easier to read / maintain
  3. It’s semantically better

What does “Semantically Better” mean?

When using the web component you’re dynamically adding a heading tag to the page using Javascript, this means when the page loads there’s no headings on the page. This isn’t great. I know that search engines have come a long way when it comes to crawling html, but for something essential for screen-readers it still scares me a little to not be rendering h2 tags statically.

Your thoughts?

What do you think? Which do you think is “better”, the Astro component or the Web Component?