Search

Building a Design System and Consuming it in Multiple Frameworks using Stencil.js

Nathan Bennett

Richard Flosi

9 min read

Mar 10, 2022

Building a Design System and Consuming it in Multiple Frameworks using Stencil.js

Large organizations with multiple software development departments may find themselves supporting multiple web frameworks across the organization. This can make it challenging to keep the brand consistent across applications as each application is likely implementing its own solution to the brand. What if we could create a design system that could be used across multiple frameworks? Other organizations may find themselves implementing similar components in various brands for various clients. What if the design system could also be white-labeled?

This blog post will be exploring these questions by building a design system with Stencil.js. The design system will support white-labeling via CSS Variables. Then the design system will be used without a framework, and with the ReactVueSvelte, and Stencil frameworks.

Stencil is a compiler for web components that can build custom elements for use across multiple frameworks. The compatibility of custom elements with various frameworks is tracked by Custom Elements Everywhere.

The code for this blog can be found on GitHub at https://github.com/BNR-Developer-Sandbox/BNR-blog-stencil-design-system.

Building the Components

Each component encapsulates its own CSS and functionality. Developers wanting to publish a web component library can follow the Getting started guide for Stencil. The Stencil API provides decorators and lifecycle hooks to reduce boilerplate code and define implementation patterns.

Stencil Decorators

The Stencil API provides a number of decorators that are removed at compile time.

Each Stencil Component uses the @Component() decorator to declare a new web component defining the tag, styleUrl, and if the Shadow DOM should be used or not.

@Component({
  tag: "ds-form",
  styleUrl: "ds-form.css",
  shadow: true,
})

Lifecycle Hooks

The Stencil API also provides various lifecycle hooks. The only lifecycle hook used in this codebase is the render() method which uses JSX to return a tree of components to render at runtime.

Application Shell

The ds-shell component provides a header, main, and footer section available via slots. The header and footer slots are named slots, while the main content area uses the default unnamed slot. This component provides the general layout for an application.

import { Component, Host, h } from "@stencil/core";

@Component({
  tag: "ds-shell",
  styleUrl: "ds-shell.css",
  shadow: true,
})
export class DsShell {
  render() {
    return (
      <Host>
        <header>
          <slot name="header"></slot>
        </header>
        <main>
          <slot></slot>
        </main>
        <footer>
          <slot name="footer"></slot>
        </footer>
      </Host>
    );
  }
}

Hero

The ds-hero component provides a default slot container and encapsulates the CSS for the component.

import { Component, Host, h } from "@stencil/core";

@Component({
  tag: "ds-hero",
  styleUrl: "ds-hero.css",
  shadow: true,
})
export class DsHero {
  render() {
    return (
      <Host>
        <slot></slot>
      </Host>
    );
  }
}

Form

The ds-form component provides generic form handling by listening for inputchange, and click events that bubble up to the form element. The component uses the @Element() decorator to declare a reference to the host element in order to dispatch the formdata event from the host element when the form is submitted. The component also uses the @State() decorator to declare the internal state of the form data.

import { Component, Element, Host, State, h } from "@stencil/core";

@Component({
  tag: "ds-form",
  styleUrl: "ds-form.css",
  shadow: true,
})
export class DsForm {
  @Element() el: HTMLElement;
  @State() data: any = {};
  onInput(event) {
    const { name, value } = event.target;
    this.data[name] = value;
  }
  onChange(event) {
    const { name, value } = event.target;
    this.data[name] = value;
  }
  onClick(event) {
    if (event?.target?.type === "submit") {
      const formData = new FormData();
      Object.entries(this.data).forEach(([key, value]) => {
        formData.append(key, String(value));
      });
      const formDataEvent = new FormDataEvent("formdata", { formData });
      this.el.dispatchEvent(formDataEvent);
    }
  }
  render() {
    return (
      <Host>
        <form
          // form event
          onInput={(event) => this.onInput(event)}
          onChange={(event) => this.onChange(event)}
          onClick={(event) => this.onClick(event)}
        >
          <slot></slot>
        </form>
      </Host>
    );
  }
}

White-labeling the components

In order to implement white-labeling, CSS Variables are used in the components which can be set by the design system consumer. The example repository only sets three variables to illustrate the idea; a complete design system would likely include more variables including sizes, fonts, etc.

Each application defines CSS Variables to implement a different brand.

:root {
  /* CSS Variables for theming white-labeled components */
  --primary-color: red;
  --secondary-color: gold;
  --tertiary-color: green;
}

Each application also defines a simple CSS Reset.

body {
  margin: 0;
  background-color: var(--tertiary-color);
}

Consuming the components

While consuming the custom elements the same HTML structure was used in each framework. Each example application implemented in each framework composes the ds-shell component for application layout, with a ds-hero to provide a call-to-action content area, and a unique form and form handler to process the form.

<ds-shell>
  <h1 slot="header">App Name</h1>
  <ds-hero>
    <ds-form>
      <label>
        Your Name:
        <br />
        <input type="text" name="name" />
      </label>
      <br />
      <label>
        Your Expertise:
        <br />
        <input type="text" name="expertise" />
      </label>
      <br />
      <input type="submit" value="Say Hello" />
    </ds-form>
  </ds-hero>
  <span slot="footer">
    <span>1</span>
    <span>2</span>
    <span>3</span>
  </span>
</ds-shell>

Event binding

The main difference between frameworks was binding the formdata event. Each framework has a slightly different syntax for declaring event handlers. The following examples demonstrate the event binding syntax for each framework.

Without a Framework

The Design System includes an implementation of the application without a framework in index.html. CSS Variables are defined in a <style> tag in the <head> tag. The event listener is added in a <script> tag at the end of the <body> tag.

<!DOCTYPE html>
<html dir="ltr" lang="en">
  <head>
    <meta charset="utf-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=5.0"
    />
    <title>Design System</title>

    <script type="module" src="/build/design-system.esm.js"></script>
    <script nomodule src="/build/design-system.js"></script>
    <style type="text/css">
      :root {
        /* CSS Variables for theming white-labeled components */
        --primary-color: red;
        --secondary-color: gold;
        --tertiary-color: green;
      }

      body {
        margin: 0;
        background-color: var(--tertiary-color);
      }
    </style>
  </head>
  <body>
    <ds-shell>
      <h1 slot="header">App Name</h1>
      <ds-hero>
        <ds-form>
          <label>
            Your Name:
            <br />
            <input type="text" name="name" />
          </label>
          <br />
          <label>
            Your Expertise:
            <br />
            <input type="text" name="expertise" />
          </label>
          <br />
          <input type="submit" value="Say Hello" />
        </ds-form>
      </ds-hero>
      <span slot="footer">
        <span>1</span>
        <span>2</span>
        <span>3</span>
      </span>
    </ds-shell>

    <script>
      function handleFormData(event) {
        event.stopPropagation();
        const { formData } = event;
        const data = Object.fromEntries(formData);
        const { name, expertise } = data;
        alert(`Hello ${name}, I hear you are good at ${expertise}.`);
      }

      const form = document.getElementsByTagName("ds-form")[0];
      form.addEventListener("formdata", (event) => handleFormData(event));
    </script>
  </body>
</html>

In order to add the event listener without a framework the native addEventListener method is used.

form.addEventListener("formdata", (event) => handleFormData(event));

React

The React application is implemented in App.js with CSS Variables in index.css.

import "design-system/ds-shell";
import "design-system/ds-hero";
import "design-system/ds-form";

function handleFormData(event) {
  event.stopPropagation();
  const { formData } = event;
  const data = Object.fromEntries(formData);
  const { reaction } = data;
  alert(`I am surprised that you reacted ${reaction}.`);
}

function App() {
  return (
    <ds-shell>
      <h1 slot="header">React App</h1>
      <ds-hero>
        <ds-form onformdata={(event) => handleFormData(event)}>
          <label>
            How did you react?:
            <br />
            <input type="text" name="reaction" />
          </label>
          <br />
          <input type="submit" value="What is your reaction?" />
        </ds-form>
      </ds-hero>
      <span slot="footer">
        <span>1</span>
        <span>2</span>
        <span>3</span>
      </span>
    </ds-shell>
  );
}

export default App;

React uses a lowercase onformdata attribute in JSX to bind the formdata event to a handler.

<ds-form onformdata={(event) => handleFormData(event)}>
  <!-- ... -->
</ds-form>

Vue

The Vue application is implemented in App.vue with CSS Variables in base.css.

<script setup>
  import "design-system/ds-shell";
  import "design-system/ds-hero";
  import "design-system/ds-form";

  function handleFormData(event) {
    event.stopPropagation();
    const { formData } = event;
    const data = Object.fromEntries(formData);
    const { view } = data;
    alert(`${view}!?! Wow! What a view!`);
  }
</script>

<template>
  <ds-shell>
    <h1 slot="header">Vue App</h1>
    <ds-hero>
      <ds-form @formdata="handleFormData">
        <label>
          How's the view?:
          <br />
          <input type="text" name="view" />
        </label>
        <br />
        <input type="submit" value="Looking good?" />
      </ds-form>
    </ds-hero>
    <span slot="footer">
      <span>1</span>
      <span>2</span>
      <span>3</span>
    </span>
  </ds-shell>
</template>

<style>
  @import "@/assets/base.css";
</style>

Vue uses a lowercase @formdata attribute set to the function name to bind the formdata event to a handler.

<ds-form onformdata={(event) => handleFormData(event)}>
  <!-- ... -->
</ds-form>

Svelte

The Svelte application defines application variables and the form handler in main.js.

import App from "./App.svelte";

const app = new App({
  target: document.body,
  props: {
    appName: "Svelte App",
    fieldLabel: "Your Location",
    fieldName: "location",
    submitLabel: "Where you at?",
    handleFormData: (event) => {
      event.stopPropagation();
      const { formData } = event;
      const data = Object.fromEntries(formData);
      const { location } = data;
      alert(`Where's ${location}?`);
    },
  },
});

export default app;

App.svelte uses the variables from main.js to render HTML and bind events.

<script>
  export let appName, fieldLabel, fieldName, submitLabel, handleFormData;
  import "design-system/ds-shell";
  import "design-system/ds-hero";
  import "design-system/ds-form";
</script>

<ds-shell>
  <h1 slot="header">{appName}</h1>
  <ds-hero>
    <ds-form on:formdata="{handleFormData}">
      <label>
        {fieldLabel}:
        <br />
        <input type="text" name="{fieldName}" />
      </label>
      <br />
      <input type="submit" value="{submitLabel}" />
    </ds-form>
  </ds-hero>
  <span slot="footer">
    <span>1</span>
    <span>2</span>
    <span>3</span>
  </span>
</ds-shell>

The CSS Variables are defined in global.css.

Svelte uses a lowercase on:formdata attribute to bind the formdata event to a handler.

<ds-form on:formdata="{handleFormData}">
  <!-- ... -->
</ds-form>

Stencil

The Stencil application is implemented in app-root.tsx with CSS Variables in app.css.

import { Component, h } from "@stencil/core";
import "design-system/ds-shell";
import "design-system/ds-hero";
import "design-system/ds-form";

@Component({
  tag: "app-root",
  styleUrl: "app-root.css",
  shadow: true,
})
export class AppRoot {
  handleFormData(event) {
    event.stopPropagation();
    const { formData } = event;
    const data = Object.fromEntries(formData);
    const { expertise } = data;
    alert(`So you are good with ${expertise}...`);
  }
  render() {
    return (
      <ds-shell>
        <h1 slot="header">Stencil App</h1>
        <ds-hero>
          <ds-form onFormData={(event) => this.handleFormData(event)}>
            <label>
              Your Expertise:
              <br />
              <input type="text" name="expertise" />
            </label>
            <br />
            <input type="submit" value="Say Something" />
          </ds-form>
        </ds-hero>
        <span slot="footer">
          <span>1</span>
          <span>2</span>
          <span>3</span>
        </span>
      </ds-shell>
    );
  }
}

Stencil uses a camel case onFormData attribute to bind the formdata event to a handler.

<ds-form onFormData={(event) => this.handleFormData(event)}>
  <!-- -->
</ds-form>

Conclusions

Building web components allows developers to reuse UI elements in multiple frameworks. Starting with a design system, developers are able to develop a basic app shell in React, Vue, Svelte, and Stencil. The compatibility within frameworks is tracked by Custom Elements Everywhere. The application for each framework had different ways to handle events but each project handled imports, html, and CSS similarly. A properly configured Stencil project can be used to publish a component library that is consumed by developers across multiple frameworks.

Event Bindings

The main difference is how events are bound to handlers in each framework.

Without a framework use addEventListener(). In React use onformdata to listen for formdata events. In Vue use the @formdata shorthand or v-on:formdata to listen for events. In Svelte use on:formdata to declare a handler for formdata events. In Stencil use onFormData in order to handle events published by components.

Richard Flosi

Author Big Nerd Ranch
Richard Flosi brings 20+ years of web development experience to Big Nerd Ranch. Richard is passionate about using full-stack JavaScript serverless architecture to build scalable long-term solutions.
Speak with a Nerd

Schedule a call today! Our team of Nerds are ready to help

Let's Talk

Related Posts

We are ready to discuss your needs.

Not applicable? Click here to schedule a call.

Stay in Touch WITH Big Nerd Ranch News