Ever since the first release of web components I've been interested in them. A way to build components that are isolated from each other without needing a bulky library or framework sounded amazing. Unfortunately, they tend to be hard to work with and browser support was slow to come.
Lit is a small library, built on top of web components, that makes it much easier to build interoperable web components. The team released Lit version 3 last year, and I just got around to trying it out. I'm impressed with what Lit is capable of. There are a few things missing that I'd want to see before building out a major application, like a router API, but it seems like a great fit if you need to build some web components to drop into an existing site. It might even be nice for a framework agnostic design system.
Let's take a look at a simple project. In this case I'll make a very simple to-do list application.
Start the Project
Lately, when it comes to starting a new frontend project Vite has been my go-to tool. They have starting templates that fit for most of the libraries I would use, and it "just works".
To start out we need to generate our project, I'm choosing the Lit TypeScript template:
npm create vite@latest my-lit-app -- --template lit-ts
That should give you a directory tree similar to what I have below:
my-lit-app
├── public
│ └── vite.svg
├── src
│ ├── my-element.ts
│ ├── index.css
│ ├── vite-env.d.ts
│ └── assets
│ └── lit.svg
├── .gitignore
├── index.html
├── package.json
└── tsconfig.json
From there you will need to install your NPM dependencies, then we can start the application to see what we have.
# Install NPM dependencies
npm install
# Start the application in development mode
npm run dev
If you open the URL provided, this is what your page should look like.
Building a Component
To keep things simple, let's replace the component the template provided with one of our own. I've replaced the contents of src/my-element.ts
with what I have below. This is a very simple boilerplate that will still build and render successfully, serving as a foundation.
import { LitElement, css, html } from 'lit'
import { customElement, property } from 'lit/decorators.js'
@customElement('my-element')
export class MyElement extends LitElement {
static styles = css``
render() {
return html`
<div></div>
`
}
}
declare global {
interface HTMLElementTagNameMap {
'my-element': MyElement
}
}
Expanding from here, I want to get my HTML elements onto the page. After all, that's what is going to let us see progress and start adding functionality. Inside the render
method you'll notice a template literal, specifically a tagged template named html
, is being used to return the HTML the component will render. I'll add a form with a text input field and a button, along with an empty unordered list element. This special tagged template is part of what Lit provides to make building web components easier. It's important to note, this isn't JSX. It's HTML. You don't have the same restrictions, like needing a single parent element.
// ...
render() {
return html`
<form>
<input type="text" name="todoItem" />
<button>Add Item</button>
</form>
<ul></ul>
`;
}
// ...
Now, we need a place to put the items for our to-do list and a way to display them on the page. To do that we will use the @state
directive. This directive lets us add a private or protected property that still triggers updates to the component. Our array is going to contain objects, so one thing we need to do is help Lit with determining if our value has changed. This is because a new object, even if it has the exact same properties, will not be considered equal when using the ===
operator. One easy way to make this comparison is to convert our values to JSON strings, and then compare them. That's what I have done below with the hasChanged
function.
This code can be added at the top of our class near the style
property.
// ...
@state({
hasChanged: (value?, oldValue?) => {
return JSON.stringify(value) !== JSON.stringify(oldValue);
},
})
private todoItems: { id: number; complete: boolean; value: string }[] = [
{id: 1, complete: false, value: 'Item 1'},
{id: 2, complete: false, value: 'Item 2'},
];
// ...
Now that we have items for our list, we need to update our HTML to display them. Lit has something to help make sure we keep DOM updates to the minimum, it's called the repeat
directive. We pass our array of items, a function that returns an item's id, and a function that renders an item to the directive and it returns the HTML for our list. When our array of items changes, it will help minimize the number of updates. This behavior is something you have to purposefully use, but it's there for the same reason the key
attribute is in JSX/React. You may notice the odd ?checked=${...}
notation below. That is how Lit handles boolean attributes.
render() {
return html`
<form>
<input type="text" name="todoItem" />
<button>Add Item</button>
</form>
<ul>
${repeat(
this.todoItems,
(item) => item.id,
({ id, complete, value }) =>
html`<li>
<input type="checkbox" ?checked=${complete} /> ${value}
</li>`,
)}
</ul>
`;
}
We can see our list now, but we can't add new items and we aren't saving the checked state of our items.
Making it Interactive
Let's start with adding new items to our to-do list. First, use the @query
decorator to make it easy to access our text input element. We pass the decorator the CSS selector for the element we want to find. Then we are able to access the element through the variable the decorator is applied to. That will make it easy for us to get the input element's value. Next, we'll need to create a private handler method to update our to-do list with the new item. Similar to how you would do it with React, I am defining a new array and assigning it to the variable. To create a unique ID value I am getting the current unix epoch and adding a random number. The epoch number is probably good enough, but a little randomness isn't going to hurt. At the end, after adding the item to the list, I am clearing the value of the text input and setting focus to the input element.
You might notice the e.preventDefault();
line. I'm using the submit
event here, and that line will prevent the default behavior when submitting a form. That is, to make an HTTP request to the page defined in the action
property, or the current page if that is undefined. We don't need that behavior so do disabling it is the way to do. We could avoid it with a click event, but by using the submit event a user typing something in and pressing enter works exactly as you'd expect with no additional effort.
On the HTML form tag you can see a new attribute that attaches the submit event listener to our form. The @submit
syntax is how you attach the listener to a given element. Next, we'll be attaching a click event for our checkboxes.
// ...
@query('input[name="todoItem"]')
private addItemInputEl!: HTMLInputElement;
private _handleAddItem(e: SubmitEvent) {
e.preventDefault();
this.todoItems = [
...this.todoItems,
{
id: Math.floor(Date.now() + Math.random() * 1000),
complete: false,
value: this.addItemInputEl.value,
},
];
this.addItemInputEl.value = '';
this.addItemInputEl.focus();
}
// ...
render() {
return html`
<form @submit=${this._handleAddItem}>
// ...
`;
}
// ...
We'll need to create a private handler method for the click event we'll be generating. We'll take advantage of event bubbling so that clicking on the checkbox and the to-do item itself both cause our checkbox to be checked. To help with identifying the correct item, we'll add a data attribute that contains our item's ID. Our handler needs to determine the item ID to search for and update. The currentTarget
property of the event will contain a reference to our list item element, since that is where the event handler is attached. From there, it's a matter of creating a new array with the modified to-do item with the complete
value set to the opposite of what it is currently. This ensures we can both check and uncheck an item by clicking on it.
private _handleItemClick(e: PointerEvent) {
const el: HTMLInputElement = e.currentTarget as HTMLInputElement;
this.todoItems = [
...this.todoItems.map((item) => {
if (item.id.toString() === el.dataset.id) {
return {
...item,
complete: !item.complete,
};
}
return item;
}),
];
}
render() {
return html`
// ...
html`<li data-id="${id}" @click=${this._handleItemClick}>
<input type="checkbox" ?checked=${complete} /> ${value}
</li>`,
)}
// ...
`;
}
Our to-do list is working now, but it's not obvious you can click on an item to toggle the checkbox. With just a little CSS we can change that. Update the string inside the css
tagged template and then your mouse cursor will change to a pointer anytime you are hovering over one of the to-do items.
static styles = css`
ul li {
cursor: pointer;
}
`;
One of the nice things about web components and Lit, is this CSS is scoped to our component. So it won't affect anything else on our page. With a few more CSS changes we can have this to-do list looking even better.
Below is the full component. With some additional CSS changes to the index.css
file we can have the application looking even better. Our to-do list disappears if you refresh the page, but I'm not going to go into what it would take to save and restore that from localStorage
, that is an exercise I'll leave up to you.
Lit is a fast way to develop web components. This example results in a JS bundle that is about 22kB, which could be smaller but also isn't horrible. Lit has good documentation, and makes it easy to get started. There are still some features I'd like to see, but a lot of them are already in the work. What do you think? Do you like Lit? Do you want me to create the same application in vanilla web components to see what the difference in difficulty and bundle size really is? Let me know on social media.
import { LitElement, css, html } from 'lit';
import { customElement, query, state } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
@customElement('my-element')
export class MyElement extends LitElement {
static styles = css`
ul {
list-style: none;
padding-left: 0;
}
ul li {
cursor: pointer;
}
ul li input[type='checkbox'] {
margin-right: 0.5em;
}
`;
@state({
hasChanged: (value?, oldValue?) => {
return JSON.stringify(value) !== JSON.stringify(oldValue);
},
})
private todoItems: { id: number; complete: boolean; value: string }[] = [
{ id: 1, complete: false, value: 'Item 1' },
{ id: 2, complete: false, value: 'Item 2' },
];
@query('input[name="todoItem"]')
private addItemInputEl!: HTMLInputElement;
private _handleAddItem(e: SubmitEvent) {
e.preventDefault();
this.todoItems = [
...this.todoItems,
{
id: Math.floor(Date.now() + Math.random() * 1000),
complete: false,
value: this.addItemInputEl.value,
},
];
this.addItemInputEl.value = '';
this.addItemInputEl.focus();
}
private _handleItemClick(e: PointerEvent) {
const el: HTMLInputElement = e.currentTarget as HTMLInputElement;
this.todoItems = [
...this.todoItems.map((item) => {
if (item.id.toString() === el.dataset.id) {
return {
...item,
complete: !item.complete,
};
}
return item;
}),
];
}
render() {
return html`
<form @submit=${this._handleAddItem}>
<input type="text" name="todoItem" />
<button>Add Item</button>
</form>
<ul>
${repeat(
this.todoItems,
(item) => item.id,
({ id, complete, value }) =>
html`<li data-id="${id}" @click=${this._handleItemClick}>
<input type="checkbox" ?checked=${complete} /> ${value}
</li>`,
)}
</ul>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'my-element': MyElement
}
}
Top comments (0)