The moment has come to write real code. In this lesson you will create your first component with Lit, starting with the classic "Hello World" and quickly moving on to something more meaningful within this course: the first version of <task-card>, the component that will represent an individual task in TaskFlow. This first version will be deliberately simple — with no properties or dynamic data — in order to focus on understanding the basic mechanics of a Lit component: how the class is defined, how its template is declared, and how it is registered as a usable HTML tag.
Contents
- The three steps of every Lit component
- Hello World with LitElement
- Registering the component:
customElements.defineand@customElement - Using the component as a regular HTML tag
- First version of
<task-card> - Seeing it work on the page
- The three steps of every Lit component
Every component made with Lit, however complex it may become later in the course, is always built following the same three steps:
- Extend
LitElement: you create a JavaScript class that inherits fromLitElement, the base class provided by Lit. - Implement
render(): inside that class you define arender()method that returns the component's HTML template, using thehtmlfunction. - Register the element: you give the component a tag name via
customElements.define(or the equivalent decorator@customElement), so the browser knows which class to use when it finds that tag in the HTML.
The rest of this lesson develops each of these three steps with concrete examples.
- Hello World with LitElement
Let's start with the simplest possible example. Inside the taskflow project created in the previous lesson, create a file src/components/hola-mundo.js with the following content:
import { LitElement, html } from 'lit';
class HolaMundo extends LitElement {
render() {
return html`<p>¡Hola Mundo desde Lit!</p>`;
}
}
customElements.define('hola-mundo', HolaMundo);Let's go through each line:
import { LitElement, html } from 'lit';: this imports the two pieces that will be used.LitElementis the base class that every Lit component inherits from;htmlis a special function (a tagged template literal, a standard JavaScript feature) that lets you write HTML in a readable way inside JavaScript code.class HolaMundo extends LitElement { ... }: this declares a normal JavaScript class that inherits fromLitElement. From this point on,HolaMundoalready has all the capabilities that Lit offers (reactive rendering, lifecycle, etc.), even though this first example does not use any of the more advanced ones.render() { return html\¡Hola Mundo desde Lit!
`; }: therender()method is the heart of any Lit component. Lit calls it automatically whenever the component needs to be shown (or updated) and expects it to return the result of thehtml` function. Here, it simply returns a paragraph with a fixed piece of text.customElements.define('hola-mundo', HolaMundo);: this line registers theHolaMundoclass as the implementation of the<hola-mundo>tag. From this moment on, any<hola-mundo>that appears in the page's HTML will be "activated" by the browser using this class.
An important detail about the html function: although at first glance it looks like a simple string, it is not. html is a tagged template literal that Lit parses in a special way, which lets it, among other things, know exactly which parts of the template are fixed and which are dynamic (this is exploited in depth in the reactive templates module). For now, with a completely static template like this one, it is enough to know that the content between the backticks (`) is the HTML that will be shown.
- Registering the component:
customElements.define and @customElement
customElements.define and @customElementThe call to customElements.define('hola-mundo', HolaMundo) is not a Lit peculiarity: it is the standard way in which the Custom Elements specification (seen in the previous lesson) registers any custom element, with or without Lit involved. Lit does not replace this mechanism, it simply uses it.
There is an alternative way of writing this registration, using a decorator, if the project has decorator support (as is the case with the Vite Lit template seen in the previous lesson):
import { LitElement, html } from 'lit';
import { customElement } from 'lit/decorators.js';
@customElement('hola-mundo')
class HolaMundo extends LitElement {
render() {
return html`<p>¡Hola Mundo desde Lit!</p>`;
}
}Both forms are equivalent: @customElement('hola-mundo') is simply a more concise way of writing, right above the class, the same thing that customElements.define('hola-mundo', HolaMundo) does after it. The following table summarizes when each style is appropriate:
| Style | Advantage | When to use it |
|---|---|---|
customElements.define(...) |
Requires no special build configuration; works in plain JavaScript with no extra steps | Plain JavaScript projects, without Babel/TypeScript configured for decorators |
@customElement(...) |
More concise and readable; keeps the tag name attached to the class definition | TypeScript projects, or JavaScript projects with Babel configured to support decorators (like the Vite template) |
An important requirement, common to both styles and inherited directly from the Custom Elements standard: the tag name must always contain at least one hyphen (hola-mundo, task-card, user-avatar...). This rule exists to guarantee that custom tags never collide with the browser's current or future native tags, which never contain a hyphen.
This course will mostly use customElements.define, since it is the form that works with no additional configuration, showing @customElement as an alternative when it adds clarity.
- Using the component as a regular HTML tag
Once registered, <hola-mundo> is used exactly like any native HTML tag. There is no need to "mount" it through a special function or pass it anything through JavaScript for it to appear: it is enough to write it in the HTML.
<!DOCTYPE html> <html lang="es"> <head> <meta charset="UTF-8"> <title>TaskFlow</title> <script type="module" src="/src/components/hola-mundo.js"></script> </head> <body> <h1>Mi primera prueba con Lit</h1> <hola-mundo></hola-mundo> </body> </html>
Two details to keep in mind:
- The
<script>that loads the component must havetype="module", because the file usesimport, a feature of standard JavaScript ES modules. - The
<hola-mundo></hola-mundo>element can be placed anywhere in the<body>, as many times as you like, just as you would with a<div>or a<button>. Each occurrence of the tag creates an independent instance of the component.
- First version of
<task-card>
<task-card>With the basic mechanics now clear, it's time to create TaskFlow's first real component: <task-card>. In this lesson it will be a deliberately simple and fully static version: it will always show the same sample content, with no configurable property and no dynamic data. The ability to customize each card with real data (title, status, assigned person...) will come with reactive properties, in module 3.
Create the file src/components/task-card.js:
import { LitElement, html } from 'lit';
class TaskCard extends LitElement {
render() {
return html`
<article>
<h3>Preparar la demo del sprint</h3>
<p>Estado: En curso</p>
<p>Asignada a: Ana</p>
</article>
`;
}
}
customElements.define('task-card', TaskCard);This component follows exactly the same three steps seen in section 1: it inherits from LitElement, implements render() returning an html template (in this case with several nested tags: an <article> wrapping a heading and two paragraphs), and registers itself with customElements.define under the name task-card.
To use it, you can create a small test page, index.html, at the root of the project (if you are using the Vite structure, replace the sample content it includes by default):
<!DOCTYPE html> <html lang="es"> <head> <meta charset="UTF-8"> <title>TaskFlow</title> <script type="module" src="/src/components/task-card.js"></script> </head> <body> <h1>TaskFlow</h1> <task-card></task-card> </body> </html>
- Seeing it work on the page
With the development server running (npm run dev, as explained in the previous lesson) and the browser open at the local URL, you should see the card rendered on the page. A useful exercise at this point is to open the browser's developer tools (F12) and inspect the <task-card> element: if Shadow DOM were not active (we have not used it explicitly yet, since we have not called attachShadow nor used the template inside a render with Lit's own Shadow DOM), you would see the content of the <article> as direct children of the element in the regular DOM ("light DOM").
It is important to clarify a nuance here: although this example did not mention it explicitly, LitElement does use Shadow DOM by default to render the content returned by render(). That is, when inspecting <task-card> in the developer tools, you will actually see a special #shadow-root tag inside the element, with the <article> hanging from it, not directly from <task-card>. This is exactly what is expected: it is the default behavior of any Lit component. The next topic in this same module explains in more detail what this implies and why it is so; for now, if you see that #shadow-root in the inspector, it is confirmation that everything is working correctly.
Common Mistakes and Tips
- Forgetting
type="module"on the<script>: if the component does not appear and the browser console shows an error related toimport, the first thing to check is that the<script>loading the file has thetype="module"attribute. - Tag name without a hyphen:
customElements.define('taskcard', TaskCard)will throw a runtime error, because the standard requires at least one hyphen in the name. The correct form istask-card. - Registering the same name twice: if the component definition code runs more than once (for example, due to a bundler configuration error that duplicates the module), the browser will throw an error stating that name is already defined. The usual solution is to make sure each component is defined in a single file that is imported only once.
- Expecting
render()to modify the DOM directly:render()must not modify the DOM by hand (for example, withdocument.querySelector); its only responsibility is to return the template viahtml. It is Lit that takes care of applying that template to the DOM efficiently. - Confusing the HTML tag with the class name: the class name (
TaskCard) can be any valid JavaScript identifier and does not need hyphens or a direct relationship to the tag name; it is the first argument ofcustomElements.define('task-card') that defines how it will be used in the HTML.
Exercises
- Create a
<user-avatar>component that shows, in a fully static way, the text "AC" inside a<span>wrapped in a<div>(representing, for now, the initials of a fixed user). Register it and use it on your test page. - Add a second instance of
<task-card>to the sameindex.html, right below the first one. Watch in the browser that two identical cards appear, each an independent instance of the component. - Rewrite the
task-card.jscomponent from section 5 using the@customElementdecorator instead ofcustomElements.define, assuming your project has decorator support (the Vite Lit template does).
Solutions
import { LitElement, html } from 'lit';
class UserAvatar extends LitElement {
render() {
return html`
<div>
<span>AC</span>
</div>
`;
}
}
customElements.define('user-avatar', UserAvatar);<script type="module" src="/src/components/user-avatar.js"></script> ... <user-avatar></user-avatar>
When you reload the page you will see two cards with exactly the same content ("Preparar la demo del sprint"), because for now the component is static and does not receive any different data per instance. This limitation is precisely what reactive properties will solve in module 3.
import { LitElement, html } from 'lit';
import { customElement } from 'lit/decorators.js';
@customElement('task-card')
class TaskCard extends LitElement {
render() {
return html`
<article>
<h3>Preparar la demo del sprint</h3>
<p>Estado: En curso</p>
<p>Asignada a: Ana</p>
</article>
`;
}
}The behavior in the browser is identical to the version with customElements.define; only the writing style changes.
Conclusion
In this lesson you created your first Lit component following the three steps that will repeat throughout the whole course: inheriting from LitElement, implementing render() with the html function, and registering the component with customElements.define (or its equivalent @customElement). With that foundation, you created the first version of <task-card>, still fully static, with no properties or dynamic data: it always shows the same sample task.
You may have noticed that, when inspecting the component in the browser, a #shadow-root appears that Lit creates automatically. In the next lesson, "Anatomy of a Lit Component," we will look in detail at what this means, how a class that extends LitElement is structured internally, and we will offer a first overview of a component's lifecycle, before jumping, in module 2, into the reactive templates that will let <task-card> stop being static.
Lit Course
Module 1: Introduction to Lit and Web Components
- What are Web Components and why Lit?
- Setting Up the Development Environment
- Your First Lit Component
- Anatomy of a Lit Component
Module 2: Reactive Templates and Rendering
- Lit's Template Engine
- Expressions and Interpolation in Templates
- Conditional Rendering
- List Rendering
- The Rendering Cycle
Module 3: Reactive Properties and State
- Reactive Properties
- Internal State with @state
- Types of Properties and Custom Converters
- Attributes vs Properties and Reflection
Module 4: Styling Lit Components
- Encapsulated CSS with Shadow DOM
- Shared Styles Between Components
- Custom CSS Properties and Theming
- Slots and Styling Distributed Content
Module 5: Events and Component Communication
- Handling DOM Events in Templates
- Custom Events: Communication from Child to Parent
- Communication from Parent to Child with Properties
- Communication Patterns Between Sibling Components
Module 6: Lifecycle and Advanced Behavior
- Lifecycle Callbacks
- Reactive Hooks: willUpdate, updated, and firstUpdated
- Reactive Controllers
- Mixins and Composing Behavior
Module 7: Directives and Advanced Template Features
- Built-in Directives: classMap, styleMap and ifDefined
- Custom Directives
- Asynchronous Rendering with until
- Shared Context with @lit/context
Module 8: Integration, Interoperability and Deployment
- Using Lit Components in Plain HTML
- Integrating Lit with React, Vue, and Angular
- Server-Side Rendering with @lit-labs/ssr
- Bundling, Publishing, and TypeScript
Module 9: Testing and Best Practices
- Unit Tests with Web Test Runner
- Accessibility in Web Components
- Performance and Optimization
- Common Patterns and Anti-patterns
