Web Components library development with Lit

Our company is engaged in the development, support and maintenance of sites of any complexity. From simple one-page sites to large-scale cluster systems built on micro services. Experience of developers is confirmed by certificates from vendors.
Development and maintenance of all types of websites:
Informational websites or web applications
Business card websites, landing pages, corporate websites, online catalogs, quizzes, promo websites, blogs, news resources, informational portals, forums, aggregators
E-commerce websites or web applications
Online stores, B2B portals, marketplaces, online exchanges, cashback websites, exchanges, dropshipping platforms, product parsers
Business process management web applications
CRM systems, ERP systems, corporate portals, production management systems, information parsers
Electronic service websites or web applications
Classified ads platforms, online schools, online cinemas, website builders, portals for electronic services, video hosting platforms, thematic portals

These are just some of the technical types of websites we work with, and each of them can have its own specific features and functionality, as well as be customized to meet the specific needs and goals of the client.

Our competencies:
Development stages
Latest works
  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1161
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1041
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    822
  • image_crm_chasseurs_493_0.webp
    CRM development for Chasseurs
    847
  • image_website-sbh_0.png
    Website development for SBH Partners
    999
  • image_website-_0.png
    Website development for Red Pear
    451

Developing a Web Components Library (Lit)

Lit is a Google library for creating Web Components. It weighs ~5kb gzipped and builds on top of native APIs (Custom Elements, Shadow DOM, Templates), adding reactive properties, declarative templates via tagged template literals, and automatic DOM updates.

Choosing Lit over "bare" Custom Elements is justified when there are more than 3–5 components: Lit removes boilerplate code for manual DOM updates, reactive attributes, and lifecycle management.

Installation and project structure

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

Or in an existing project:

npm install lit

Typical library structure:

src/
├── components/
│   ├── button/
│   │   ├── button.ts
│   │   ├── button.styles.ts
│   │   └── button.test.ts
│   ├── modal/
│   └── tooltip/
├── styles/
│   └── tokens.css      ← CSS custom properties
├── index.ts            ← re-export all components
└── types.ts

Basic component

import { LitElement, html, css, PropertyValues } from 'lit'
import { customElement, property, state, query } from 'lit/decorators.js'
import { classMap } from 'lit/directives/class-map.js'
import { ifDefined } from 'lit/directives/if-defined.js'

@customElement('ui-button')
export class UiButton extends LitElement {
  // Reactive properties — changes trigger re-render
  @property({ type: String })
  variant: 'primary' | 'secondary' | 'ghost' = 'primary'

  @property({ type: String })
  size: 'sm' | 'md' | 'lg' = 'md'

  @property({ type: Boolean, reflect: true })
  disabled = false

  @property({ type: Boolean })
  loading = false

  @property({ type: String })
  type: 'button' | 'submit' | 'reset' = 'button'

  // Internal state — not an attribute, not a public property
  @state()
  private _focused = false

  // Access to DOM element inside shadow
  @query('button')
  private _button!: HTMLButtonElement

  static styles = css`
    :host {
      display: inline-flex;
    }

    button {
      display: inline-flex;
      align-items: center;
      justify-content: center;
      gap: 8px;
      border: none;
      cursor: pointer;
      font-family: inherit;
      font-weight: 600;
      border-radius: var(--ui-radius, 8px);
      transition: background 0.15s, opacity 0.15s, transform 0.1s;
    }

    button:disabled {
      opacity: 0.5;
      cursor: not-allowed;
    }

    button:active:not(:disabled) {
      transform: scale(0.97);
    }

    /* Variants */
    :host([variant="primary"]) button,
    button.variant--primary {
      background: var(--ui-color-primary, #7000ff);
      color: #fff;
    }

    :host([variant="secondary"]) button,
    button.variant--secondary {
      background: transparent;
      border: 1.5px solid var(--ui-color-primary, #7000ff);
      color: var(--ui-color-primary, #7000ff);
    }

    :host([variant="ghost"]) button,
    button.variant--ghost {
      background: transparent;
      color: var(--ui-color-primary, #7000ff);
    }

    /* Sizes */
    .size--sm { padding: 6px 14px;  font-size: 13px; }
    .size--md { padding: 10px 22px; font-size: 15px; }
    .size--lg { padding: 14px 30px; font-size: 17px; }

    /* Loading spinner */
    .spinner {
      width: 1em;
      height: 1em;
      border: 2px solid transparent;
      border-top-color: currentColor;
      border-radius: 50%;
      animation: spin 0.6s linear infinite;
    }

    @keyframes spin {
      to { transform: rotate(360deg); }
    }
  `

  render() {
    const classes = classMap({
      [`variant--${this.variant}`]: true,
      [`size--${this.size}`]: true,
      'is-loading': this.loading,
    })

    return html`
      <button
        class=${classes}
        type=${this.type}
        ?disabled=${this.disabled || this.loading}
        aria-disabled=${this.disabled || this.loading}
        @focus=${() => { this._focused = true }}
        @blur=${() => { this._focused = false }}
      >
        ${this.loading ? html`<span class="spinner" aria-hidden="true"></span>` : ''}
        <slot name="icon-start"></slot>
        <slot></slot>
        <slot name="icon-end"></slot>
      </button>
    `
  }

  // Lifecycle: after first render
  protected firstUpdated() {
    this._button.addEventListener('click', this._handleClick)
  }

  // Lifecycle: after each update
  protected updated(changedProps: PropertyValues) {
    if (changedProps.has('disabled')) {
      this.setAttribute('aria-disabled', String(this.disabled))
    }
  }

  private _handleClick = (e: Event) => {
    if (this.disabled || this.loading) {
      e.preventDefault()
      e.stopPropagation()
      return
    }

    // Custom event that bubbles
    this.dispatchEvent(new CustomEvent('ui-click', {
      bubbles: true,
      composed: true,
      detail: { originalEvent: e },
    }))
  }

  // Public method (called from outside)
  focus() {
    this._button?.focus()
  }

  disconnectedCallback() {
    super.disconnectedCallback()
    this._button?.removeEventListener('click', this._handleClick)
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'ui-button': UiButton
  }
}

Reactive properties in detail

@customElement('ui-tabs')
export class UiTabs extends LitElement {
  // reflect: true — syncs JS property with HTML attribute
  @property({ type: Number, reflect: true })
  activeIndex = 0

  // converter — custom string attribute to type transformation
  @property({
    converter: {
      fromAttribute: (value: string | null) =>
        value ? value.split(',').map(Number) : [],
      toAttribute: (value: number[]) => value.join(','),
    }
  })
  selectedIds: number[] = []

  // hasChanged — skip unnecessary updates
  @property({
    hasChanged: (newVal: object, oldVal: object) =>
      JSON.stringify(newVal) !== JSON.stringify(oldVal),
  })
  config: Record<string, unknown> = {}
}

Directives in templates

import { repeat } from 'lit/directives/repeat.js'
import { cache } from 'lit/directives/cache.js'
import { asyncReplace } from 'lit/directives/async-replace.js'
import { ref } from 'lit/directives/ref.js'

render() {
  return html`
    <!-- repeat with key for efficient diff -->
    <ul>
      ${repeat(
        this.items,
        (item) => item.id,  // key
        (item) => html`<li>${item.name}</li>`
      )}
    </ul>

    <!-- cache — doesn't destroy DOM on switch -->
    ${cache(this.showDetails
      ? html`<details-panel></details-panel>`
      : html`<summary-view></summary-view>`
    )}

    <!-- ref — access to DOM element -->
    <canvas ${ref(this._canvasRef)}></canvas>
  `
}

Controllers (Reactive Controllers)

Reusable logic independent of the component:

// controllers/mouse-controller.ts
import { ReactiveController, ReactiveControllerHost } from 'lit'

export class MouseController implements ReactiveController {
  host: ReactiveControllerHost
  x = 0
  y = 0

  constructor(host: ReactiveControllerHost) {
    this.host = host
    host.addController(this)
  }

  hostConnected() {
    window.addEventListener('mousemove', this._onMouseMove)
  }

  hostDisconnected() {
    window.removeEventListener('mousemove', this._onMouseMove)
  }

  private _onMouseMove = (e: MouseEvent) => {
    this.x = e.clientX
    this.y = e.clientY
    this.host.requestUpdate()  // triggers component re-render
  }
}

// Using in a component
@customElement('cursor-tracker')
class CursorTracker extends LitElement {
  private mouse = new MouseController(this)

  render() {
    return html`
      <p>Cursor: ${this.mouse.x}, ${this.mouse.y}</p>
    `
  }
}

Building a library for publication

// package.json
{
  "name": "@myorg/ui-components",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/index.js",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "types": "./dist/index.d.ts"
    },
    "./button": {
      "import": "./dist/components/button/button.js",
      "types": "./dist/components/button/button.d.ts"
    }
  },
  "files": ["dist"],
  "customElements": "custom-elements.json"
}
// vite.config.ts for library
export default defineConfig({
  build: {
    lib: {
      entry: 'src/index.ts',
      formats: ['es'],
      fileName: 'index',
    },
    rollupOptions: {
      external: ['lit'],  // don't bundle Lit — peer dependency
    },
  },
})

Timeline

3–5 components with Lit, decorators and basic build — 1 week. Full library of 10–15 components with controllers, types, storybook documentation, tests (Playwright) and npm publication — 3–5 weeks.