Build a Design System That Actually Scales: Lit Web Components, Scoped Styles, Slots, and Decorators

Your React component library doesn’t work in Vue. Your Vue components are useless in Angular. You’ve been rebuilding the same button, the same card, the same modal — three times, for three frameworks, diverging slowly until they’re unrecognizable. That’s not a design system. That’s technical debt with a logo.

Web Components have been "the answer" for over a decade, but raw browser APIs are genuinely painful to work with. The boilerplate alone is enough to make you quit. That’s where Lit comes in — it’s a thin, fast layer on top of native Web Components that gives you reactive properties, declarative templates, and the full Shadow DOM, without the framework lock-in.

The official repo is at github.com/lit/lit. Keep it open.

This guide covers exactly what you need to build a real, production-grade design system: scoped styles that don’t leak, slots for flexible composition, and TypeScript decorators that eliminate boilerplate. Not theory — actual patterns you can ship.

Why Lit and Not Just React

React is a great application framework. It’s a poor choice for a design system that needs to work across products, teams, and technology stacks. When you ship a React component library, you’re also shipping React as a peer dependency, dragging along its version constraints, its reconciler, its entire mental model.

Lit components are custom HTML elements. They work in React, Vue, Angular, Svelte, and plain HTML. No wrapper libraries. No interop shims. You define <my-button> once, and it works everywhere browsers work. That’s the actual promise of the web platform, and Lit is the most pragmatic way to collect on it.

Bundle size is also in a different league. The entire Lit runtime is ~6KB gzipped. That’s not a rounding error compared to React’s ~40KB.

Project Setup

Bootstrap a TypeScript + Lit project:

npm create vite@latest my-design-system -- --template vanilla-ts
cd my-design-system
npm install lit

For a real design system you’ll want a monorepo (Turborepo or pnpm workspaces), Storybook for the catalog, and a build step with Rollup or Vite’s library mode. But for this tutorial, Vite’s dev server is enough to see everything working.

Configure tsconfig.json to enable decorators — they’re still at stage 3 and need explicit opt-in:

{
  "compilerOptions": {
    "target": "ES2022",
    "useDefineForClassFields": false,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": false,
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true
  }
}

useDefineForClassFields: false is non-negotiable here. Leave it true and Lit’s reactive property system breaks silently because class fields override the property accessors that Lit installs. This one trips up almost everyone the first time.

Your First Lit Component

// src/components/ds-button.ts
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';

@customElement('ds-button')
export class DsButton extends LitElement {
  @property({ type: String })
  variant: 'primary' | 'secondary' | 'ghost' = 'primary';

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

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

    button {
      padding: 0.5rem 1.25rem;
      border-radius: 6px;
      border: none;
      cursor: pointer;
      font-family: inherit;
      font-size: 0.875rem;
      font-weight: 500;
      transition: opacity 0.15s;
    }

    :host([disabled]) button {
      opacity: 0.5;
      cursor: not-allowed;
      pointer-events: none;
    }

    :host([variant='primary']) button {
      background: var(--ds-color-primary, #2563eb);
      color: var(--ds-color-on-primary, #ffffff);
    }

    :host([variant='secondary']) button {
      background: transparent;
      border: 1.5px solid var(--ds-color-primary, #2563eb);
      color: var(--ds-color-primary, #2563eb);
    }

    :host([variant='ghost']) button {
      background: transparent;
      color: var(--ds-color-primary, #2563eb);
    }

    button:hover {
      opacity: 0.85;
    }
  `;

  render() {
    return html`
      <button ?disabled=${this.disabled}>
        <slot></slot>
      </button>
    `;
  }
}

Use it in HTML:

<ds-button variant="primary">Save changes</ds-button>
<ds-button variant="secondary" disabled>Cancel</ds-button>

That’s the skeleton. Now let’s go deep on each of the three pillars.

Scoped Styles and the Shadow DOM

The styles defined in static styles live inside the component’s Shadow DOM. They cannot escape. External CSS cannot reach in — except for CSS custom properties, which pierce Shadow boundaries by design.

This is the contract:

  • Isolation by default: your .button class won’t collide with Bootstrap’s .button. Ever.
  • :host: targets the custom element itself from within its own shadow root. Use it to control the element’s external presentation — display, width, margin.
  • :host([attr]): targets the host when a specific attribute is present. This is how you style reflected boolean props and variant selectors.
  • ::slotted(selector): targets slotted children. Limited — you can only style direct slotted children, not their descendants.

Gotcha: Inherited Styles Still Inherit

Shadow DOM is not a total wall. CSS inherited properties — color, font-family, font-size, line-height — flow right through the shadow boundary from parent to host. That’s usually what you want (your button picking up the page’s font), but it means your component isn’t as isolated as it might feel. If you want to prevent inheritance, explicitly reset in :host:

:host {
  font-family: var(--ds-font-family, system-ui);
}

Theming via CSS Custom Properties

Shadow DOM’s one intentional hole is CSS custom properties. They’re inherited, so they cross boundaries. This is your theming API:

/* In your global stylesheet or :root */
:root {
  --ds-color-primary: #7c3aed;
  --ds-color-on-primary: #ffffff;
  --ds-border-radius: 8px;
  --ds-font-family: 'Inter', system-ui;
}

Components read these with a fallback for when the token isn’t defined:

button {
  background: var(--ds-color-primary, #2563eb);
  border-radius: var(--ds-border-radius, 6px);
}

This is the right way to expose theming in a Web Component design system. Don’t expose CSS parts via ::part() for every internal element — that’s an escape hatch, not a theming system. Use it sparingly, for elements where the consumer genuinely needs arbitrary control.

CSS ::part() — Use Carefully

If you do need to let consumers style internals:

render() {
  return html`<button part="button"><slot></slot></button>`;
}
/* In the consumer's stylesheet */
ds-button::part(button) {
  letter-spacing: 0.05em;
  text-transform: uppercase;
}

The rule is: expose part names as public API. Document them. Don’t change them without a semver bump. They’re as much a public surface as your property names.

Slots: The Composition Primitive

Slots are how Web Components compose. They’re the equivalent of React’s children prop, but more powerful because you can have multiple named slots.

Default Slot

render() {
  return html`<button><slot></slot></button>`;
}

The consumer puts content between the tags and it renders inside <slot>:

<ds-button>
  <svg>...</svg>
  Pay now
</ds-button>

Named Slots

// src/components/ds-card.ts
import { LitElement, html, css } from 'lit';
import { customElement } from 'lit/decorators.js';

@customElement('ds-card')
export class DsCard extends LitElement {
  static styles = css`
    :host {
      display: block;
      border-radius: var(--ds-border-radius, 8px);
      border: 1px solid var(--ds-color-border, #e2e8f0);
      overflow: hidden;
      background: var(--ds-color-surface, #ffffff);
    }

    .header {
      padding: 1rem 1.5rem;
      border-bottom: 1px solid var(--ds-color-border, #e2e8f0);
      font-weight: 600;
    }

    .body {
      padding: 1.5rem;
    }

    .footer {
      padding: 0.75rem 1.5rem;
      border-top: 1px solid var(--ds-color-border, #e2e8f0);
      background: var(--ds-color-surface-muted, #f8fafc);
      display: flex;
      justify-content: flex-end;
      gap: 0.5rem;
    }

    /* Hide footer wrapper when no content is slotted into it */
    .footer:not(:has(slot[name="footer"] > *)) {
      display: none;
    }
  `;

  render() {
    return html`
      <div class="header">
        <slot name="header">Untitled</slot>
      </div>
      <div class="body">
        <slot></slot>
      </div>
      <div class="footer">
        <slot name="footer"></slot>
      </div>
    `;
  }
}

Usage:

<ds-card>
  <span slot="header">Payment Details</span>

  <p>Your subscription renews on June 1st.</p>

  <ds-button slot="footer" variant="ghost">Cancel</ds-button>
  <ds-button slot="footer" variant="primary">Confirm</ds-button>
</ds-card>

Gotcha: Slot Fallback Content

The content inside <slot> tags is the fallback, rendered when nothing is projected in. In the example above, Untitled shows if the consumer doesn’t provide a header slot. This is useful — document your fallbacks so consumers know what they’re getting when they omit optional slots.

Detecting Slot Changes

Sometimes you need to know whether a slot has content so you can conditionally show wrapper elements. The ::slotchange event fires when slotted content changes:

private _handleSlotChange(e: Event) {
  const slot = e.target as HTMLSlotElement;
  const hasContent = slot.assignedNodes({ flatten: true }).length > 0;
  this.requestUpdate(); // trigger re-render if you use a state flag
}

render() {
  return html`
    <slot name="footer" @slotchange=${this._handleSlotChange}></slot>
  `;
}

The CSS :has() trick in the card example above is cleaner for simple show/hide — no JavaScript needed. But for complex conditional logic, slotchange is your tool.

Decorators: Removing the Boilerplate

Lit’s decorator API is the cleanest part of the experience. Here’s what you actually use in a design system:

@customElement

Registers the class as a custom element. Equivalent to manually calling customElements.define('ds-button', DsButton). Just use the decorator.

@property

Declares a reactive property. When the value changes, Lit schedules a re-render.

@property({ type: String })
variant: 'primary' | 'secondary' = 'primary';

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

@property({ type: Number })
count = 0;

The reflect: true option is important: it mirrors the property value back to the HTML attribute. This is what lets :host([disabled]) work in CSS. Without reflect, setting element.disabled = true in JS won’t add the disabled attribute to the DOM, and your CSS selector won’t match.

@state

Internal reactive state that doesn’t map to an attribute. Think of it as useState for a single component — consumers can’t set it from outside.

@state()
private _isOpen = false;

@state()
private _selectedIndex = -1;

Use @state for things like "is this dropdown open" or "which tab is active" — internal UI state that has no business being exposed as an attribute.

@query and @queryAll

Typed references to elements in your shadow root:

import { query, queryAll } from 'lit/decorators.js';

@query('input')
private _input!: HTMLInputElement;

@queryAll('li')
private _items!: NodeListOf<HTMLLIElement>;

Far better than calling this.shadowRoot?.querySelector('input') everywhere. You get full TypeScript types and the laziness is handled for you.

@eventOptions

Fine-grained control over event listener options:

import { eventOptions } from 'lit/decorators.js';

@eventOptions({ passive: true })
private _handleScroll(e: Event) {
  // won't block the scroll thread
}

A Complete Component: ds-input

Let’s pull it together with a component that actually exercises all three features:

// src/components/ds-input.ts
import { LitElement, html, css } from 'lit';
import { customElement, property, state, query } from 'lit/decorators.js';

@customElement('ds-input')
export class DsInput extends LitElement {
  @property({ type: String })
  label = '';

  @property({ type: String })
  placeholder = '';

  @property({ type: String })
  value = '';

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

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

  @property({ type: String })
  name = '';

  @state()
  private _focused = false;

  @query('input')
  private _inputEl!: HTMLInputElement;

  static styles = css`
    :host {
      display: block;
      font-family: var(--ds-font-family, system-ui);
    }

    .wrapper {
      display: flex;
      flex-direction: column;
      gap: 0.25rem;
    }

    label {
      font-size: 0.875rem;
      font-weight: 500;
      color: var(--ds-color-label, #374151);
    }

    :host([required]) label::after {
      content: ' *';
      color: var(--ds-color-error, #dc2626);
    }

    .input-row {
      display: flex;
      align-items: center;
      border: 1.5px solid var(--ds-color-border, #d1d5db);
      border-radius: var(--ds-border-radius, 6px);
      background: var(--ds-color-surface, #ffffff);
      transition: border-color 0.15s;
      overflow: hidden;
    }

    .input-row.focused {
      border-color: var(--ds-color-primary, #2563eb);
      outline: 2px solid color-mix(in srgb, var(--ds-color-primary, #2563eb) 20%, transparent);
    }

    :host([invalid]) .input-row {
      border-color: var(--ds-color-error, #dc2626);
    }

    input {
      flex: 1;
      border: none;
      outline: none;
      padding: 0.5rem 0.75rem;
      font-size: 0.9375rem;
      font-family: inherit;
      background: transparent;
      color: var(--ds-color-text, #111827);
    }

    input::placeholder {
      color: var(--ds-color-placeholder, #9ca3af);
    }

    /* Slots for leading/trailing icons or addons */
    .prefix, .suffix {
      display: contents;
    }

    ::slotted([slot='prefix']) {
      padding-left: 0.75rem;
      color: var(--ds-color-muted, #6b7280);
    }

    ::slotted([slot='suffix']) {
      padding-right: 0.75rem;
      color: var(--ds-color-muted, #6b7280);
    }

    .hint {
      font-size: 0.8125rem;
      color: var(--ds-color-muted, #6b7280);
    }

    :host([invalid]) .hint {
      color: var(--ds-color-error, #dc2626);
    }
  `;

  // Expose a public focus method — composable with forms
  focus() {
    this._inputEl?.focus();
  }

  private _handleInput(e: Event) {
    const target = e.target as HTMLInputElement;
    this.value = target.value;
    this.dispatchEvent(new CustomEvent('ds-change', {
      detail: { value: this.value },
      bubbles: true,
      composed: true, // cross shadow boundary
    }));
  }

  render() {
    return html`
      <div class="wrapper">
        ${this.label
          ? html`<label for="input">${this.label}</label>`
          : null}

        <div class="input-row ${this._focused ? 'focused' : ''}">
          <slot name="prefix"></slot>
          <input
            id="input"
            name=${this.name}
            placeholder=${this.placeholder}
            .value=${this.value}
            ?required=${this.required}
            @input=${this._handleInput}
            @focus=${() => { this._focused = true; }}
            @blur=${() => { this._focused = false; }}
          />
          <slot name="suffix"></slot>
        </div>

        <slot name="hint">
          <!-- consumers put helper text here -->
        </slot>
      </div>
    `;
  }
}

Usage:

<ds-input label="Email" placeholder="[email protected]" required>
  <svg slot="prefix" ...></svg>
  <span slot="hint">We'll never share your email.</span>
</ds-input>

Gotchas Worth Knowing Before You Ship

Events don’t cross shadow boundaries by default. If you dispatchEvent a native or custom event inside shadow DOM, it stops at the shadow root. Use composed: true on CustomEvent to let it bubble past. And always add bubbles: true or it won’t bubble at all. Forgetting composed: true is the #1 source of "why doesn’t my event handler fire" confusion with Web Components.

Form participation is still rough. Native <input> inside shadow DOM isn’t associated with an ancestor <form> unless you use the ElementInternals API (attachInternals()). For form-associated components (inputs, checkboxes, selects), you need extra work. It’s doable, but expect an afternoon.

SSR is possible but complicated. Declarative Shadow DOM (DSD) lets you pre-render shadow roots in HTML, and Lit has server-side rendering via @lit-labs/ssr. If your design system needs to work in Next.js or Nuxt without hydration flash, read the Lit SSR docs carefully before committing to that path.

TypeScript strict mode and decorators. You’ll see @property() trigger errors about property initializers with strictPropertyInitialization. The ! non-null assertion on @query results is expected — the query runs after render, not at construction time.

Don’t use innerHTML or unsafeHTML for user content. Lit’s html template tag is safe by default — it escapes interpolated values. The moment you switch to unsafeHTML, you own the XSS surface. Keep it tagged template literals.

Production Patterns

For a real design system, structure your exports carefully:

// src/index.ts — barrel export
export { DsButton } from './components/ds-button.js';
export { DsCard } from './components/ds-card.js';
export { DsInput } from './components/ds-input.js';

// Also export side-effect-only registration entry point
// consumers can import this to register all elements at once

Publish a separate define entrypoint that registers all custom elements. This lets consumers import your type definitions without registering elements twice if they do selective imports.

Version your CSS token names. --ds-color-primary should be stable across major versions. If you rename a token, keep the old one pointing to the new one with a deprecation comment. Token renames are breaking changes.

Document your slots, parts, events, and CSS custom properties as public API. A component’s public surface is everything a consumer can interact with from outside the shadow boundary — not just the JavaScript properties.

Write Playwright tests that actually open a browser. Shadow DOM piercing in tests is inconsistent across testing libraries. Playwright’s locator.shadowRoot() support is solid. Don’t trust unit tests that mock the DOM for Web Component behavior.


Lit won’t solve every problem in design system engineering, but it gives you the right primitives. Scoped styles mean no more global CSS battles. Slots mean flexible composition without prop drilling. Decorators mean readable, boilerplate-free component definitions. Everything runs natively in the browser, today, in every framework your organization uses or will use in five years.

That’s a better foundation than rebuilding the same button for the fourth time.

👁 Views: 112,658 · Unique visitors: 45,393