← Back to list

Building Fast Web Components with Lit, Vite, and Vitest (Part 1)

A comprehensive guide to building, testing, and shipping Web Components based projects with Lit, in a Vite and Vitest tools powered…

Nehemie Niyomahoro · 2026-06-17 15:49 · 3 claps · 11.0 min read
#coding #web-development #lit #we-components #vites
Open on Medium ↗
Wiki topics: 💻 · Programming 🌐 · Web Development

Photo by Markus Spiske on Unsplash

Photo by Markus Spiske on Unsplash

Building Fast Web Components with Lit, Vite, and Vitest (Part 1)

A comprehensive guide to building, testing, and shipping Web Components based projects with Lit, in a Vite and Vitest tools powered environment.

In the evolving landscape of frontend development, we‘re witnessing a significant shift toward web standards and lightweight tooling. Web Components have matured into a viable, framework-agnostic approach to building reusable UI elements, and Lit has emerged as the library of choice for making this experience delightful. Combined with Vite’s lightning-fast build tooling and Vitest‘s modern testing framework, you have a stack that‘s not just performant but genuinely enjoyable to work with.

In this guide, we’ll explore each piece of this modern stack, walking through real-world patterns and best practices for building production-ready applications.

1. Web Components: The Foundation

What Are Web Components?

Web Component(s) are a suite of standardized browser technologies that enable developers to create encapsulated & reusable HTML elements using web APIs.

This standards-based development approach means that, unlike framework-based components like React or Vue, web components are framework-agnostic and can work in any environment that supports basic HTML and JavaScript.

These technologies mainly include:

  • Custom Elements: Defining new HTML tags with custom behavior, ex <counter><counter>, <counter><counter>.
  • Shadow DOM: Encapsulating styles and markup, preventing conflicts with the rest of your application.
  • HTML Templates: Defining reusable chunks of HTML that can be instantiated multiple times using <template> and <slot>

Web Components work everywhere in vanilla HTML, React, Vue, Svelte, or any framework you can name. As of 2026, framework compatibility scores are high.

The Custom Elements Lifecycle

Every custom element can hook into its own lifecycle, and we can define a custom element by extending HTMLElement and implementing lifecycle callbacks.

Main lifecycle methods include:

  • connectedCallback() : called when the element is added to the DOM
  • disconnectedCallback() : called when the element is removed from the DOM
  • attributeChangedCallback(name, oldValue, newValue) : called when an observed attribute changes
  • observedAttributes : a static getter that declares which attributes to observe for changes

Below is a minimal ES6-based example of a custom element without any library:

// ─── 01-vanilla-counter.js ───────────────────────────────────────────────────
// A plain ES6 custom element — no libraries, no build step.
// Demonstrates: shadow DOM, observed attributes, property <-> attribute sync,
// and firing a custom event when the value changes.

class VanillaCounter extends HTMLElement {
  // Attributes listed here trigger attributeChangedCallback when they change.
  static get observedAttributes() {
    return ['value', 'step', 'min', 'max'];
  }

  constructor() {
    super();

    // Encapsulate styles + markup inside a shadow root.
    this._shadow = this.attachShadow({ mode: 'open' });

    // Internal state — kept in sync with attributes.
    this._value = 0;
    this._step  = 1;
    this._min   = -Infinity;
    this._max   = Infinity;

    this._render();
  }

  // ── Lifecycle methods ───────────────────────────

  connectedCallback() {
    // Read initial attribute values once the element is in the DOM.
    this._value = Number(this.getAttribute('value') ?? 0);
    this._step  = Number(this.getAttribute('step')  ?? 1);
    this._min   = this.hasAttribute('min') ? Number(this.getAttribute('min')) : -Infinity;
    this._max   = this.hasAttribute('max') ? Number(this.getAttribute('max')) :  Infinity;
    this._render();
  }

  attributeChangedCallback(name, _old, next) {
    switch (name) {
      case 'value': this._value = Number(next); break;
      case 'step':  this._step  = Number(next); break;
      case 'min':   this._min   = Number(next); break;
      case 'max':   this._max   = Number(next); break;
    }
    this._render();
  }

  // ── JS properties (so users can do: el.value = 5) ────────────────

  get value() { return this._value; }
  set value(v) {
    this._value = this._clamp(Number(v));
    this.setAttribute('value', this._value); // reflects back to attribute
  }

  get step() { return this._step; }
  set step(v) { this._step = Number(v); this.setAttribute('step', this._step); }

  // ── Public API ─────────────────────────

  increment() { this.value = this._value + this._step; }
  decrement() { this.value = this._value - this._step; }
  reset()     { this.value = 0; }

  // ── Internals ─────────────────────────────

  _clamp(v) {
    return Math.min(this._max, Math.max(this._min, v));
  }

  /** Fire a CustomEvent so parent components can react. */
  _dispatch() {
    this.dispatchEvent(
      new CustomEvent('counter-change', {
        detail:  { value: this._value, step: this._step },
        bubbles: true,   // travels up the DOM tree
        composed: true,  // crosses shadow DOM boundaries
      })
    );
  }

  _render() {
    const atMin = this._value <= this._min;
    const atMax = this._value >= this._max;

    this._shadow.innerHTML = `
      <style>
        :host { }
        button { }
     </style>

      <button id="dec" ${atMin ? 'disabled' : ''}>−</button>
      <span class="value">${this._value}</span>
      <button id="inc" ${atMax ? 'disabled' : ''}>+</button>
      <button id="rst" class="reset">reset</button>
    `;

    // Wire up events after injecting HTML.
    this._shadow.getElementById('dec').addEventListener('click', () => {
      this.decrement();
      this._dispatch();
    });
    this._shadow.getElementById('inc').addEventListener('click', () => {
      this.increment();
      this._dispatch();
    });
    this._shadow.getElementById('rst').addEventListener('click', () => {
      this.reset();
      this._dispatch();
    });
  }
}

customElements.define('vanilla-counter', VanillaCounter);

Using it in the HTML would be

<vanilla-counter value="0" step="5" min="0" max="100"></vanilla-counter>

<script>
 document.querySelector('vanilla-counter')
  .addEventListener('counter-change', e => {
    console.log('New value:', e.detail.value);
  });
</script>

Writing the above boilerplate by hand every time is tedious, and this is

The Issue with Vanilla Web Components!

While the web platform provides powerful primitives, working with them directly can be cumbersome. The browser standards are intentionally low-level, leaving developers to implement their own state management, reactive rendering, and attribute synchronization. Writing a robust component in vanilla JavaScript requires significant boilerplate code.

And this is where Lit comes in.

2. Lit: A Lightweight Abstraction That Delights

Lit, and what makes it Special

Lit is a thin declarative layer built on top of Web Components that transforms them from clunky primitives into a pleasant developer experience. Weighing in at just 9.70 kB gzipped, roughly the same weight as Solid and 80% smaller than React, Lit proves that you don‘t need a massive framework to build sophisticated UI components.

Lit provides several key features that streamline Web Component development:

  • Reactive rendering: Automatically re-renders your component when properties change
  • Attribute reflection: Seamlessly sync properties with HTML attributes (including JSON data)
  • Declarative templates: Use tagged template literals for HTML with built-in XSS protection
  • Scoped styles: CSS encapsulation through Shadow DOM with CSS template literals
  • Lifecycle hooks: Familiar lifecycle callbacks with Lit‘s own reactive update cycle

Also, while working with Lit, you’ll frequently find yourself encountering these core decorators, functions, and patterns around authoring components and managing their state and rendering:

  • **LitElement** , the base class for all components
  • `html``` a template literal tag that creates efficient DOM descriptions
  • `css``` a template literal tag for encapsulated styles
  • **@property()** decorator that declares a reactive property
  • **@state()** decorator for internal reactive state (not exposed as an attribute)

and many more, which we’ll cover in the next sections, such as lifecycle hooks, event handling, slots, and advanced patterns.

3. Lit-Based Project Setup with Vite/ Building Your First Lit Component

Vite is a next-generation dev/build tool that leverages native ES modules in development for near-instant cold starts, and Rollup under the hood for optimised production builds. It has first-class TypeScript support and a rich plugin ecosystem.

Vite’s development server is perfect for Web Components, as it serves native ES modules with proper caching and HMR support.

Scaffold the project

npm create vite@latest my-project -- --template lit-ts
cd my-project
npm install

This gives you a TypeScript + Lit project with a working vite.config.ts out of the box. After scaffolding, your project structure will look like:

my-project/
├── index.html
├── package.json
├── public/
├── src/
│   ├── /assets
│   └── my-element.ts
│   └── index.css
├── tsconfig.json
└── vite.config.ts

Add Vitest

Though we won’t be covering testing in this part, this is how you also add Vitest and the corresponding testing util libraries.

npm install -D vitest @vitest/ui jsdom @web/test-runner-commands

Install testing utilities for Lit

npm install -D @open-wc/testing

@open-wc/testing exports fixture, html, and a set of assertion helpers that understand the Shadow DOM. It is built on top of Chai and works seamlessly with Vitest.

Final package.json (Similar)

{
  "name": "counter-project",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "lit": "^3.3.2"
  },
  "devDependencies": {
    "typescript": "~6.0.2",
    "vite": "^8.0.12"
  }
}

tsconfig.json

We will be using Typescript for the entire article, so make sure you enable decorator support for Lit’s @property() and check your tsconfig to resemble the below setup.

{
  "compilerOptions": {
    "target": "es2023",
    "experimentalDecorators": true,
    "useDefineForClassFields": false,
    "module": "esnext",
    "lib": ["ES2023", "DOM"],
    "types": ["vite/client"],
    "skipLibCheck": true,

    /* Bundler mode */
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "verbatimModuleSyntax": true,
    "moduleDetection": "force",
    "noEmit": true,

    /* Linting */
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "erasableSyntaxOnly": true,
    "noFallthroughCasesInSwitch": true
  },
  "include": ["src"]
}

useDefineForClassFields: false is required for Lit's decorator-based reactive properties to work correctly with TypeScript's class field initialisation behaviour.

4. Your First Lit Component

4.1 Sample basic component

We are going to create src/components/lit-counter.ts a Ts & Lit-based counter component like the one we gave as an example above, with added undo functionality.


import { LitElement, html, css } from 'lit';
import { property, state } from 'lit/decorators.js';

export class LitCounter extends LitElement {
  // ── Styles (scoped to shadow DOM automatically) 
  static styles = css`
    :host { }
  `;

  // ── Reactive properties ────────────────────────
  // @property() → reflected to/from HTML attributes + triggers re-render
  // @state()    → internal only, also triggers re-render

  @property({ type: Number }) value = 0;
  @property({ type: Number }) step  = 1;
  @property({ type: Number }) min   = -Infinity;
  @property({ type: Number }) max   =  Infinity;

  // "history" is internal state — not exposed as an attribute.
  @state() private _history: number[] = [];

  // ── Computed getters ───────────────────────────
  get atMin() { return this.value <= this.min; }
  get atMax() { return this.value >= this.max; }
  get canUndo() { return this._history.length > 0; }

  // ── Template ───────────────────────────────────
  // Lit's html`` tag is efficient: it only patches the DOM parts that changed.

  render() {
    return html`
      <button ?disabled=${this.atMin} @click=${this._decrement}>−</button>
      <span class="value">${this.value}</span>
      <button ?disabled=${this.atMax} @click=${this._increment}>+</button>
      <button class="reset" @click=${this._reset}>reset</button>
      <button class="reset" ?disabled=${!this.canUndo} @click=${this._undo}>undo</button>
    `;
  }

  // ── Methods ────────────────────────────────────

  private _clamp(v: number) {
    return Math.min(this.max, Math.max(this.min, v));
  }

  private _setValue(next: number) {
    this._history = [...this._history, this.value]; // save for undo
    this.value = this._clamp(next);
    this._dispatch();
  }

  _increment() { this._setValue(this.value + this.step); }
  _decrement() { this._setValue(this.value - this.step); }
  _reset()     { this._setValue(0); }

  _undo() {
    if (!this.canUndo) return;
    const prev = this._history[this._history.length - 1];
    this._history = this._history.slice(0, -1); // immutable update → triggers re-render
    this.value = prev;
    this._dispatch();
  }

  // ── Custom event ───────────────────────────────
  // Identical event shape as the vanilla version — consumers don't care which
  // implementation they're talking to.

  private _dispatch() {
    this.dispatchEvent(
      new CustomEvent('counter-change', {
        detail:  { value: this.value, step: this.step },
        bubbles: true,
        composed: true,
      })
    );
  }

  // ── Lit lifecycle: runs once after first render 
  firstUpdated() {
    console.log('LitCounter mounted, initial value:', this.value);
  }

  // ── Lit lifecycle: runs after every update ────
  updated(changed: Map<string, unknown>) {
    if (changed.has('value')) {
      console.log('value changed →', this.value);
    }
  }
}

The same counter rebuilt with Lit. Notice how much boilerplate disappears. No manual innerHTML, no querySelector, no attributeChangedCallback. Lit handles all of it.

This component demonstrates several key Lit concepts:

  • @customElement: Registers the component with the browser
  • @property: Declares a public reactive property that syncs with attributes
  • @state: Declares internal reactive state that triggers re-renders
  • render(): Returns a declarative template using html tagged template literals
  • Lifecycle hooks: connectedCallback for initialization logic
  • Shadow DOM styles: Scoped CSS that won‘t leak out or conflict

To use it in index.html:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Lit Vite</title>
    <link rel="stylesheet" href="./src/index.css" />
    <script type="module" src="/src/index.ts"></script>
  </head>
  <body>
      <div>
        <lit-counter value="10" step="5" min="0" max="50"></lit-counter>
      </div>
  </body>
</html>

src/index.ts:

export * from './lit-counter'

Run the dev server:

npm run dev

Vite serves the page with HMR. Any change to the component file hot-reloads only the changed module.

4.2 Reactive Properties and State

Lit’s reactivity system is one of its most elegant features. You just declare a property with the @property() decorator and Lit will:

  1. Create a getter/setter pair that triggers a re-render when the value changes.
  2. Reflect the property to/from an HTML attribute (configurable).
  3. Batch multiple property changes into a single asynchronous render microtask.

Property options

@property({
  type: Number,        // Type converter for attribute → property
  reflect: true,       // Mirror property changes back to the attribute
  attribute: 'my-val', // Use a custom attribute name
  noAccessor: false,   // Set to true to skip the getter/setter
})
count = 0;

willUpdate and updated hooks

protected willUpdate(changedProperties: Map<string, unknown>) {
  // Runs synchronously before render, useful for computing derived state
}

protected updated(changedProperties: Map<string, unknown>) {
  // Runs after the DOM has been updated
}

The Reactive Update Cycle

When a reactive property (marked with @property or @state) changes, Lit schedules an asynchronous update. The full sequence is:

  1. Property change: A reactive property is assigned a new value
  2. Update scheduled: Lit batches multiple property changes together into a microtask
  3. **shouldUpdate()** : Override to prevent unnecessary renders (return false to skip)
  4. **willUpdate()** : Called before rendering; ideal for computing derived state
  5. **render()** : Returns the new template; only changed DOM nodes are patched
  6. **updated()** : Called after the DOM has been updated; ideal for imperative DOM work
  7. **updateComplete** — A Promise That resolves when the full update cycle finishes

This fine-grained reactivity model updates only the parts of the DOM that have changed; no virtual DOM diffing required.

Real-world example: async data fetching

Here is a more complete component that shows @property, @state, lifecycle hooks, and conditional rendering working together:

import { LitElement, html, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';

interface User {
  name: string;
  email: string;
}

@customElement('user-profile')
export class UserProfile extends LitElement {
  static styles = css`
    :host { }
  `;
  @property({ type: String }) userId = '';
  @state() private loading = true;
  @state() private user: User | null = null;

  connectedCallback() {
    super.connectedCallback();
    this._fetchUser();
  }

  private async _fetchUser() {
    try {
      const response = await fetch(`/api/users/${this.userId}`);
      this.user = await response.json();
    } catch (error) {
      console.error('Failed to fetch user:', error);
    } finally {
      this.loading = false;
    }
  }

  render() {
    if (this.loading) return html`<div>Loading...</div>`;
    if (!this.user)   return html`<div>User not found</div>`;
    return html`
      <div class="name">${this.user.name}</div>
      <div class="email">${this.user.email}</div>
      <slot></slot>
    `;
  }
}

Notice how:

  • @property declares userId as part of the public API, settable via attribute or property
  • @state fields (loading, user) are private; changes that trigger re-renders but are not reflected in attributes
  • Rendering is conditional: Lit re-evaluates the full render() return value on every update and only patches the DOM nodes that changed

5. Styles and Shadow DOM

Shadow DOM encapsulates styles completely — component styles cannot leak out, and external styles cannot leak in (unless you explicitly allow them via CSS custom properties or ::part()).

css tagged template

static styles = css`
  :host {
    /* Styles the element itself */
    display: block;
    border: 1px solid var(--border-color, #e5e7eb);
    border-radius: 0.5rem;
    padding: 1rem;
  }
  :host([hidden]) {
    display: none;
  }
  :host([variant='danger']) .title {
    color: #dc2626;
  }
`;

CSS Custom Properties — the theming API

Because CSS variables pierce the Shadow DOM boundary, they are the standard theming mechanism for Web Components:

// Component definition
static styles = css`
  button {
    background-color: var(--button-bg, #3b82f6);
    color: var(--button-color, #ffffff);
    border-radius: var(--button-radius, 0.375rem);
  }
`;
/* Consumer's global CSS */
my-button {
  --button-bg: #7c3aed;
  --button-radius: 9999px; /* pill shape */
}

::part() — structural theming

For deeper styling access, expose named parts:

render() {
  return html`
    <div part="container">
      <span part="label">${this.label}</span>
    </div>
  `;
}
/* Consumer */
my-badge::part(label) {
  font-weight: bold;
  letter-spacing: 0.05em;
}

Sharing styles between components

When multiple components share the same base styles, extract them into a shared module and compose them using an array:

// src/styles/shared.ts
import { css } from 'lit';

export const resetStyles = css`
  *, *::before, *::after { box-sizing: border-box; }
  :host { display: block; }
`;
export const typographyStyles = css`
  :host { font-family: sans-serif; line-height: 1.5; }
`;
// In your component
import { resetStyles, typographyStyles } from '../styles/shared.js';

static styles = [
  resetStyles,
  typographyStyles,
  css`
    /* Component-specific overrides */
    :host { padding: 1rem; }
  `,
];

If you need to import an external .css file (e.g., from a design system), use unsafeCSS:

import sharedStyles from './shared.css?inline';
import { unsafeCSS } from 'lit';

static styles = [
  unsafeCSS(sharedStyles),
  css`:host { padding: 1rem; }`,
];

*unsafeCSS bypasses Lit's style-sanitisation — only use it with CSS you control or fully trust.*

6. Events and Component Communication

Dispatching custom events

  private _dispatch() {
    this.dispatchEvent(
      new CustomEvent('counter-change', {
        detail:  { value: this.value, step: this.step },
        bubbles: true,
        composed: true,
      })
    );
  }

***composed: true* is required for events to bubble out of the Shadow DOM into the light DOM where parent components can listen.

Listening to events

render() {
  return html`
      <lit-counter
        value="10"
        step="5"
        min="0"
        max="50"
        @counterChange="${this._handleInput}"
      >
      </lit-counter>
  `;
}

The @event Syntax in Lit templates is shorthand for addEventListener. Lit automatically removes and re-adds listeners only when they change, keeping memory usage clean.

Parent → child communication via properties

// Parent template
html`<my-list .items=${this.data} ?loading=${this.isLoading}></my-list>`
  • .prop=${value} — set a property (not an attribute) — use for complex objects, arrays, functions
  • ?attr=${bool} — set/remove a boolean attribute
  • attr=${value} — set a string attribute

Next up:

Part 2 — Testing with Vitest, Advanced Patterns & Production Build →


메타데이터
post_id
db2f91bf451f
slug
building-fast-web-components-with-lit-vite-and-vitest-part-1-db2f91bf451f
url
https://medium.com/@nehemie/building-fast-web-components-with-lit-vite-and-vitest-part-1-db2f91bf451f
canonical_url
https://medium.com/@nehemie/building-fast-web-components-with-lit-vite-and-vitest-part-1-db2f91bf451f
author_url
https://medium.com/@nehemie
status
ok
fetched_at
2026-06-22 19:40:15