DEV Community

Thomas Reggi
Thomas Reggi

Posted on

When to use an Astro component over a Web Component.

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>

Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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?

Top comments (2)

Collapse
 
dannyengelman profile image
Danny Engelman

1) It's simpler
Of course, you deferred code to a 3rd party dependency

2) It's easier to read / maintain
Of course, you deferred code to a 3rd party dependency

3) It's semantically better
Of course, you deferred code to a 3rd party dependency

So you compare making your own soup from vegetables, to buying a soup-starter in the supermarket.

Note: your constructor fails when the code is executed before DOM was parsed. There won't be any attributes.

Collapse
 
reggi profile image
Thomas Reggi

damn πŸ”₯πŸ”₯πŸ”₯ I like this πŸ’ž