Creating a link-icon custom element

Published at Leave your comment

You know those times when you have a list of links and you want to show a nice icon next to each one? Maybe social media links in a footer, or a list of resources, or links in a bio page? You could manually add icons for each platform, but that gets tedious fast. What if the link could just show the right icon automatically?

That’s exactly what the <link-icon> custom element does. Pass it a URL and it figures out which icon to show. Twitter, GitHub, LinkedIn, Instagram, YouTube and a bunch more. If it doesn’t recognize the URL, it shows a generic link icon. Simple!

Here’s what it looks like in action:

<link-icon url="https://twitter.com/username"></link-icon>
<link-icon url="https://github.com/username"></link-icon>
<link-icon url="https://railsdesigner.com"></link-icon>

The first two show their respective platform icons. The last one shows a generic link icon. No configuration needed. Just pass the URL and you’re done.

The code is available on GitHub.

How it works

The custom element uses pattern matching to detect which platform a URL belongs to. It checks the URL against a list of patterns and returns the matching icon:

#platformPatterns = {
  twitter: /twitter\.com|x\.com/i,
  facebook: /facebook\.com|fb\.com/i,
  instagram: /instagram\.com/i,
  linkedin: /linkedin\.com/i,
  github: /github\.com/i,
  youtube: /youtube\.com|youtu\.be/i,
  // …
};

(all icons come from Phosphor Icons. I like that library: they’re consistent, well-designed and has vast collection of icons. It is also supported in Rails Icons)

Notice how Twitter matches both twitter.com and x.com? Same with YouTube matching both youtube.com and youtu.be. The patterns are flexible enough to catch common variations.

The element stores all the icon SVGs internally. When it renders, it determines which icon to show and injects the right SVG:

#render() {
  this.#url = this.getAttribute("url") || "";
  this.#iconType = this.#determineIconType();

  this.shadowRoot.innerHTML = `
    <style>
      :host {
        display: inline flex;
        width: 1.125rem;
        aspect-ratio: 1 / 1;
      }

      .icon {
        width: 100%; height: 100%;
        display: flex;
        align-items: center;
        justify-content: center;

        svg {
          width: 100%;
          aspect-ratio: 1 / 1;
          fill: currentColor;
        }
      }
    </style>

    <div class="icon" data-icon-type="${this.#iconType}">
      ${this.#iconMarkup()}
    </div>
  `;
}

The #determineIconType method loops through the patterns and returns the first match:

#determineIconType() {
  if (!this.#url) return "default";

  for (const [platform, pattern] of Object.entries(this.#platformPatterns)) {
    if (pattern.test(this.#url)) return platform;
  }

  return "default";
}

If no pattern matches, it returns "default" which shows the generic link icon.

Using Shadow DOM for encapsulation

The element uses Shadow DOM to keep its styles isolated. Say what? I think this is one of the coolest features of custom elements: the styles you write inside the Shadow DOM don’t leak out to the rest of your page, and styles from your page don’t leak in. See that :host selector in the styles? That targets the custom element (this.element if you are familiar with Stimulus) itself. You can style the element from the outside (like setting its width), but the internal structure (the .icon div and the SVG) is completely protected (you can optionally “open it up”; will write about that later). Your global CSS won’t accidentally mess with it. So this means that if you have a .icon class in your main CSS and a .icon class in the Shadow DOM, they won’t conflict. Pretty neat!

The Shadow DOM is created in the constructor:

constructor() {
  super();

  this.attachShadow({ mode: "open" });
}

That mode: "open" means you can access the shadow root from JavaScript if needed (which is almost always 😅). But for most cases, you just set it up once and forget about it.

Updates on-the-lfy

The element observes the url attribute. If you change it, the icon updates automatically:

static get observedAttributes() {
  return ["url"];
}

attributeChangedCallback(name, oldValue, newValue) {
  if (name === "url" && oldValue !== newValue) {
    this.#render();
  }
}

This means you can update the URL dynamically and the icon will change. Useful if you’re building something interactive where links change based on user input.

You can also set the URL via JavaScript with a Stimulus controller:

// app/javascript/controllers/profile_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["icon"]

  updateIcon(event) {
    this.iconTarget.url = event.target.value
  }
}
<div data-controller="profile">
  <link-icon data-profile-target="icon"></link-icon>

  <input type="url" data-action="input->profile#updateIcon">
</div>

The setter updates the attribute, which triggers the attributeChangedCallback, which re-renders the element. Everything stays in sync.

Why custom elements?

You might wonder why use a custom element instead of a helper method or a component. It’s reusable across any framework or no framework at all. Drop the JavaScript file into any project and it works. But, more interestingly, it’s reactive. Change the URL and the icon updates. That’s impossible to do with a helper method/component alone.

If you’re new to custom elements, I wrote about custom elements before before. Check them out to read more.

Setting it up

To use this in your Rails app, add the JavaScript file to app/javascript/components/link-icon.js (the commit shows the full code). Then import it in your application.js:

import "components/link-icon"

If you’re using importmap (like the example in the commit), pin the components directory:

# config/importmap.rb
pin_all_from "app/javascript/components", under: "components"

Then just use <link-icon> in your views. That’s it!


Pretty handy, right?

Let me know if you try it or have questions below!

💬 Over to you…

What did you like about this article? Learned something knew? Found something is missing or even broken? 🫣 Let me (and others) know!

Comments are powered by Chirp Form

More articles like this on modern Rails, UI & frontend engineering?
JavaScript for Rails Developers
Make JS your 2nd favorite language