VOOZH about

URL: https://dev.to/sendotltd/lit-3-port-970-kb-on-par-with-solid-because-web-components-are-legitimately-viable-now-2041

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


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>
 `
 }
}

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.