Sam Breed

Product Developer, Investor

Little Web Components

How I use little Web Components (Custom Elements) to improve my website

Little Web Components are serious business
Little Web Components are serious business

For as long as I can remember, I had assumed Web Components (Custom Elements) were in the “still baking” phase of the standards lifecyle. Not yet ready for consumption unless you wanted a more hazardous life as a web developer.

That’s no longer true: Web Components are ready to use.*

Dave Rupert recently shared a post about his <fit-vids> component that caught my attention. For the first time, Web Components seemed simple and approachable. I didn’t have to re-write my entire website to use them! I decided to follow his example of giving Custom Elements a try the next time I need a little DOM thing.

As of today, I have three little components:

*More or less. If you want a full framework, look at something like Enhance. My examples are not by any means comprehensive.


This is a case where I normally would have written a few lines of vanilla JS and called it a day. With Web Components I can write a few lines of vanilla JS and call it a day, but this time with newer tools.

The component launches a share sheet using the Web Share API when available, otherwise it copies the current link to the clipboard (and changes the button text to indicate what just happened).

The user experience doesn’t seem that much different. But after years of using UI components in other contexts, I prefer the custom element:

Custom Element:

    data-title="Little Web Components"

CSS Selector:

<button class="share-button"
    data-title="Little Web Components"

I like the Custom Element because it clearly delineates between presentation and function. <button> is a visual element. <share-button> is a functional element.

Here's the source for <share-button>:
class ShareButton extends HTMLElement {
  constructor() {

    const { title, link, description } = this.dataset;
    if (!link) return;

    const $button = this.querySelector("button");
    if (!$button) return;

    $button.addEventListener("click", () => {
      if ("share" in navigator) {
      } else if ("clipboard" in navigator) {

    function share() {
      // call navigator.share()

    function copyToClipboard() {
      // call navigator.clipboard.writeText()
      // swap the button text

customElements.define("share-button", ShareButton);

This one seems a little silly and is definitely an afterthought. Instead of dumping a plain old anchor tag with a mailto: URL scheme, I’ve wrapped that into a custom element. Similar to ShareButton, I pass in a title and link and the component formats the URL scheme to open an email with a subject and a body.

Before / without JavaScript, the link is inert. Maybe you’re not a fan, but I’m fine with this. There’s a little extra work to go through in order to get the link styled correctly, but that’s the cost of using the Shadow DOM.

    data-title="Little Web Components"
  → Reply

You can see this and ShareButton in action at the bottom of this page.

class ReplyLink extends HTMLElement {
  constructor() {
  connectedCallback() {
    const { title, link } = this.dataset;
    if (!link) return;

    const shadow = this.attachShadow({ mode: "closed" });
    const subject = encodeURIComponent(`Re: ${title}`);
    const body = encodeURIComponent(`\n\nLink to: ${link}\n`)

    const style = document.createElement("style");
    style.textContent = `
      a {
        color: var(--text-dim);
        text-decoration: none;
      a:hover {
        text-decoration: underline;
        text-underline-offset: 2px;

    const a = document.createElement("a");
    a.setAttribute("href", `mailto:[email protected]?subject=${subject}&body=${body}`);
    a.innerHTML = "→ Reply";

    shadow.append(style, a)

customElements.define("reply-link", ReplyLink);


<code-block> adds a “Copy to Clipboard” button to code blocks. Code highlighting is already done by Astro. An earlier version of this component handled syntax highlighting, but it is only suitable for client-generated text.

I’m using the “Light DOM” pattern again here. The component simply wraps its children and adds functionality.

      console.log("Wow, look at that copy button up there 👆")
class CodeEmbed extends HTMLElement {
  constructor() {

  connectedCallback() {
    const button = document.createElement("button");
    button.innerHTML = `<svg viewBox="0 0 20 20" width="16" height="16">
      <use href="/icon-sprite.svg#icon-copy"></use>

    button.addEventListener("click", () => {
      navigator.clipboard.writeText(this.querySelector("pre")?.textContent ?? "");
      const span = document.createElement("span");
      span.innerHTML = "Copied!";
      setTimeout(() => {
      }, 1000);


customElements.define("code-block", CodeEmbed);

And here’s the plugin that adds it to code blocks automatically:

Rehype Plugin
function rehypeCodeWrapperPlugin() {
  return function transformer(tree) {
    visit(tree, 'raw', (node) => {
      if (node.value.startsWith('<pre')) {
        const rawContent = node.value;
        node.value = `<code-block>${rawContent}</code-block>`;
    return tree;
→ Reply