When I started learning more about web development, or more specifically about front-end frameworks, I thought writing components was so much better and more maintainable than calling .innerHTML() whenever you need to perform DOM operations. JSX felt like a great way to mix HTML, CSS, and JS in a single file, but I wanted a more vanilla JavaScript solution instead of having to install a JSX framework like React or Solid.
So I’ve decided to go with lit-html for writing my own components.
Why not use the entire lit package instead of just lit-html?
Honestly, I believe something like lit-html should be a part of vanilla JavaScript (maybe someday?). So by using lit-html, I basically pretend like it is already. It’s my go-to solution when I want to write HTML in JavaScript. For more solid reasons, you can refer to the following list:
- Size difference. This often does not really matter for most projects anyway.)
- LitElement creates a shadow DOM by default. I don’t want to use the shadow DOM when creating my own components. I prefer to allow styling solutions like Tailwind to work instead of having to rely on solutions like CSS shadow parts to style my components. The light DOM can be nice.
import { html, render } from "lit-html"is all you need to get started to write lit-html templates whereas Lit requires you to learn about decorators to use most of its features. Sometimes you may want to use Lit directives if you need performant renders but it’s not necessary to make lit-html work on your project.
I will be showing two examples with what I consider to be two distinct methods to create a lit-html custom element. The first example will use what I call a “stateless render” because there won’t be any state parameters passed into the lit-html template. Usually this kind of component will only call the render method once during its lifecycle since there is no state to update. The second example will use a “stateful render” which calls the render function every time a state parameter changes.
Stateless Render
For my first example, the custom-element is a <textarea> wrapper that also has a status bar similar to Notepad++ that shows the length and lines of the content inside the <textarea>. The status bar will also display the position of the cursor and span of the selection if any characters are selected. Here is a picture of what it looks like for those readers that haven’t used Notepad++ before.

I used a library called TLN (“Textarea with Line Numbers”) to make the aesthetic of the textarea feel more like Notepad++, similar to the library’s official demo. Since the base template has no state parameters, I’m using plain old JavaScript events to manually modify the DOM in response to changes within the textarea. I also used the render function again to display the updated status bar contents instead of user .innerHTML() to keep it consistent with the surrounding code.
Using lit-html to render stateless components like these is useful, but perhaps not taking full advantage of the power of lit-html. According to the official documentation:
When you call render, lit-html only updates the parts of the template that have changed since the last render. This makes lit-html updates very fast.
You may ask: “Why should you use lit-html in examples like this where it won’t make that much of a difference performance wise? Since the root render function is really only called once (or once every connectedCallback()) in the custom elements lifecycle.”
My answer is that, yes, it’s not necessary if you just want rendering to the DOM to be fast. The main reason I use lit-html is that the syntax is so much nicer to me compared to setting HTML to raw strings. With vanilla JavaScript, you have to perform .createElement(), .append(), and .addEventListener() to create deeply nested HTML structures. Calling .innerHTML() = `<large html structure></>` is much better, but you still need to perform .querySelector() to lookup the newly created HTML and add event listeners to it.
The @event syntax makes it much more clear where the event listener is located compared to the rest of the template. For example…
class MyElement extends LitElement {
...
render() {
return html`
<p><button @click="${this._doSomething}">Click Me!</button></p>
`;
}
_doSomething(e) {
console.log("something");
}
}Code language: JavaScript (javascript)
It also makes it much more apparent to me on first glance that event.currentTarget can only be the HTMLElement where you attached the listener and event.target can be the same but also may come from any child of the said HTMLElement. The template also calls .removeEventListener() on its own when the template is removed from the DOM so that’s also one less thing to worry about.
The Status Bar Area
Before I continue explaining the change events that make the status bar work, I would like to highlight one of the drawbacks of the “stateless render”: there isn’t really a neat way to render the initial state of HTML elements. I could add placeholder content for when the input is empty and no selection was made yet, but the render() function only appends the template to the given root. It doesn’t delete siblings within the root so the status bar text would end up being doubled. This could be fixed if I call an initial render somewhere in the custom element, similar to the render calls within the event listeners, but I’ve opted to omit that to keep the example simple.
The input change event is one of the more common change events. It’s straightforward to see that this will be the change event used to calculate and display the updated input length and the number of newlines that the input has.
I thought I would have a much harder time displaying the live status of selected text, but the selectionchange event provides everything I need to calculate the selection status within the textarea. This change event is relatively new too, having only been a part of baseline last September 2024.
Since I’ve already highlighted the two main events driving the status bar, I’ll proceed to the next example.
Stateful Render
My second example is a <pokemon-card> custom-element. The pokemon card component will generate a random Pokémon from a specific pokemon TCG set. The specifications of the web component are as follows:
- The placeholder will be this generic pokemon card back.
- A Generate button that adds a new Pokémon card from the TCG set.
- Left and right arrow buttons for navigation.
- Text that shows the name and page of the currently displayed Pokémon.
In this example, only two other external libraries were used for the web component that weren’t related to lit and lit-html. I used shuffle from es-toolkit to make sure the array of cards is in a random order each time the component is instantiated. Though the shuffle function itself is likely small enough that you could just write your own implementation in the same file if you want to minimize dependencies.
I also wanted to mention es-toolkit in this article for readers that haven’t heard about it yet. I think it has a lot of useful utility functions so I included it in my example. According to their introduction, “es-toolkit is a modern JavaScript utility library that offers a collection of powerful functions for everyday use.” It’s a modern alternative to lodash, which used to be a staple utility library in every JavaScript project especially during the times before ES6 was released.
There are many ways to implement a random number generator or how to randomly choose an item from a list. I decided to just create a list of all possible choices, shuffle it, then use the pop method so that it’s guaranteed no card will get generated twice. The es-toolkit shuffle type documentation states that it “randomizes the order of elements in an array using the Fisher-Yates algorithm”.
Handling State using Signals
Vanilla JavaScript doesn’t come with a state management solution. While LitElement’s property and state decorators do count as solutions, I want to utilize a solution that I consider should be a part of Vanilla JavaScript just as with lit-html. The state management solution for the component will be JavaScript Signals. Unlike lit-html, signals are already a Stage 1 Proposal so there is a slightly better chance it will become a standard part of the JavaScript specification within the next few years.
As you can see from the Stage 1 Proposal, explaining JavaScript Signals from scratch can be very long that it might as well be its own multi-part article series so I will just give a rundown on how I used it in the <pokemon-card> custom-element. If you’re interested in a quick explanation of what signals are, the creator of SolidJS, which is a popular framework that uses signals, explains their thoughts here.
Signals need an effect implementation to work which is not a part of the proposed signal API, since according to the proposal, it ties into “framework-specific state or strategies which JS does not have access to”. I will be copy and pasting the watcher code in the example despite the comments recommending otherwise. My components are also too basic for any performance related issues to happen anyways. I also used the @lit-labs/signals to keep the component “lit themed” but you can also just use the recommended signal-polyfill directly too.
Signal Syntax
The syntax I used to create a signal state in my custom HTMLElement are as follows:
#visibleIndex = new Signal.State(0)
get visibleIndex() {
return this.#visibleIndex.get()
}
set visibleIndex(value: number) {
this.#visibleIndex.set(value)
}Code language: JavaScript (javascript)
There is a much more concise way to define the above example which involves auto accessors and decorators. Unfortunately, CodePen only supports TypeScript 4.1.3 as of writing, so I’ve opted to just use long-hand syntax in the example. An example of the accessor syntax involving signals is also shown in the signal-polyfill proposal.
Card Component Extras
The Intersection Observer API was used to allow the user to navigate the card component via horizontal scroll bar while also properly updating the state of the current page being displayed.
There is also a keydown event handler present to also let the user navigate between the cards via keyboard presses. Depending on the key being pressed, it calls either the handlePrev() or handleNext() method to perform the navigation.
Finally, while entirely optional, I also added a feature to the component that will preload the next card in JavaScript to improve loading times between generating new cards.
True. But of course I had to use CodePen 2.0, which has a much more modern TypeScript, to get stuff working with auto accessors and decorators.
https://codepen.io/editor/chriscoyier/pen/019b6ac4-cd0c-74a6-ae06-6aee8750c99f