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:
- It’s simpler
- It’s easier to read / maintain
- 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?