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
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.
- 📦 Repo: https://github.com/sen-ltd/portfolio-app-lit
- 🌐 Live: https://sen.ltd/portfolio/portfolio-app-lit/
- 🏢 Company: https://sen.ltd/
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)