DEV Community

Gil Fink
Gil Fink

Posted on • Originally published at gilfink.Medium on

Progressive Web Components: Unlocking Universal UI with Native APIs

The Cross-Framework Conundrum

In the dynamic world of front-end development, we often find ourselves building intricate UI components. These components, born from hours of meticulous design and development, frequently become tethered to a specific framework — be it React, Vue, or Angular. This “framework monogamy” creates a significant challenge: how do you share that expertly crafted component across different projects, used by different teams, potentially leveraging different frameworks, without resorting to painful rewrites or clunky integration hacks?

The ideal scenario is a truly universal component: one codebase, consumable everywhere, without forcing an entire project into a specific ecosystem. This is where the power of Progressive Web Components (PWCs), built on native browser APIs, comes into play.

The Foundation: Native Web Components

At its core, a Web Component is a custom HTML element, completely defined and encapsulated by the browser’s own standards. It’s not a framework; it’s a browser feature. This means it works inherently in any modern browser and can be seamlessly integrated into any front-end framework (or no framework at all).

Let’s illustrate this with a practical example: a collapsible panel. This is a common UI element that often requires custom logic for toggling visibility, accessibility attributes, and perhaps some internal content management.

The Core Web Component: A Collapsible Panel Example

We’ll build our collapsible panel using the native Web Components API directly:

// collapsible-panel.js
class CollapsiblePanel extends HTMLElement {
  static get observedAttributes() {
    return ['open'];
  }

  constructor() {
    super();
    this.attachShadow({ mode: 'open' }); 
    this.isOpen = this.hasAttribute('open');
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
          border: 1px solid #ccc;
          margin-bottom: 10px;
        }
        .header {
          padding: 10px;
          background-color: #f0f0f0;
          cursor: pointer;
          font-weight: bold;
          display: flex;
          justify-content: space-between;
          align-items: center;
        }
        .content {
          padding: 10px;
          border-top: 1px solid #eee;
        }
        .icon {
          transition: transform 0.2s ease-in-out;
        }
        :host([open]) .icon {
          transform: rotate(90deg);
        }
      </style>
      <div class="header" role="button" tabindex="0" aria-expanded="${this.isOpen}">
        <slot name="header"></slot>
        <span class="icon">&#9658;</span>
      </div>
      <div class="content">
        <slot></slot>
      </div>
    `;
    this._header = this.shadowRoot.querySelector('.header');
    this._content = this.shadowRoot.querySelector('.content');
  }

  connectedCallback() {
    this._header.addEventListener('click', this._togglePanel);
    this._header.addEventListener('keydown', this._handleKeydown);
    this._updateContentVisibility();
  }

  disconnectedCallback() {
    this._header.removeEventListener('click', this._togglePanel);
    this._header.removeEventListener('keydown', this._handleKeydown);
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'open') {
      this.isOpen = newValue !== null;
      this._updateContentVisibility();
      this._header.setAttribute('aria-expanded', this.isOpen);
      this.dispatchEvent(new CustomEvent('panel-toggle', { detail: { open: this.isOpen } }));
    }
  }

  _togglePanel() {
    if (this.isOpen) {
      this.removeAttribute('open');
    } else {
      this.setAttribute('open', '');
    }
  };

  _handleKeydown(event) {
    if (event.key === 'Enter' || event.key === ' ') {
      event.preventDefault();
      this._togglePanel();
    }
  };

  _updateContentVisibility() {
    this._content.style.display = this.isOpen ? '' : 'none';
  }

  toggle() {
    this._togglePanel();
  }
}
customElements.define('collapsible-panel', CollapsiblePanel);
Enter fullscreen mode Exit fullscreen mode

You can use this component directly in HTML:

<collapsible-panel open>
  <span slot="header">My Awesome Panel</span>
  <p>This is some content inside the collapsible panel.</p>
</collapsible-panel>
<collapsible-panel>
  <span slot="header">Another Panel</span>
  <p>More content here.</p>
</collapsible-panel>
Enter fullscreen mode Exit fullscreen mode

The “Progressive” Layer: Framework Enhancements

The Web Component is fully functional on its own. However, in a framework-driven application, developers expect a more “native” integration — passing props, listening to events in a framework-idiomatic way, and potentially leveraging framework context. This is where the “progressive layer” comes in: creating lightweight, framework-specific wrappers that enhance the Web Component’s usability without adding unnecessary overhead to its core.

Enhancing for React Example

In React, we want to treat the Web Component like any other React component, managing its open state and listening to its panel-toggle event:

// CollapsiblePanelReact.jsx
import React, { useRef, useEffect, useState } from 'react';
import './collapsible-panel';

const CollapsiblePanel = ({ children, header, defaultOpen = false, onToggle }) => {
  const panelRef = useRef(null);
  const [isOpen, setIsOpen] = useState(defaultOpen);

  useEffect(() => {
    const currentPanel = panelRef.current;
    if (!currentPanel) return;
    // Synchronize React state to Web Component attribute
    if (isOpen) {
      currentPanel.setAttribute('open', '');
    } else {
      currentPanel.removeAttribute('open');
    }
    const handlePanelToggle = (event) => {
      setIsOpen(event.detail.open);
      onToggle && onToggle(event.detail.open);
    };
    currentPanel.addEventListener('panel-toggle', handlePanelToggle);
    return () => {
      currentPanel.removeEventListener('panel-toggle', handlePanelToggle);
    };
  }, [isOpen, onToggle]);

  return (
    <collapsible-panel ref={panelRef}>
      <span slot="header">{header}</span>
      {children}
    </collapsible-panel>
  );
};

export default CollapsiblePanel;
Enter fullscreen mode Exit fullscreen mode

Now, a consuming developer can use it like this:

// MyApp.jsx
import React from 'react';
import CollapsiblePanel from './CollapsiblePanelReact';

const MyApp = () => {
  const handlePanelToggle = (open) => {
    console.log(`Panel is now ${open ? 'open' : 'closed'}`);
  };

  return (
    <div>
      <CollapsiblePanel header="React Specific Panel" defaultOpen onToggle={handlePanelToggle}>
        <p>This panel integrates seamlessly with React state and events.</p>
      </CollapsiblePanel>
      <button onClick={() => panelRef.current?.toggle()}>Toggle via Ref</button>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Enhancing for Vue Example

Vue also allows straightforward integration with Web Components. We can create a simple wrapper to align with Vue’s reactivity system and event handling:

<template>
  <collapsible-panel :open="isOpen" @panel-toggle="handleToggle">
    <template v-slot:header>{{ header }}</template>
    <slot></slot>
  </collapsible-panel>
</template>

<script>
import './collapsible-panel';
export default {
  props: {
    header: String,
    open: Boolean, 
  },
  data() {
    return {
      isOpen: this.open,
    };
  },
  watch: {
    open(newVal) {
      this.isOpen = newVal;
    }
  },
  methods: {
    handleToggle(event) {
      this.isOpen = event.detail.open;
      this.$emit('toggle', event.detail.open); 
    },
    toggle() {
      this.$refs.panel.toggle();
    }
  },
};
</script>
Enter fullscreen mode Exit fullscreen mode

And in a Vue app:

<template>
  <div>
    <CollapsiblePanelVue header="Vue Specific Panel" :open="isVuePanelOpen" @toggle="handleVueToggle">
      <p>This content lives happily within a Vue component.</p>
    </CollapsiblePanelVue>
    <button @click="toggleVuePanel">Toggle Vue Panel</button>
  </div>
</template>

<script>
import CollapsiblePanelVue from './CollapsiblePanelVue.vue';
export default {
  components: {
    CollapsiblePanelVue,
  },
  data() {
    return {
      isVuePanelOpen: true,
    };
  },
  methods: {
    handleVueToggle(open) {
      this.isVuePanelOpen = open;
      console.log(`Vue panel is now ${open ? 'open' : 'closed'}`);
    },
    toggleVuePanel() {
      this.isVuePanelOpen = !this.isVuePanelOpen;
    }
  },
};
</script>
Enter fullscreen mode Exit fullscreen mode

The Advantages of PWC

The Progressive Web Component pattern, leveraging native APIs, offers advantages such as:

  • True Interoperability: Your core UI component is truly framework-agnostic. It’s a standard browser element, guaranteeing maximum reusability across diverse tech stacks.
  • Reduced Development Overhead: Instead of developing and maintaining multiple versions of the same component for different frameworks, you maintain one core. The framework wrappers are thin layers, primarily focused on prop/event synchronization.
  • Future-Proofing: Native Web Components are part of the web platform. They are not dependent on a specific framework’s lifecycle or popularity, ensuring your components remain functional and relevant for long-term.
  • Flexible Integration: Teams can choose to use the native Web Component directly or opt for the framework-enhanced version, providing flexibility without sacrificing quality.

Potential Considerations and Challenges

While Progressive Web Components offer great advantages, it’s also important to acknowledge some potential considerations and challenges. Integrating Web Components into existing framework ecosystems, especially older ones, might sometimes introduce minor friction. For instance, some frameworks might require specific configurations to correctly pass complex data types (like objects or arrays) as attributes, or to handle custom events in their own idiomatic way. There’s also a learning curve for teams unfamiliar with native Web Component APIs, which can differ from framework-specific component paradigms. Furthermore, the tooling and development experience for native Web Components, while rapidly improving, might not always be as mature or feature-rich as those tailored for established frameworks. Developers might need to invest more time in setting up development servers, linting, and testing environments compared to a fully integrated framework CLI. These aspects are often manageable with careful planning and good documentation, but they are worth considering during adoption.

In today’s AI revolution, which includes AI generated code, it is easier and faster to build native Web Components and as a result these challenges become minor and less frightening.

Moving Beyond Framework Silos

In an ecosystem brimming with choices, achieving UI consistency and developer efficiency across projects can seem like an uphill battle. Progressive Web Components, built on the solid foundation of native browser APIs, offer a clear path forward. They empower us to design and build components once, knowing they can adapt and integrate smoothly into any environment. This approach allows us to transcend framework-specific silos and build a more cohesive and sustainable web experience.

Top comments (0)