{"id":8102,"date":"2025-12-29T09:11:35","date_gmt":"2025-12-29T14:11:35","guid":{"rendered":"https:\/\/frontendmasters.com\/blog\/?p=8102"},"modified":"2025-12-29T09:11:36","modified_gmt":"2025-12-29T14:11:36","slug":"custom-elements-with-lit-html","status":"publish","type":"post","link":"https:\/\/frontendmasters.com\/blog\/custom-elements-with-lit-html\/","title":{"rendered":"How I Write Custom Elements with lit-html"},"content":{"rendered":"\n<p>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 <code>.innerHTML()<\/code> whenever you need to perform DOM operations. <a href=\"https:\/\/en.wikipedia.org\/wiki\/JavaScript_XML\">JSX<\/a> 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.<\/p>\n\n\n\n<p>So I&#8217;ve decided to go with <a href=\"https:\/\/lit.dev\/docs\/libraries\/standalone-templates\/\">lit-html<\/a> for writing my own components.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Why not use the entire lit package instead of just lit-html?<\/h2>\n\n\n\n<p>Honestly, I believe something like lit-html should be a part of vanilla JavaScript (<a href=\"https:\/\/justinfagnani.com\/2025\/06\/30\/what-should-a-dom-templating-api-look-like\/\">maybe someday?<\/a>). So by using lit-html, I basically pretend like it is already. It&#8217;s my go-to solution when I want to write HTML in JavaScript. For more solid reasons, you can refer to the following list:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Size difference.<\/strong> This often does not really matter for most projects anyway.)\n<ul class=\"wp-block-list\">\n<li><a href=\"https:\/\/bundlephobia.com\/package\/lit-html@3.3.1\">lit-html<\/a> &#8211; 7.3 kb min, 3.1 kb min + gzip<\/li>\n\n\n\n<li><a href=\"https:\/\/bundlephobia.com\/package\/lit@3.3.1\">lit<\/a> &#8211; 15.8 kb min, 5.9 kb min + gzip<\/li>\n<\/ul>\n<\/li>\n\n\n\n<li><strong>LitElement creates a <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/Web_components\/Using_shadow_DOM\">shadow DOM<\/a> by default.<\/strong> I don\u2019t 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 <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/CSS\/CSS_shadow_parts\">CSS shadow parts<\/a> to style my components. <a href=\"https:\/\/frontendmasters.com\/blog\/light-dom-only\/\">The light DOM can be nice<\/a>.<\/li>\n\n\n\n<li><strong><code>import { html, render } from \"lit-html\"<\/code> is all you need<\/strong> to get started to write lit-html templates whereas Lit requires you to learn about <a href=\"https:\/\/lit.dev\/docs\/components\/decorators\/\">decorators<\/a> to use most of its features. Sometimes you may want to use Lit directives if you need performant renders but it\u2019s not necessary to make lit-html work on your project.<\/li>\n<\/ul>\n\n\n\n<p>I will be showing two examples with what I consider to be two distinct methods to create a lit-html <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/Web_components\/Using_custom_elements\">custom element<\/a>. The first example will use what I call a <strong>\u201cstateless render\u201d<\/strong> because there won\u2019t 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 <strong>\u201cstateful render\u201d<\/strong> which calls the render function every time a state parameter changes.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Stateless Render<\/h2>\n\n\n\n<p>For my first example, the custom-element is a <code>&lt;textarea><\/code> wrapper that also has a status bar similar to <a href=\"https:\/\/notepad-plus-plus.org\/\">Notepad++<\/a> that shows the length and lines of the content inside the <code>&lt;textarea><\/code>. The <strong>status bar<\/strong> 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\u2019t used Notepad++ before.<\/p>\n\n\n<div class=\"wp-block-image\">\n<figure class=\"aligncenter size-large is-resized\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"571\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/12\/image1-1.png?resize=1024%2C571&#038;ssl=1\" alt=\"A screenshot of a text editor displaying an excerpt about Lorem Ipsum, highlighting the text in yellow and showing line and character counts.\" class=\"wp-image-8108\" style=\"aspect-ratio:1.793357655997292;width:607px;height:auto\" srcset=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/12\/image1-1.png?resize=1024%2C571&amp;ssl=1 1024w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/12\/image1-1.png?resize=300%2C167&amp;ssl=1 300w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/12\/image1-1.png?resize=768%2C428&amp;ssl=1 768w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/12\/image1-1.png?w=1075&amp;ssl=1 1075w\" sizes=\"auto, (max-width: 1000px) 100vw, 1000px\" \/><\/figure>\n<\/div>\n\n\n<div class=\"wp-block-cp-codepen-gutenberg-embed-block cp_embed_wrapper\"><iframe id=\"cp_embed_raegrMQ\" src=\"\/\/codepen.io\/anon\/embed\/raegrMQ?height=750&amp;theme-id=1&amp;slug-hash=raegrMQ&amp;default-tab=result\" height=\"750\" scrolling=\"no\" frameborder=\"0\" allowfullscreen allowpaymentrequest name=\"CodePen Embed raegrMQ\" title=\"CodePen Embed raegrMQ\" class=\"cp_embed_iframe\" style=\"width:100%;overflow:hidden\">CodePen Embed Fallback<\/iframe><\/div>\n\n\n\n<p>I used a library called <a href=\"https:\/\/github.com\/MatheusAvellar\/textarea-line-numbers\">TLN<\/a> (&#8220;Textarea with Line Numbers&#8221;) to make the aesthetic of the textarea feel more like Notepad++, similar to the library\u2019s official <a href=\"https:\/\/lab.avl.la\/textarea-line-numbers\/demo.html\">demo<\/a>. Since the base template has no state parameters, I\u2019m 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 <code>.innerHTML()<\/code> to keep it consistent with the surrounding code.<\/p>\n\n\n\n<p>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 <a href=\"https:\/\/lit.dev\/docs\/libraries\/standalone-templates\/\">official documentation<\/a>:<\/p>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p>When you call render, <strong>lit-html only updates the parts of the template that have changed since the last render.<\/strong> This makes lit-html updates very fast.<\/p>\n<\/blockquote>\n\n\n\n<p>You may ask: <em>\u201cWhy should you use lit-html in examples like this where it won\u2019t make that much of a difference performance wise? Since the root render function is really only called once (or once every <code>connectedCallback()<\/code>) in the custom elements lifecycle.\u201d<\/em><\/p>\n\n\n\n<p>My answer is that, yes, it\u2019s not <em>necessary<\/em> 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 <code>.createElement()<\/code>, <code>.append()<\/code>, and <code>.addEventListener()<\/code> to create deeply nested HTML structures. Calling <code>.innerHTML() = `&lt;large html structure&gt;&lt;\/&gt;`<\/code> is much better, but you still need to perform <code>.querySelector()<\/code> to lookup the newly created HTML and add event listeners to it.<\/p>\n\n\n\n<p><a href=\"https:\/\/lit.dev\/docs\/components\/events\/\">The <code>@event<\/code> syntax<\/a> makes it much more clear where the event listener is located compared to the rest of the template. For example&#8230;<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-1\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-class\"><span class=\"hljs-keyword\">class<\/span> <span class=\"hljs-title\">MyElement<\/span> <span class=\"hljs-keyword\">extends<\/span> <span class=\"hljs-title\">LitElement<\/span> <\/span>{\n  ...\n  render() {\n    <span class=\"hljs-keyword\">return<\/span> html`<span class=\"xml\">\n      <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">p<\/span>&gt;<\/span><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">button<\/span> @<span class=\"hljs-attr\">click<\/span>=<span class=\"hljs-string\">\"<\/span><\/span><\/span><span class=\"hljs-subst\">${<span class=\"hljs-keyword\">this<\/span>._doSomething}<\/span><span class=\"xml\"><span class=\"hljs-tag\"><span class=\"hljs-string\">\"<\/span>&gt;<\/span>Click Me!<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">button<\/span>&gt;<\/span><span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">p<\/span>&gt;<\/span>\n    `<\/span>;\n  }\n  _doSomething(e) {\n    <span class=\"hljs-built_in\">console<\/span>.log(<span class=\"hljs-string\">\"something\"<\/span>);\n  }\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-1\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>It also makes it much more apparent to me on first glance that <code>event.currentTarget<\/code> can only be the HTMLElement where you attached the listener and <code>event.target<\/code> can be the same but also may come from any child of the said HTMLElement. The template also calls <code>.removeEventListener()<\/code> on its own when the template is removed from the DOM so that\u2019s also one less thing to worry about.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The Status Bar Area<\/h3>\n\n\n\n<p>Before I continue explaining the change events that make the status bar work, I would like to highlight one of the drawbacks of the \u201cstateless render\u201d: there isn\u2019t 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 <code>render()<\/code> function only appends the template to the given root. It doesn\u2019t 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\u2019ve opted to omit that to keep the example simple.<\/p>\n\n\n\n<p>The input change event is one of the more common change events. It\u2019s 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.<\/p>\n\n\n\n<p>I thought I would have a much harder time displaying the live status of selected text, but the <code>selectionchange<\/code> 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 <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/HTMLInputElement\/selectionchange_event\">September 2024<\/a>. <\/p>\n\n\n\n<p>Since I\u2019ve already highlighted the two main events driving the status bar, I\u2019ll proceed to the next example.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Stateful Render<\/h2>\n\n\n\n<p>My second example is a <code>&lt;pokemon-card&gt;<\/code> custom-element. The pokemon card component will generate a random Pok\u00e9mon from a specific pokemon TCG set. The specifications of the web component are as follows:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>The placeholder will be this <a href=\"https:\/\/upload.wikimedia.org\/wikipedia\/en\/3\/3b\/Pokemon_Trading_Card_Game_cardback.jpg\">generic pokemon card back<\/a>.<\/li>\n\n\n\n<li>A <strong>Generate<\/strong> button that adds a new Pok\u00e9mon card from the TCG set.<\/li>\n\n\n\n<li>Left and right arrow buttons for navigation.<\/li>\n\n\n\n<li>Text that shows the name and page of the currently displayed Pok\u00e9mon.<\/li>\n<\/ul>\n\n\n\n<div class=\"wp-block-cp-codepen-gutenberg-embed-block cp_embed_wrapper\"><iframe id=\"cp_embed_raegryo\" src=\"\/\/codepen.io\/anon\/embed\/raegryo?height=630&amp;theme-id=1&amp;slug-hash=raegryo&amp;default-tab=result\" height=\"630\" scrolling=\"no\" frameborder=\"0\" allowfullscreen allowpaymentrequest name=\"CodePen Embed raegryo\" title=\"CodePen Embed raegryo\" class=\"cp_embed_iframe\" style=\"width:100%;overflow:hidden\">CodePen Embed Fallback<\/iframe><\/div>\n\n\n\n<p>In this example, only two other external libraries were used for the web component that weren\u2019t related to lit and lit-html. I used <a href=\"https:\/\/es-toolkit.dev\/reference\/array\/shuffle.html\">shuffle<\/a> from <a href=\"https:\/\/es-toolkit.dev\">es-toolkit<\/a> 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.<\/p>\n\n\n\n<p>I also wanted to mention <a href=\"https:\/\/es-toolkit.dev\/\">es-toolkit<\/a> in this article for readers that haven\u2019t heard about it yet. I think it has a lot of useful utility functions so I included it in my example. According to their <a href=\"https:\/\/es-toolkit.dev\/intro.html\">introduction<\/a>, \u201ces-toolkit is a modern JavaScript utility library that offers a collection of powerful functions for everyday use.\u201d It\u2019s a modern alternative to lodash, which used to be a staple utility library in every JavaScript project especially during the times before <a href=\"https:\/\/262.ecma-international.org\/6.0\">ES6<\/a> was released.<\/p>\n\n\n\n<p>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\u2019s guaranteed no card will get generated twice. The es-toolkit shuffle type documentation states that it &#8220;randomizes the order of elements in an array using the <a href=\"https:\/\/en.wikipedia.org\/wiki\/Fisher%E2%80%93Yates_shuffle\">Fisher-Yates<\/a> algorithm&#8221;.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Handling State using Signals<\/h2>\n\n\n\n<p>Vanilla JavaScript doesn\u2019t come with a state management solution. While LitElement\u2019s <a href=\"https:\/\/lit.dev\/docs\/api\/decorators#property\">property<\/a> and <a href=\"https:\/\/lit.dev\/docs\/api\/decorators#state\">state<\/a> 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 <a href=\"https:\/\/signaldb.js.org\/signals\">JavaScript Signals<\/a>. Unlike lit-html, signals are already a <a href=\"https:\/\/github.com\/tc39\/proposal-signals\">Stage 1 Proposal<\/a> so there is a slightly better chance it will become a standard part of the JavaScript specification within the next few years.<\/p>\n\n\n\n<p>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 <code>&lt;pokemon-card&gt;<\/code> custom-element. If you\u2019re interested in a quick explanation of what signals are, the creator of SolidJS, which is a popular framework that uses signals, explains their thoughts <a href=\"https:\/\/www.youtube.com\/watch?v=l-0fKa0w4ps\">here<\/a>.<\/p>\n\n\n\n<p>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 \u201cframework-specific state or strategies which JS does not have access to\u201d. I will be copy and pasting the <a href=\"https:\/\/github.com\/tc39\/proposal-signals?tab=readme-ov-file#implementing-effects\">watcher code<\/a> 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 <a href=\"https:\/\/github.com\/lit\/lit\/tree\/main\/packages\/labs\/signals\">@lit-labs\/signals<\/a> to keep the component \u201clit themed\u201d but you can also just use the recommended <a href=\"https:\/\/github.com\/proposal-signals\/signal-polyfill\">signal-polyfill<\/a> directly too.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Signal Syntax<\/h3>\n\n\n\n<p>The syntax I used to create a signal state in my custom HTMLElement are as follows:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-2\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\">#visibleIndex = <span class=\"hljs-keyword\">new<\/span> Signal.State(<span class=\"hljs-number\">0<\/span>)\n\n<span class=\"hljs-keyword\">get<\/span> visibleIndex() {\n\u00a0\u00a0<span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-keyword\">this<\/span>.#visibleIndex.get()\n}\n\n<span class=\"hljs-keyword\">set<\/span> visibleIndex(value: number) {\n\u00a0\u00a0<span class=\"hljs-keyword\">this<\/span>.#visibleIndex.set(value)\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-2\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>There is a much more concise way to define the above example which involves <a href=\"https:\/\/www.typescriptlang.org\/docs\/handbook\/release-notes\/typescript-4-9.html#auto-accessors-in-classes\">auto accessors<\/a> and <a href=\"https:\/\/www.typescriptlang.org\/docs\/handbook\/decorators.html#accessor-decorators\">decorators<\/a>. Unfortunately, CodePen only <a href=\"https:\/\/codepen.io\/versions\/\">supports<\/a> TypeScript 4.1.3 as of writing, so I\u2019ve opted to just use long-hand syntax in the example. An <a href=\"https:\/\/github.com\/proposal-signals\/signal-polyfill?tab=readme-ov-file#combining-signals-and-decorators\">example<\/a> of the accessor syntax involving signals is also shown in the signal-polyfill proposal.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Card Component Extras<\/h2>\n\n\n\n<p>The <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/Intersection_Observer_API\">Intersection Observer API<\/a> 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.<\/p>\n\n\n\n<p>There is also a <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/Element\/keydown_event\"><code>keydown<\/code> event<\/a> handler present to also let the user navigate between the cards via keyboard presses. Depending on the key being pressed, it calls either the <code>handlePrev()<\/code> or <code>handleNext()<\/code> method to perform the navigation.<\/p>\n\n\n\n<p>Finally, while entirely optional, I also added a feature to the component that will <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/HTMLImageElement\/Image\">preload the next card in JavaScript<\/a> to improve loading times between generating new cards.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>You can use a smaller part of Lit to build web web components that still take advantage of some of it&#8217;s best features, particularly if you&#8217;re cool with Light DOM.<\/p>\n","protected":false},"author":40,"featured_media":8122,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_acf_changed":false,"sig_custom_text":"","sig_image_type":"featured-image","sig_custom_image":0,"sig_is_disabled":false,"inline_featured_image":false,"_jetpack_memberships_contains_paid_content":false,"footnotes":""},"categories":[1],"tags":[3,382,36],"class_list":["post-8102","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-blog-post","tag-javascript","tag-lit","tag-web-components"],"acf":[],"jetpack_featured_media_url":"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/12\/lit-thumb.jpg?fit=1031%2C618&ssl=1","jetpack_sharing_enabled":true,"_links":{"self":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts\/8102","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/users\/40"}],"replies":[{"embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/comments?post=8102"}],"version-history":[{"count":14,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts\/8102\/revisions"}],"predecessor-version":[{"id":8127,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts\/8102\/revisions\/8127"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/media\/8122"}],"wp:attachment":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/media?parent=8102"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/categories?post=8102"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/tags?post=8102"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}