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

  1. The three steps of every Lit component
  2. Hello World with LitElement
  3. Registering the component: customElements.define and @customElement
  4. Using the component as a regular HTML tag
  5. First version of <task-card>
  6. Seeing it work on the page

  1. 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:

  1. Extend LitElement: you create a JavaScript class that inherits from LitElement, the base class provided by Lit.
  2. Implement render(): inside that class you define a render() method that returns the component's HTML template, using the html function.
  3. 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.

  1. 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. LitElement is the base class that every Lit component inherits from; html is 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 from LitElement. From this point on, HolaMundo already 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!

    `; }: the render()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 the HolaMundo class 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.

  1. Registering the component: customElements.define and @customElement

The 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.

  1. 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 have type="module", because the file uses import, 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.

  1. First version of <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>

  1. 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 to import, the first thing to check is that the <script> loading the file has the type="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 is task-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, with document.querySelector); its only responsibility is to return the template via html. 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 of customElements.define ('task-card') that defines how it will be used in the HTML.

Exercises

  1. 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.
  2. Add a second instance of <task-card> to the same index.html, right below the first one. Watch in the browser that two identical cards appear, each an independent instance of the component.
  3. Rewrite the task-card.js component from section 5 using the @customElement decorator instead of customElements.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>
<task-card></task-card>
<task-card></task-card>

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

Module 2: Reactive Templates and Rendering

Module 3: Reactive Properties and State

Module 4: Styling Lit Components

Module 5: Events and Component Communication

Module 6: Lifecycle and Advanced Behavior

Module 7: Directives and Advanced Template Features

Module 8: Integration, Interoperability and Deployment

Module 9: Testing and Best Practices

Module 10: Project: Building TaskFlow

© Copyright 2026. All rights reserved