Build Cross Platform Components with Stencil and Capacitor
Introduction
The overwhelming majority of design systems that exist today are built for the web. While this is great for web developers, the web is not the only platform that people use. It doesn’t take a 10x developer to see that mobile apps are just as common as web applications. Yet, so many design systems seem to be built without the mobile experience in mind. In this tutorial, we’ll break out of this web centric mindset and see how we can build components that are suitable for the web and mobile. If you’re worried that we’ll have to use Swift or Java or Kotlin, fear not. We’re going to build cross platform components solely with web technologies using Stencil and Capacitor.
Stencil is a web components compiler that enables the components we write to be used with multiple different frontend frameworks. Capacitor is a cross-platform native runtime that enables the components we write to be used with multiple different platforms. Together, they can help us build cross-framework, cross-platform design systems that can be used in all kinds of projects across an entire organization.
In this tutorial, we’re going to be building an avatar component. This component, when clicked, will allow the user to change their avatar. On the web, the user will be able to select a new photo from the file system. On mobile, the user will be able to choose a photo from their photo gallery or take a new picture with the device camera. Here’s what it will look like.
You can find all of the code for this tutorial in the stencil-capacitor-avatar-component github repo. If you prefer to watch a video tutorial, I recently created a video where I walk through this entire process. Check it out!
NOTE: This tutorial assumes that you already have a Stencil project created. To learn how to create a new Stencil project, check out the getting started docs.
Adding Capacitor to our Project
Before we get started creating our avatar component, we’ll first need to add Capacitor to our Stencil project. Start by installing Capacitor core and the Capacitor CLI.
npm install @capacitor/core
npm install @capacitor/cli --save-dev
After that, initialize Capacitor with
npx cap init
You’ll be prompted to provide an app name and ID here. Feel free to name your app whatever you would like.
The next steps are specific to the mobile platforms that you intend to support.
iOS
For our component to work with iOS, we will first need to install the @capacitor/ios
package
npm install @capacitor/ios
And then add the iOS platform
npx cap add ios
Android
Similarly, for our component to work with Android, we will need to install the @capacitor/android
package
npm install @capacitor/android
And then add the Android platform
npx cap add android
Scaffolding the Component
Alright, with Capacitor installed and configured, we can now get started building our avatar component. Let’s start by creating a new component called my-avatar
.
Our avatar component will have one prop called image
, which will be the url to the avatar image. We should give it a default value in case an image is not provided (in the case of a new user). Because the value for this image will change when the user selects a new avatar, we’ll pass the option { mutable: true }
to the @Prop
decorator. For the component template, the only element we need is an <img />
tag to display the avatar image. Our component should look like this
import { Component, Host, h, Prop } from '@stencil/core';
@Component({
tag: 'my-avatar',
styleUrl: 'my-avatar.css',
shadow: true,
})
export class MyAvatar {
@Prop({ mutable: true }) image: string = 'https://avatars.dicebear.com/api/micah/capacitor.svg';
render() {
return (
<Host>
<img src={this.image} alt="avatar" />
</Host>
);
}
}
In the my-avatar.css
file, we can add some styles to the img
to size and shape it a bit.
img {
height: 150px;
width: 150px;
border-radius: 99rem;
background-color: #f1f1f1;
object-fit: cover;
}
Using the Capacitor Camera Plugin
With the general structure of our component in place, it’s now time to start adding some functionality. To implement the ability to change the avatar image, we are going to leverage the Capacitor Camera Plugin. On mobile, this plugin provides the ability to take a photo with the device camera or to choose an existing one from the photo library. On the web, the plugin works just like an input
element with type file
. We can install the plugin with the following commands:
npm install @capacitor/camera
npx cap sync
Access to the device camera requires specific permissions to be configured. To learn how to configure those permissions for both iOS and Andriod, checkout the Capacitor Camera Plugin docs.
Getting a Photo
With the plugin installed and configured, we can now import the Camera
and CameraResultType
at the top of our avatar component.
import { Camera, CameraResultType } from '@capacitor/camera';
Now let’s write a function that will use the camera plugin to get a photo and set the component’s image.
handleClick = async () => {
const photo = await Camera.getPhoto({
quality: 90,
allowEditing: true,
resultType: CameraResultType.Uri,
});
this.image = photo.webPath;
};
The Camera.getPhoto()
function prompts the user to provide a photo from the photo library or to take a new photo with the camera. It accepts a series of options to further customize its behavior. You can learn more about those options in the API documentation.
At this point, we want to execute this function when our avatar image is clicked.
<img src={this.image} alt="avatar" onClick={this.handleClick} />
With this function defined and connected to the image, our avatar component should now work as expected! Let’s add the component to our index.html
file to see it in action.
<my-avatar></my-avatar>
We can then run our project three different ways:
Web: npm run start
iOS: npx cap open ios
and run with Xcode
Android: npx cap open android
and run with Android Studio
For an added bonus, I want to improve the component’s API by adding a custom event.
Improving the Component’s API
User’s of our avatar component will likely want to handle any change to the avatar photo. They may want to save it to a database for example. To improve the way consumers interact with our component, we can create a custom event that fires every time the avatar image changes. To do that, we first need to declare the event with the @Event()
decorator.
@Event() myChange: EventEmitter<String>;
I’m calling the event myChange
, but feel free to give the event a name specific to your design system. Next, we need to emit the event when the image is changed. We can do this in our handleClick
function.
handleClick = async () => {
const photo = await Camera.getPhoto({
quality: 90,
allowEditing: true,
resultType: CameraResultType.Uri,
});
this.image = photo.webPath;
this.myChange.emit(this.image);
};
Now users of our component can listen to when the image changes and perform the relevant actions when that event occurs.
<my-avatar></my-avatar>
<script>
document.querySelector('my-avatar').addEventListener('myChange', () => {
console.log('avatar image changed');
});
</script>
Cross Platform Design Systems
All in all, this avatar component is meant to showcase the flexibility of building components that are suitable for both the web and mobile. Building components with Capacitor and Stencil allows your design system to support multiple platforms and frameworks. With this strategy, you can even use your Stencil components in an existing native or React Native app with Portals. Design systems built in this way are so versatile that they can serve as the only design system that your company may need. With everyone pulling from the same design system, it is much easier to achieve consistency and feature parity across all of your applications.
As you play around with Capacitor in your Stencil projects, I encourage you to check out the documentation. There are tons of other great plugins to explore. I’m excited to see what kind of cross platform components you end up building!