DEV Community

SEN LLC
SEN LLC

Posted on

Lit 3 Port: 9.70 kB, on Par with Solid, Because Web Components Are Legitimately Viable Now

Lit 3 Port: 9.70 kB, on Par with Solid, Because Web Components Are Legitimately Viable Now

Web Components as a browser spec are clunky to use directly. Lit is the thin declarative layer that makes them pleasant, and for this landing page it lands at 9.70 kB gzip — roughly the same weight as Solid, and 80% smaller than React. For a Web Components-based approach, that's genuinely usable.

Entry #9 in the framework comparison series. Running scoreboard: React 49 kB, Vue 28.76, Svelte 18.92, Solid 8.33, Nuxt 52.01, SvelteKit 32.50, Qwik first-paint 28.60, Astro 3.17. Lit comes in at 9.70 kB, landing near Solid — not the smallest, but the smallest framework that produces reusable, embeddable Web Components.

🔗 Live demo: https://sen.ltd/portfolio/portfolio-app-lit/
📦 GitHub: https://github.com/sen-ltd/portfolio-app-lit

Screenshot

Lit's class-based component model

Components are classes extending LitElement, with decorators for state and props:

import { LitElement, html, css } from 'lit'
import { customElement, state, property } from 'lit/decorators.js'
import type { PortfolioData, Lang } from './types'
import { loadPortfolioData } from './data'
import { filterAndSort, type FilterState } from './filter'
import { MESSAGES, detectDefaultLang } from './i18n'

@customElement('portfolio-app')
export class PortfolioApp extends LitElement {
  @state() private data: PortfolioData | null = null
  @state() private lang: Lang = detectDefaultLang()
  @state() private filter: FilterState = {
    query: '', category: 'all', stack: 'all', stage: 'all', sort: 'number',
  }
  @state() private loading = true
  @state() private error = ''

  connectedCallback() {
    super.connectedCallback()
    loadPortfolioData()
      .then((d) => { this.data = d; this.loading = false })
      .catch((e) => { this.error = String(e); this.loading = false })
  }

  render() {
    if (this.loading) return html`<div class="state state-loading">Loading...</div>`
    if (this.error) return html`<div class="state state-error">${this.error}</div>`
    if (!this.data) return html``

    const visible = filterAndSort(this.data.entries, this.filter, this.lang)
    const m = MESSAGES[this.lang]

    return html`
      <header class="site-header">
        <h1>${m.title}</h1>
        <p class="meta">${visible.length} / ${this.data.entries.length}</p>
      </header>
      <main>
        <input
          type="text"
          .value=${this.filter.query}
          @input=${(e: Event) => this.filter = { ...this.filter, query: (e.target as HTMLInputElement).value }}
        />
        ${visible.map((entry) => html`
          <article class="card">
            <h2>${entry.name[this.lang]}</h2>
            <p>${entry.pitch[this.lang]}</p>
          </article>
        `)}
      </main>
    `
  }
}
Enter fullscreen mode Exit fullscreen mode

The tagged template literal (html\...`) is the core template mechanism.${...}injects values,@input=attaches events,.value=` binds properties. Lit uses the browser's native template API to update only the dynamic parts — no virtual DOM diffing.

Shadow DOM and stylesheet handling

Every Lit component is wrapped in a shadow DOM by default, which gives you genuine style encapsulation but blocks shared stylesheets from applying. To ship the shared style.css from the series into a Lit component:

`ts
import style from './style.css?inline'

@customElement('portfolio-app')
export class PortfolioApp extends LitElement {
static styles = css${unsafeCSS(style)}
// ...
}
`

Vite's ?inline query imports the CSS as a string; unsafeCSS wraps it as a Lit CSSResult that can participate in the shadow DOM's constructable stylesheets. A little indirect compared to React's "CSS is global, it just works" default, but the encapsulation guarantee is genuinely valuable for reusable components.

@state() and @property()

`ts
@state() private filter: FilterState = { ... } // internal state
@property() public entry: Entry // from parent
`

Equivalent to React's useState and component props. Both trigger a re-render automatically when their value changes. Decorator support requires "experimentalDecorators": true in tsconfig.json, but Vite's Lit template scaffolds this by default.

Event binding with @

`html
<input @input=${(e) => this.handleInput(e)} />
`

@input, @click, @change, and every DOM event attach via the @ prefix. One gotcha: if you write the handler as a plain class method, this binding is lost on invocation. Either use arrow functions inline (as above) or bind in the constructor. Arrow-function handlers are the idiomatic pattern.

Why Lit and Solid land near each other

Lit's runtime is about 5.5 kB gzip; plus the app code (component + shared filter/data/i18n) that's ~4.2 kB. Total 9.70 kB.

Both Lit and Solid reject the virtual DOM in favor of direct DOM updates. They use different mechanisms:

  • Solid: compiles JSX to direct DOM creation + signal-driven property updates
  • Lit: runtime parses tagged template literals and updates specific DOM positions

Compile-time vs. runtime is the architectural difference, but the feature set is identical (no VDOM, fine-grained updates), so the final bundle sizes converge.

Web Components' hidden superpower: cross-framework reuse

Lit components register as real custom elements, so <portfolio-app> works inside any page:

`html

React header

`

React, Vue, Svelte, Solid — all of them treat a Lit-defined element as an ordinary HTML tag. This is the one thing Lit does that none of the other framework ports in this series can do: produce a component you can embed in someone else's stack.

For this landing page it's irrelevant (the whole page is the component). But for a library of widgets meant to be dropped into arbitrary host applications, Lit's value proposition becomes clear. Web Components are the only framework-agnostic component standard that exists, and Lit is the most comfortable way to author them.

Shared files

`sh
$ diff repos/portfolio-app-react/src/filter.ts repos/portfolio-app-lit/src/filter.ts

no output

`

Tests

14 Vitest cases on filter.ts. Same suite as every port.

Scoreboard

Port gzip vs React
021 React 49.00 kB
022 Vue 28.76 kB −41%
023 Svelte 18.92 kB −61%
024 Solid 8.33 kB −83%
025 Nuxt 52.01 kB +7%
026 SvelteKit 32.50 kB −33%
027 Qwik 28.60 kB (first-paint) −42%
028 Astro 3.17 kB −94%
029 Lit 9.70 kB −80%

Series

This is entry #29 in my 100+ public portfolio series, and #9 in the framework comparison.

Next (and final): Preact (030). Same React source reused almost verbatim, shrunk to 8.75 kB by swapping the runtime. A very different lesson than the other eight.

Top comments (0)