A resource for design system and engineering teams
Lit andStorybookstarter.
One button, every seam tested.
Install, run one command, read the output.
A starter is only useful if it runs.
Most web component starters stop at a counter that renders. The hard parts come later: a button inside a shadow root that does not submit its form, a manifest that lists private fields, a Storybook build that silently drops the element, tests in a simulated DOM that cannot see computed styles. This starter is small on purpose, one component, and it covers those seams with tests that run in a real browser.
The component is ds-button, the same one Field Guides 13 and 14 use. It reads Field Guide 03's semantic tokens through the shadow boundary, follows Field Guide 14's scoping rules (the checker reports zero findings on its styles), and ships the @lit/react wrapper that Field Guide 13's matrix recommends for React 18 consumers.
Every version and output in this guide comes from copying the kit to a clean directory, running npm install against the public registry and then npm run check: build, 19 tests, server render, Storybook build and a guard on the static build. It exited 0 on 24 September 2026.
A starting point, not a framework. One component, one theme pair, one browser in CI; add Firefox and WebKit instances before you rely on it. @lit-labs/ssr is a Lit Labs package. Prepared with AI assistance and edited by hand.
Start here
What is in the box, and how to run it.
Copy the kit's lit-storybook-starter folder, then install and check. The first run may need npx playwright install chromium.
| File | What it does |
|---|---|
src/ds-button.ts | The Lit 3 component: form-associated, focus-delegating, token-driven. |
src/ds-button.styles.ts | Shadow styles in three @layers; semantic tokens and --_ state only. |
src/tokens/semantic.css | Field Guide 03's semantic tokens, light and dark. Loaded once, in the document. |
src/react/index.ts | The @lit/react wrapper, 17 lines. |
src/ds-button.stories.ts | Seven stories, one with a play function; every story is also a test. |
test/ds-button.test.ts | Twelve browser tests: tokens, theme, forms, focus, React. |
custom-elements-manifest.config.mjs | Analyzer config with a public-API-only plugin. |
scripts/ssr-render.mjs | Renders the button with @lit-labs/ssr and measures the HTML. |
scripts/verify-storybook.mjs | Fails if the static Storybook build lost an element registration. |
terminal
npm install # 222 packages, 15 s on the verification run
npm run check # build, analyze, test, ssr, build-storybook, verify
npm run storybook # http://localhost:6006| Label | Meaning |
|---|---|
KIT TEST | Proven by a test in test/ or a story, in Chromium 153. |
BUILD | Proven by a build step in npm run check exiting 0. |
SPEC | Follows from a platform specification or a tool's own documentation. |
PRACTICE | A working method with a review signal rather than a hard check. |
Section 01
The component: small, native, token-driven.
What ds-button does that a first-draft Lit button usually does not.
Suggested owners: Component owner
Tokens come from the document
The component reads
--ds-color-bg-brandand friends;semantic.csssets them on:rootand[data-theme="dark"]. No token import inside the shadow root.Evidence: Tests read the computed background:
rgb(18, 91, 208)in light,rgb(128, 173, 255)afterdata-theme="dark", with no re-render.Field Guide 14, rules T01 and T02.
Variants switch private properties on
:hosttoneandsizereflect to attributes, and:host([tone="destructive"])sets--_bgand--_fg. The inner button reads only--_*.Evidence: Test:
tone="destructive"computesrgb(196, 43, 43). The Field Guide 14 checker reports 0 findings onds-button.styles.ts.Field Guide 14, T04.
It is form-associated
static formAssociated = trueandattachInternals(). Activation callsform.requestSubmit()orform.reset();formDisabledCallbackhonours a disabled fieldset.requestSubmit()runs without a submitter, so the button adds no name or value to the form data.Evidence: Tests: submits its light-DOM form; does not submit while loading; disabled by a disabled fieldset;
el.formreturns the form.web.dev, more capable form controls; MDN ElementInternals (S11, S12).
A control test proves why
A naive element with a native
<button type="submit">in its shadow root does not submit the outer form. Keep that test: it documents the reason for L03 and fails loudly if the platform ever changes.Evidence: Test "a submit button inside a shadow root does not submit the outer form" passes.
Shoelace discussion 1057 (S16).
Focus goes to the real control
shadowRootOptionsaddsdelegatesFocus: true, soel.focus(), labels and the Tab key land on the inner button, and:focus-visiblestyles apply there.Evidence: Test: after
el.focus(),shadowRoot.activeElementis the inner button. SSR output carriesshadowrootdelegatesfocus.Lit shadow DOM docs (S02).
Loading keeps the name and says it is busy
The spinner is
aria-hiddenand sits beside the slot, not in place of it, so the label stays the accessible name. The inner button getsaria-busyandaria-disabled, and activation is blocked.Evidence: Test: loading sets
aria-busy="true"andaria-disabled="true"and submits nothing.Field Guide 02, Button contract: loading keeps the label for the accessible name.
No decorators, so any toolchain compiles it
static propertiesplusdeclarefields initialised in the constructor. NoexperimentalDecorators, no dependency on how a compiler implements decorators, anduseDefineForClassFields: falsekeeps the fields reactive.Evidence:
tscfrom TypeScript 7.0.2 and Vite 8.3.0 both compile the component with no decorator settings.Lit reactive properties docs (S01).
The element carries the behaviour. Everything else is packaging.
Section 02
The manifest: the API that tools read.
custom-elements.json feeds Storybook's docs table, IDEs, Field Guide 13's JSX type generator and any agent reading your library. Make it describe the public API and nothing else.
Suggested owners: Component owner + design-system lead
Generate it on every build
cem analyzewithlitelement: truein the config, run bybuild,test,storybookandbuild-storybook. PointcustomElementsinpackage.jsonat it so tools find it.Evidence:
@custom-elements-manifest/analyzer: Created new manifest.in everynpm run check.CEM analyzer docs (S07).
Write unions inline
With
declare tone: ButtonTone, the manifest records the textButtonToneand every consumer loses the allowed values. Written inline, the manifest carries"primary" | "secondary" | "ghost" | "destructive"; export aliases from the class for TypeScript users.Evidence: The starter's first draft produced
ButtonTone; the manifest and Storybook's docs table now list the four values.Observed in the kit run.
Publish the public API only
The analyzer lists
#internals,#onClickand static fields, and Storybook shows them as controls. An 11-linepackageLinkPhaseplugin drops#privateandprivatemembers.Evidence: Manifest members after the plugin: tone, size, type, disabled, loading, form, formDisabledCallback.
Observed in the kit run; plugin API (S07).
Document slots and parts in JSDoc
@summary,@slotand@csspart controlon the class. They become the docs page description, the slots table and the parts table, and Field Guide 14's rule T16.Evidence: Storybook's docs page lists one part,
control, with its description.S07; Custom Elements Manifest schema (S08).
If the manifest is wrong, every tool that reads it is wrong in the same way.
Section 03
Storybook 10: docs and tests in one.
Storybook 10 is ESM-only and builds web components with Vite. These checks are what it took to trust its output.
Suggested owners: Design-system lead + component owner
Use the web-components-vite framework
framework: "@storybook/web-components-vite", stories in CSF with Lithtmltemplates,tags: ["autodocs"]for the docs page.Evidence:
Storybook build completed successfully: 8 index entries (7 stories and a docs page).Storybook web components docs (S03).
Load the manifest in preview
setCustomElementsManifest(manifest)in.storybook/preview.ts. In 10.6.0 that alone fills the docs table; issue 33038 reported a regression in 10.0.7.Evidence: The static docs page shows descriptions, tone values, defaults and the part, all from the manifest.
Kit run; Storybook issue 33038 (S06).
Make axe a failing test
a11y: { test: "error" }in preview parameters, with@storybook/addon-a11y. axe sees inside open shadow roots.Evidence: A temporary story with an empty
<ds-button>failed withButtons must have discernible text (button-name)(axe 4.13); removed afterwards.Storybook accessibility testing (S05).
Run every story as a test, on Vitest 4
@storybook/addon-vitestturns each story and play function into a browser test. Its 10.6.0 release peers Vitest^3 || ^4, so npm refuses Vitest 5.0.1 (ERESOLVE). The starter pins 4.1.11.Evidence:
stories (chromium): 7 of 7 pass, including the form-submit play function.Storybook Vitest addon docs (S04); npm peer dependency metadata.
Pre-bundle Lit once
Without it, story tests log
Multiple versions of Lit loaded. Addinglitandlit/directive-helpers.jstooptimizeDeps.includeinviteFinalremoves the warning.Evidence: No multiple-versions warning on two clean runs with the setting.
Lit multiple-versions message (S13).
Docs, controls and tests come from the same stories. Keep it that way.
Section 04
Tests: a real browser, and the seams.
The shadow boundary changes styling, focus and forms. Those are exactly the things a simulated DOM cannot compute, so the tests run in Chromium.
Suggested owners: Component owner
Run component tests in a real browser
Vitest browser mode with the Playwright provider, headless Chromium. Two projects:
unitfortest/**andstoriesfor the story files.Evidence:
Test Files 2 passed (2),Tests 19 passed (19).Vitest browser mode (S09).
Assert computed styles, not class names
Read
getComputedStyle(control).backgroundColorafter mounting in the document with the token sheet loaded. That proves the token reached the shadow root, which a class assertion cannot.Evidence: Three token tests: light, dark and destructive, each an exact
rgb().Field Guide 14, T01.
Test React both ways
The wrapper sets props and forwards
onClick. React 19 without a wrapper setstoneandloadingas properties. If the element is defined after render, React 19 writes attributes instead; strings andtruesurvive.Evidence: Three React tests on React 19.3.0, including
loading=""before definition andel.loading === trueafter.React 19 custom element support (S15); Field Guide 13, N01 and N02.
Expect the dev-mode warning in tests only
Vite's dev server resolves Lit's development build, which logs
Lit is in dev mode. Production builds use the default production build. The warning in test output is expected; in a production bundle it is a bug.Evidence: The warning appears in
vitestoutput; the string is absent from every static Storybook asset.Lit development and production builds (S13).
Test the seams where the platform behaves differently, and nothing else twice.
Section 05
Shipping: what consumers install.
The package is the product. Keep React out of non-React installs and the element in every bundle.
Suggested owners: Release owner
An
exportsmap with four doors.,./react,./tokens.cssand./custom-elements.json. Nothing else is importable.Evidence: Field Guide 13's
usage.react19.tsxandusage.react18.tsxtype-check againstds-lit-starterandds-lit-starter/react.Kit
package.json.sideEffectsnames every file that registers, and a guard proves itA list without
src/ds-button.tslet the bundle dropcustomElements.define: the static Storybook showed plain text while every dev-mode story test passed.verify-storybook.mjsnow fails any build that never registers a manifest element.Evidence: Bad list:
ds-button never registered, exit 1. Fixed list:1 element(s) registered.Observed in the kit run; bundler
sideEffectssemantics (S14).React is an optional peer
An optional peer, imported only from
./react. A Vue or CMS consumer installs Lit and@lit/react, never React.Evidence:
dependencieslists onlylitand@lit/react.Field Guide 13, W03.
Smoke-test server rendering
@lit-labs/ssrrenders the built element to declarative shadow DOM. Measure it: styles are inlined per instance.Evidence:
npm run ssr: 3,087 bytes of HTML for one button, 2,671 of them its inline<style>;delegatesFocusserialized.Lit SSR overview (S10); Field Guide 14, T22.
One command in CI
npm run checkis the gate: build, analyze, 19 tests, SSR, Storybook build and the registration guard. The same command runs locally, in CI and in an agent's verify step.Evidence: Exit 0 on the verification run of 24 September 2026.
Field Guide 03, R21.
A green test run in dev mode says nothing about the production bundle.
Appendix A
The component and its wrapper.
Excerpts from src/ds-button.ts and src/react/index.ts. The styles are in Field Guide 14, Appendix B.
src/ds-button.ts (excerpt)
export class DsButton extends LitElement {
static formAssociated = true;
static override shadowRootOptions: ShadowRootInit = {
...LitElement.shadowRootOptions, delegatesFocus: true };
static override properties = {
tone: { reflect: true }, loading: { type: Boolean, reflect: true } };
declare tone: "primary" | "secondary" | "ghost" | "destructive";
declare loading: boolean;
readonly #internals: ElementInternals; // = this.attachInternals()
#onClick(event: MouseEvent) {
if (this.disabled || this.loading || this.#formDisabled) {
event.preventDefault(); event.stopImmediatePropagation(); return;
}
if (this.type === "submit") this.#internals.form?.requestSubmit();
else if (this.type === "reset") this.#internals.form?.reset();
}
override render() {
return html`<button part="control" type="button"
?disabled=${this.disabled || this.#formDisabled} @click=${this.#onClick}
aria-busy=${this.loading ? "true" : nothing}>${spinner}<slot></slot></button>`;
}
}Condensed for print; the full file has all five properties with JSDoc, aria-disabled while loading and a guarded customElements.define.
src/react/index.ts
import * as React from "react";
import { createComponent } from "@lit/react";
import { DsButton } from "../ds-button.js";
export const Button = createComponent({
tagName: "ds-button",
elementClass: DsButton,
react: React,
events: {},
displayName: "Button",
});Appendix B
Storybook and Vitest configuration.
The two files that took the most iterations; .storybook/preview.ts is three lines, covered by SB02 and SB03. Comments name the problem each setting solves.
.storybook/main.ts
const config: StorybookConfig = {
stories: ["../src/**/*.stories.ts"],
addons: ["@storybook/addon-docs", "@storybook/addon-a11y", "@storybook/addon-vitest"],
framework: "@storybook/web-components-vite",
core: { disableTelemetry: true },
// One pre-bundled copy of Lit (SB05).
viteFinal: async (config) => ({
...config,
optimizeDeps: {
...config.optimizeDeps,
include: [...(config.optimizeDeps?.include ?? []), "lit", "lit/directive-helpers.js"],
},
}),
};vitest.config.ts
const chromium = () => ({ // a fresh object per project, or Vitest
enabled: true, headless: true, // rejects the duplicate "chromium" project
provider: playwright(),
instances: [{ browser: "chromium" as const }],
});
export default defineConfig({
test: {
projects: [
{ test: { name: "unit", include: ["test/**/*.test.ts"], browser: chromium() } },
{ plugins: [storybookTest({ configDir: ".storybook" })],
test: { name: "stories", browser: chromium() } },
],
},
});Appendix C
The verification run, and its versions.
npm run check on a clean copy of the kit, 24 September 2026. Test names are Vitest's verbose output.
npx vitest run --reporter=verbose (names only)
✓ unit ds-button > renders a native button with the slotted label as its name
✓ unit ds-button > reads semantic tokens through the shadow boundary
✓ unit ds-button > follows the theme without re-rendering
✓ unit ds-button > maps tone to semantic tokens, not raw values
✓ unit ds-button > submits the surrounding light-DOM form when type is submit
✓ unit ds-button > does not submit while loading, and says so to assistive tech
✓ unit ds-button > is disabled by a disabled fieldset
✓ unit ds-button > moves focus to the inner control (delegatesFocus)
✓ unit a submit button inside a shadow root does not submit the outer form
✓ unit React > the @lit/react wrapper sets props and forwards clicks
✓ unit React > React 19 sets custom element properties without a wrapper
✓ unit React > React 19 falls back to attributes when the element is defined after render
✓ stories Primary, Secondary, Ghost, Destructive, Loading, Theme Matrix, Submits Its Form
Test Files 2 passed (2)
Tests 19 passed (19)
ssr: 3087 bytes of HTML for one button, 2671 of them its inline <style>
storybook: 1 element(s) registered in the static build (ds-button)The seven story tests are folded into one line here; Vitest prints one line each.
| Package | Version | Role |
|---|---|---|
lit | 3.3.3 | Component base class and templates |
@lit/react | 1.0.8 | React wrapper |
@lit-labs/ssr | 4.1.0 | Server-rendering smoke test (Labs) |
storybook and @storybook/* | 10.6.0 | web-components-vite, addon-docs, addon-a11y, addon-vitest |
@custom-elements-manifest/analyzer | 0.11.0 | custom-elements.json |
vitest, @vitest/browser-playwright | 4.1.11 | Browser tests (pinned below 5, SB04) |
playwright | 1.63.0 | Headless Chromium 153 |
vite | 8.3.0 | Dev server and Storybook builder |
typescript | 7.0.2 | Build and declarations |
react, react-dom | 19.3.0 | React tests; optional peer for consumers |
Keep with the repository
Leave a starter adoption record.
When you fork the starter into your system, record what you changed and what still proves it works.
Sources / maintenance
Keep the guide current.
Sources checked 24 September 2026. Versions are what npm install resolved from registry.npmjs.org that day; no lockfile ships, so later installs may resolve newer patches.