The last lesson of the previous module closed with a promise: with TaskFlow functionally complete —<task-board> as orchestrator and context provider, <task-list> filtering and rendering with repeat, <task-card> with its badges, its avatar, and its calculated urgency, and <task-filter> reading and writing the shared context—, it is time to see how this same application integrates with the rest of the world: plain HTML, other frameworks, server-side rendering, and bundling. This lesson starts with the simplest case of all, and precisely because of that, the most revealing one: using a Lit component inside an ordinary HTML page, with no Vite project behind it, no build step at all, no application framework. If any of this comes as a surprise, that is a sign it is worth reviewing why it is possible before seeing how it is done.
Contents
- Why a Lit component works in any HTML
- Loading Lit and a component with no build step
<script type="module">from a CDN: unpkg and esm.sh- The limitation of attributes: why writing
tareas="..."is not enough - Setting complex properties from plain JavaScript
- Complete example:
<task-card>in a static HTML page - When a project with a build step is still needed
- Why a Lit component works in any HTML
Lesson 01-01 already hinted at this idea, without developing it yet: a Lit component, once defined with customElements.define(...), is a standard Custom Element, exactly the same kind of object as <video> or <details>. Lit does not invent its own component registration mechanism, nor does it require any special runtime for a custom element to work; it relies, at every moment, on the customElements API that any modern browser implements natively. The practical consequence is as direct as it is surprising for anyone coming from an application framework: any HTML page that loads the JavaScript code of a Lit component can use that component's tag, with no need for React, no need for Vue, no need for any compiler or bundler.
This is not a design coincidence, but Lit's explicit goal since the very first lesson of this course: to produce components that are, above all, web platform standards, and for the convenience layer Lit adds on top (reactive templates, efficient updates, static properties) to disappear at runtime, leaving only the resulting Custom Element. The entire Vite project used throughout the course so far has been a development convenience —hot reload, module resolution, a local server—, not a requirement of Lit itself in order to work in a browser.
- Loading Lit and a component with no build step
The TaskFlow Vite project used in all the previous lessons resolves imports (import { LitElement, html, css } from 'lit') through node_modules, processing them with a bundler before serving them to the browser. A plain HTML page, with no development server behind it, has no such intermediate step: the browser needs to receive a valid URL directly for every module it imports. Modern browsers already know how to run native ES modules with <script type="module">, and already know how to resolve imports with a relative or absolute path, but they do not know, by themselves, which file corresponds to the name 'lit' with no further context; that is exactly the job a bundler does with node_modules, and which is completely absent from a page with no build.
There are two ways to fix this absence: point directly to a CDN that serves Lit already bundled with a full URL, or generate a bundle of one's own component (including Lit inside it) with the tools covered in lesson 08-04. This lesson focuses on the first option, the most immediate one for a one-off use case —a demo, a documentation page, an isolated widget— that does not justify setting up a full build project.
<script type="module"> from a CDN: unpkg and esm.sh
<script type="module"> from a CDN: unpkg and esm.shThe two most common CDNs for consuming npm packages directly as ES modules, with no local installation step at all, are unpkg and esm.sh. Both take a package published on npm (such as lit) and serve it already converted into an ES module that a browser can import directly by URL:
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<title>TaskFlow — Demo sin build</title>
</head>
<body>
<task-card titulo="Revisar propuesta" estado="pendiente" prioridad="2"></task-card>
<script type="module">
import { LitElement, html, css } from 'https://esm.sh/lit@3';
class TaskCard extends LitElement {
static properties = {
titulo: { type: String },
estado: { type: String },
prioridad: { type: Number },
};
render() {
return html`
<article>
<h3>${this.titulo}</h3>
<p>Estado: ${this.estado} · Prioridad: ${this.prioridad}</p>
</article>
`;
}
static styles = css`
article {
border: 1px solid #ccc;
border-radius: 8px;
padding: 1rem;
}
`;
}
customElements.define('task-card', TaskCard);
</script>
</body>
</html>This file, saved as index.html and opened directly in a browser (or served by any minimal HTTP server, including plain python -m http.server), renders <task-card> with no package.json, no dependency installation, and no prior compilation step at all. The browser downloads https://esm.sh/lit@3 the moment it executes the import, exactly as it would download any other external resource, and from that point on LitElement, html, and css are available to the rest of the script, just as in the course's Vite project.
The practical difference between unpkg and esm.sh is mostly a matter of URL format and resolution options: esm.sh tends to generate ES modules that are more directly usable with no extra parameters, whereas unpkg serves the content exactly as published in the npm package, which sometimes requires pointing explicitly to the file of the build already in ES module format (for example, https://unpkg.com/lit@3/index.js?module). For a one-off use case, either one works; what matters is always pinning a specific version (lit@3, not just lit), so the page does not change behavior without warning if the package publishes a new version with incompatible changes.
- The limitation of attributes: why writing
tareas="..." is not enough
tareas="..." is not enoughThe example in the previous section uses only string- or number-type attributes (titulo, estado, prioridad), and that is exactly why it works with no additional nuance: HTML is, by definition, a language of text attributes, and Lit automatically converts those strings to the type declared in static properties (as explained in lesson 03-03 on type converters). The problem appears as soon as a complex property —an array, an object— needs to be set directly from the HTML markup, something TaskFlow constantly needs: <task-list> receives a whole array of tasks through its tareas property.
<!-- Esto NO funciona como cabría esperar -->
<task-list tareas="[{"id":"t1","titulo":"Diseñar"}]"></task-list>Nothing technically prevents writing an attribute with a string containing hand-serialized JSON, with quotes escaped as HTML entities ("), but it is a fragile and awkward solution to maintain, and it also requires the component itself to invoke JSON.parse(...) on the attribute's value before it can be used as a real array, a step none of TaskFlow's components implement (tareas is declared as { type: Array }, and Lit's default converter for Array does not do that job of deserializing JSON automatically and safely for any arbitrary structure). The underlying limitation is the same one that already appeared, from a different angle, in lesson 03-04: HTML attributes are always text strings; JavaScript properties can be any value the language allows, including objects and arrays, but they are only accessible from code, not from the HTML markup itself.
- Setting complex properties from plain JavaScript
The solution, exactly as within Lit itself, is to set the property directly in JavaScript, not as a markup attribute:
<task-list id="lista-principal"></task-list>
<script type="module">
const lista = document.querySelector('#lista-principal');
lista.tareas = [
{ id: 't1', titulo: 'Diseñar la base de datos', estado: 'hecha', prioridad: 2 },
{ id: 't2', titulo: 'Implementar autenticación', estado: 'progreso', prioridad: 3 },
{ id: 't3', titulo: 'Escribir pruebas de integración', estado: 'pendiente', prioridad: 1 },
];
</script>document.querySelector('#lista-principal') returns the actual instance of the <task-list> element already connected to the DOM, and lista.tareas = [...] directly assigns the reactive tareas property, exactly the same mechanism triggered by Lit when a template uses .tareas="${...}" with the dot prefix (seen for the first time in lesson 05-03). There is no underlying difference between assigning a property from a Lit template inside another component and assigning it directly from a standalone line of JavaScript in an HTML page: in both cases, Lit detects the value change through the same reactive properties mechanism studied since module 3, and schedules a new render if appropriate.
This is, ultimately, the general criterion for deciding between an attribute and a property when integrating a Lit component into plain HTML: any value that is naturally a string, a number, or a simple boolean can be written directly as an attribute in the markup; any value that is an object, an array, a function, or any structure without a reasonable textual representation needs to be assigned as a property from JavaScript, after obtaining a reference to the element with document.querySelector or the equivalent.
- Complete example:
<task-card> in a static HTML page
<task-card> in a static HTML pageCombining the two previous techniques, here is what a completely static HTML page would look like, with no Vite project or build step at all, showing a single TaskFlow <task-card> with mixed data —some as attributes, one (a callback function) as a property assigned from JavaScript—:
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<title>TaskFlow — Tarjeta suelta</title>
</head>
<body>
<task-card
id="tarjeta-demo"
titulo="Revisar propuesta de cliente"
estado="progreso"
prioridad="3"
></task-card>
<script type="module">
import 'https://esm.sh/lit@3';
import './task-card.js';
const tarjeta = document.querySelector('#tarjeta-demo');
// asignadoImagen is an optional URL; it is set as a property
// because, even though it is a string, in this specific case it is
// best to decide its value dynamically in JavaScript before displaying it.
tarjeta.asignadoImagen = obtenerImagenDeUsuarioActual();
function obtenerImagenDeUsuarioActual() {
return localStorage.getItem('avatar-url') ?? undefined;
}
</script>
</body>
</html>./task-card.js here is a project's own file (not a third-party component), which defines <task-card> exactly as it has been built throughout the course, with the only difference being that its own lit imports must also resolve against a CDN URL if the file is served as-is, with no prior build step (something lesson 08-04 will explain how to avoid, generating a self-contained bundle instead). The standalone import 'https://esm.sh/lit@3';, capturing no value, is not necessary if task-card.js already imports Lit on its own from the same URL; it is included here only to make the dependency explicit in the example.
- When a project with a build step is still needed
Nothing above invalidates the Vite project used throughout the rest of the course: for serious TaskFlow development, with several components importing one another, with hot reload during development, with type checking (if TypeScript is chosen, as will be seen in lesson 08-04), and with a build process that optimizes the final result, a project with a build step remains the right choice. The case in this lesson —plain HTML loading components from a CDN— has its place in more limited scenarios: a quick demo, a documentation page showing an isolated component, a widget that a third party needs to be able to embed on its own site without depending on TaskFlow's build chain, or simply a way to check that an exported component works just as well outside the project that originated it. Recognizing which scenario applies, in each case, avoids both setting up an unnecessary build project for a one-off demo and trying to maintain an entire complex application through a bunch of standalone <script type="module"> tags with no tooling in between.
Common Mistakes and Tips
- Trying to pass an array or an object as a text attribute: as explained in section 4, HTML attributes are always strings; a complex property must be assigned from JavaScript (
elemento.propiedad = valor), never written serialized by hand inside an attribute, unless the component itself explicitly implements that deserialization. - Forgetting to pin a specific version when importing from a CDN: writing
https://esm.sh/litwith no version at all can, at some point, serve a different version from the one that was tested, with unexpected behavior changes; always pinninglit@3(or the exact version that applies) avoids that risk. - Trying to access the property before the element is defined: if the script that assigns
elemento.tareas = [...]runs beforecustomElements.define('task-list', TaskList)has run (for example, due to an incorrect script order), the assignment still works thanks to the Custom Elements upgrade mechanism (Lit remembers values assigned before the definition and applies them as soon as the element registers), but it is best not to rely on this detail for new code and to always keep the logical order: defining the component before manipulating its instances. - Serving the page with
file://instead of a minimal HTTP server: some browsers restrict ES moduleimportwhen the page is opened directly from the filesystem, with no server behind it; a minimal HTTP server such asnpx serveorpython -m http.serversolves this problem in seconds and avoids confusing CORS errors.
Exercises
- Write a plain HTML page, with no Vite project, that loads Lit from
https://esm.sh/lit@3, defines a<contador-simple>component with a state propertyvalor(initialized to0) and a button that increments it on every click, and displays it in its template. - A teammate, integrating
<task-list>into a plain HTML page, writes<task-list tareas="3"></task-list>expecting to display three tasks, and is surprised that it does not work. Explain, based on section 4, why that expression fails to achieve the expected effect and what should be written instead. - Revisit the example from section 6 and modify the script so that, instead of assigning
asignadoImagensynchronously, it assigns it after a call tofetch(...)that takes some time to resolve. Does Lit's reactive properties mechanism still work just as well in that case? Justify your answer based on what was studied about the render cycle in lesson 02-05.
Solutions
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<title>Contador simple</title>
</head>
<body>
<contador-simple></contador-simple>
<script type="module">
import { LitElement, html } from 'https://esm.sh/lit@3';
class ContadorSimple extends LitElement {
static properties = {
valor: { state: true },
};
constructor() {
super();
this.valor = 0;
}
incrementar() {
this.valor++;
}
render() {
return html`
<p>Valor: ${this.valor}</p>
<button @click="${this.incrementar}">Incrementar</button>
`;
}
}
customElements.define('contador-simple', ContadorSimple);
</script>
</body>
</html>tareas="3"sets a text attribute with the value"3", not a property holding an array of three elements; as explained in section 4, HTML has no syntax for directly expressing "an array with three tasks" inside an attribute, and Lit'sArraytype converter does not interpret"3"as an instruction to generate three tasks of any kind. To achieve the expected effect, a reference to the element must be obtained from JavaScript and the property assigned directly, as in section 5:document.querySelector('task-list').tareas = [tarea1, tarea2, tarea3].- Yes, it still works exactly the same way: Lit's reactive properties mechanism, studied since module 3, does not depend on the assignment happening synchronously right when the page loads; it reacts to any assignment to the property at any point in the component's lifetime, whether it is connected from the start or connects later on. As soon as the
fetch(...)promise resolves and the script executestarjeta.asignadoImagen = ..., Lit detects the value change and schedules a new render through the same asynchronous microtask cycle explained in lesson 02-05, exactly as if that same assignment had happened inside another Lit component instead of in a standalone script on a plain HTML page.
Conclusion
This lesson has shown that a Lit component needs no special environment in order to work: since it ultimately is a standard Custom Element, it is enough to load its code —from a CDN or from a project's own file— on any HTML page in order to use it normally, with the only nuance being that complex properties (arrays, objects) must be assigned from JavaScript, not written as markup attributes. This is the simplest case of interoperability, but not the only one: the next lesson goes one step further and examines how a Lit component integrates into applications built with other frameworks —React, Vue, and Angular—, each with its own set of frictions when dealing with Custom Elements that their own component models do not fully anticipate.
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
