A simple link web component

In this blog post, we will explore the LinkComponent - a versatile web component that renders links either as text links or button links.

I'll not go into too much detail of how I build web components. I have done so in previous posts Building a Custom Web Component: Cloudinaryimage or Truncated Aptitle - a Custom Web Component.

Introduction

The LinkComponent is designed to render links either as plain text or as a button. It offers several configurations like setting a color scheme, determining if the link opens in a new tab, and even customizing the label text. A simple usage looks like:

<link-component url="<your-url>" isbutton colorscheme="primary" isexternal>Label</link-component>

Key Features

  • Encapsulation with Shadow DOM: The component makes use of the Shadow DOM, ensuring styles and markup remain isolated from the rest of the page. This prevents accidental CSS leaks and style conflicts.

  • Dynamic Styling: The LinkComponent supports various color schemes like primary, secondary, tertiary, and inverted. This is achieved using CSS custom properties (variables) which are both readable and maintainable.

  • Reactivity: The component is designed to be reactive. Changes to its attributes or content dynamically update the rendered link. This reactivity is achieved using a MutationObserver which watches for changes in the attributes and the textContent.

  • Accessibility: When rendered as a button, the link gets an aria-label attribute, making it more accessible to screen readers. External links get target="_blank" and rel="noopener noreferrer" attributes for security and performance reasons.

Usage

Rendering as a text link:

<link-component url="<your-url>">Your Label</link-component>

// renders as: <a href="<your-url>" class="text-link">Your Label</a>

Rendering as a Button:

<link-component url="<your-url>" isbutton colorscheme="primary">Your Label</link-component>

// renders as: <a href="<your-url>" class="btn primary">Your Label</a>

External Links:

<link-component url="<your-url>" isexternal>Your Label</link-component>

// renders as: <a href="<your-url>" class="text-link" target="_blank" rel="noopener noreferrer">Your Label</a>

Code

Here is the code for the LinkComponent component:

/**
* @name LinkComponent
* @description LinkComponent is a component for links to be rendered as either a text or a button link.
* @example <link-component url="https://www.apple.com" isbutton colorscheme="primary" isexternal>Label</link-component>
* @param {string} url - url for link
* @param {string} label - label for link
* @param {string} isbutton - if exists, link is renders as a button, else as a text link
* @param {string} colorscheme - color scheme for button link, primary, secondary or inverted
* @param {string} isexternal - if exists, link is rendered as an external link
* 
*/

class LinkComponent extends HTMLElement {
  
  constructor() {
    super();
    
    this.shadow = this.attachShadow({ mode: 'open' });
    this.shadow.innerHTML = `
      <style>
        /* default styles */
        :host {
          --btn-color: #003436;
          --btn-text-color: #fff;
          --btn-border-radius: 0;
          --btn-padding: 0rem 2.5rem;
          --btn-font-size: 1rem;
          --btn-font-weight: 700;
          --btn-line-height: 2.5rem;

          --btn-color-primary: #003436;
          --btn-text-color-primary: #fff;

          --btn-color-secondary: #007175;
          --btn-text-color-secondary: #fff;

          --btn-color-tertiary: #00AFB5;
          --btn-text-color-tertiary: #222;

          --btn-color-inverted: transparent;
          --btn-border-color-inverted: #003436;
          --btn-text-color-inverted: #003436;
          --btn-text-color-hover-inverted: #fff;
          
          --link-color: #003436;
          --link-hover-color: #00C896;

          --link-white-space: nowrap;

        }

        .text-link {
          color: var(--link-color);
          text-decoration: none;
          border-bottom: 1px dashed var(--link-color);
          transition: all 0.3s ease-in-out;

          &:hover {
            opacity: 0.5;
          }
        }
        
        .btn {
          display: inline-block;
          position: relative;

          background: var(--btn-color);
          border: 1px solid var(--btn-border-color);
          border-radius: var(--btn-border-radius);
          color: var(--btn-text-color);
          cursor: pointer;
          padding: var(--btn-padding);
          font-size: var(--btn-font-size);
          font-weight: var(--btn-font-weight);
          line-height: var(--btn-line-height);
          text-align: center;
          text-decoration: none;
          white-space: var(--link-white-space);
          transition: all 0.3s ease-in-out;

          &:hover {
            opacity: 0.7;
          }

          &.primary {
            background: var(--btn-color-primary);
            border-color: var(--btn-color-primary);
            color: var(--btn-text-color-primary);
          }

          &.secondary {
            background: var(--btn-color-secondary);
            border-color: var(--btn-color-secondary);
            color: var(--btn-text-color-secondary);
          }

          &.tertiary {
            background: var(--btn-color-tertiary);
            border-color: var(--btn-color-tertiary);
            color: var(--btn-text-color-tertiary);
          } 

          &.inverted {
            background: var(--btn-color-inverted);
            border: 2px solid var(--btn-border-color-inverted);
            color: var(--btn-text-color-inverted);

            &:hover {
              background: var(--btn-border-color-inverted);
              color: var(--btn-text-color-hover-inverted);
            }
          }
        }
      </style>
      <a></a>
    `;

    this.link = this.shadow.querySelector('a');

    // cache the state of the component
    this.props = {
      url: "",
      colorscheme: "",
      isbutton: false,
      isexternal: false,
      label: ""
    };

    this.updateLink = link => {
      link.setAttribute('href', this.props.url);
      if (this.props.isexternal) { 
        link.setAttribute('target', '_blank');
        link.setAttribute('rel', 'noopener noreferrer');
      } else {
        link.removeAttribute('target');
        link.removeAttribute('rel');
      }
      if (this.props.isbutton) { 
        link.setAttribute('role', 'button');
        link.setAttribute('aria-label', this.props.label);
        link.setAttribute('class', `btn ${this.props.colorscheme}`.trim()); // trim removes white space in case colorScheme is not set
      } else {
        link.setAttribute('class', `text-link`);
        // if updated from button to text link remove these attributes
        link.removeAttribute('role');
        link.removeAttribute('aria-label');
      }
      link.textContent = this.props.label;
    }

    // watch for textContent and boolean attribute changes
    this.mutationObserver = new MutationObserver(this.mutationObserverCallback.bind(this));
    this.mutationObserver.observe(this, {
      attributes: true,
      characterData: true, 
      childList: true, 
      subtree: true 
    });
  } // end constructor

  // component attributes
  static get observedAttributes() {
    return ['url', 'colorscheme', 'isbutton', 'isexternal'];
  }

  // explicitly define properties reflecting to attributes
  get url() {
    return this.props.url;
  }
  set url(value) { 
    this.props.url = value;
    this.updateLink(this.link);
  }
  get colorscheme() {
    return this.props.colorscheme;
  }
  set colorscheme(value) { 
    this.props.colorscheme = value;
    this.updateLink(this.link);
  }
  get isbutton() {
    return this.props.isbutton;
  }
  set isbutton(value) { 
    this.props.isbutton = !!value;
    this.updateLink(this.link);
  }
  get isexternal() {
    return this.props.isexternal;
  }
  set isexternal(value) { 
    this.props.isexternal = !!value;
    this.updateLink(this.link);
  }
  
  // attribute change
  attributeChangedCallback(property, oldValue, newValue) {
    if (!oldValue || oldValue === newValue) return;

    switch (property) {
      case 'url':
        this.props.url = newValue;
        break;
      case 'colorscheme':
        this.props.colorscheme = newValue;
        break;
    }

    this.updateLink(this.link);
  }

  // boolean attributes and the textContent are changed via a mutation observer
  mutationObserverCallback(mutations) {
    mutations.forEach((mutation) => {
      /**
      * characterData and childList mutations are for textContent changes
      */

      if (mutation.type === 'characterData' || mutation.type === 'childList') {
        this.props.label = mutation.target.textContent;
      }

      /**
      * @notes
      * For boolean attributes, we use attribute mutations since they don't trigger 
      * the `attributeChangedCallback`. All other attribute changes are managed by the 
      * `attributeChangedCallback`.
      */
      if (mutation.type === 'attributes') {
        if (mutation.attributeName === 'isbutton') {
          this.props.isbutton = !!mutation.target.hasAttribute('isbutton');
        } 
        if (mutation.attributeName === 'isexternal') {
          this.props.isexternal = !!mutation.target.hasAttribute('isexternal');  
        }
      }
      this.updateLink(this.link);
    });
  } // end mutationObserverCallback

  async connectedCallback() {
    this.props.url = this.getAttribute('url');
    this.props.isbutton = this.hasAttribute('isbutton');
    this.props.isexternal = this.hasAttribute('isexternal');
    this.props.colorscheme = this.getAttribute('colorscheme');
    this.props.label = this.textContent;

    //create link element
    const link = this.shadow.querySelector('a');

    this.updateLink(link);
  }

  disconnectedCallback() {
    // remove mutation observer
    this.mutationObserver.disconnect();
  } 
}

// register component
customElements.define( 'link-component', LinkComponent );

The code can be found on GitHub and on NPM.

Scroll to top