A reference for setting up a project that uses swatchkit as a pattern library, without a build framework. You write a few small build scripts yourself, and npm run build chains them into a deployable static site.
The build uses esbuild for CSS and JS bundling, so you get real parsers, source maps, and minification without writing a custom bundler.
Use this when you want a static site, total control over output, and a small dep tree. If you want HMR, code splitting, or framework-style dev experience, use a framework-driven setup instead.
Everything this guide builds by hand can be scaffolded in one command:
mkdir my-app && cd my-app
npm init -y
npm pkg set type=module private=true # the app starter is ESM — set this first
npm install -D swatchkit esbuild
npx swatchkit init --app --cssDir ./src/css
npm install
npm run devSet
"type": "module"beforeinit --app. The starter writes an ESM config (export default { … }); without"type": "module"Node loads it as CommonJS and the build fails withError: Unexpected token 'export'. If you hit that, runnpm pkg set type=modulethen re-run with--force.
swatchkit init --app creates the integrated config, the esbuild build scripts
(scripts/clean.js, build-site.js, build-assets.js), shared renderers
(src/components/button.js, card.js), a home page, two example swatches, and
a watch-enabled package.json. npm run dev then builds, watches your source,
and serves at http://localhost:8080/ (app) and /swatchkit/ (pattern
library).
The rest of this guide explains each piece so you can customize it or build it
yourself. If you used --app, treat the sections below as a reference for what
you already have. Some examples here are intentionally a little richer than the
minimal starter (e.g. a theme-toggle.js to show how shared browser-side JS
fits in) — they illustrate the pattern, not the exact generated files.
- A main app at
/— your real product UI, rendered at build time. - A pattern library at
/swatchkit/— component documentation, generated by theswatchkitCLI from your shared renderers. - Shared renderers in
src/components/, called by both the app and the swatchkit pages. One source of truth. - A
dist/directory with plain HTML, CSS, and JS — deployable to any static host (S3, Netlify, GitHub Pages, a USB stick).
my-project/
├── package.json
├── swatchkit.config.js
├── scripts/ ← your build scripts (Step 3)
│ ├── clean.js
│ ├── build-site.js
│ └── build-assets.js
├── src/
│ ├── components/ ← your renderers
│ │ ├── button.js
│ │ └── card.js
│ ├── pages/
│ │ └── home.js ← exports home() → main app HTML
│ ├── css/
│ │ ├── main.css ← your entry CSS (@imports everything)
│ │ ├── theme.css ← your app's theme overrides
│ │ ├── swatches/
│ │ │ ├── index.css
│ │ │ ├── button.css
│ │ │ └── card.css
│ │ ├── global/ ← from `swatchkit init`
│ │ ├── compositions/ ← from `swatchkit init`
│ │ └── utilities/ ← from `swatchkit init`
│ └── js/
│ ├── main.js ← entry script for the main app
│ └── theme-toggle.js ← shared init (e.g. dark-mode toggle)
├── swatchkit/ ← pattern library source
│ ├── _swatchkit.html ← layout for the library index
│ ├── _preview.html ← layout for individual previews
│ ├── swatches/
│ │ ├── button/index.js ← calls renderButton → swatch HTML
│ │ └── card/index.js ← calls renderCard → swatch HTML
│ ├── compositions/… ← from `swatchkit init`
│ ├── utilities/… ← from `swatchkit init`
│ └── tokens/… ← generated each build (token docs)
Everything in global/, compositions/, utilities/ (under both src/css/ and swatchkit/) comes from npx swatchkit init. The rest is yours.
{
"name": "my-app",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"clean": "node scripts/clean.js",
"build:site": "node scripts/build-site.js",
"build:swatchkit": "swatchkit",
"build:assets": "node scripts/build-assets.js",
"build": "npm run clean && npm run build:site && npm run build:swatchkit && npm run build:assets",
"build:prod": "npm run clean && npm run build:site && npm run build:swatchkit && node scripts/build-assets.js --prod",
"watch:site": "onchange 'src/pages/**/*' 'src/components/**/*' -- npm run build:site",
"watch:assets": "onchange 'src/css/**/*' 'src/js/**/*' 'src/components/**/*' -e 'src/css/utilities/utilities.css' -- npm run build:assets",
"swatchkit:watch": "swatchkit --watch",
"serve": "http-server dist -c-1 -p 8080 -o",
"dev": "npm run build && npm-run-all --parallel watch:site watch:assets swatchkit:watch serve"
},
"devDependencies": {
"esbuild": "^0.28.0",
"http-server": "^14.1.1",
"npm-run-all": "^4.1.5",
"onchange": "^7.1.0",
"swatchkit": "^X.Y.Z"
}
}The swatchkit version in devDependencies is whatever release of swatchkit you ran init against; bump it manually when you upgrade swatchkit.
The build chain runs in this order, and the order is load-bearing:
1. **`clean`** — wipe `dist/` (cross-platform, works on Windows too).
2. **`build:site`** — render `src/pages/home.js` → `dist/index.html` (the main app).
3. **`build:swatchkit`** — run the `swatchkit` CLI. This parses your `@swatchkit` token blocks (in `src/css/global/tokens.css` and any other `tokenSources`), regenerates `src/css/utilities/utilities.css` from them, and writes the pattern library to `dist/swatchkit/`. Your token CSS itself is never modified.
4. **`build:assets`** — esbuild bundles `src/css/main.css` → `dist/css/main.css` and `src/js/main.js` → `dist/js/main.js`. Also copies `swatchkit-ui.css` and `swatchkit-preview.css` to `dist/css/`.
`build:swatchkit` *must* run before `build:assets` — the freshly regenerated `utilities.css` needs to exist before esbuild reads `main.css` (which `@import`s it). The full build chain handles this; if you run the steps individually, keep the order.
`build:prod` is the deployable artifact: same chain, but `build-assets.js` runs with `--prod`, which turns on minification and turns off source maps. See [Source maps](#source-maps) below.
See [The development workflow](#the-development-workflow) below for what `dev`, the `watch:*` scripts, and `serve` do.
## Step 2: `swatchkit.config.js`
```js
export default {
cssDir: "./src/css",
cssCopy: false,
cssPath: "../css/",
};
cssDir— where your source CSS lives. The CLI reads this to locate your@swatchkittoken blocks and to write the generatedutilities/utilities.css.cssCopy: false— don't copy CSS intodist/swatchkit/css/. The pattern library's HTML will reference../css/main.cssinstead, so the CSS lives in one place (dist/css/) and both the app and the library point at it.cssPath— the path from the swatchkit HTML files to the shared CSS. The default (when omitted) is../<basename of cssDir>/, so this is usually just what you'd get by default. Set it explicitly only if your build puts CSS somewhere unusual.
Three small Node scripts: clean.js, build-site.js, and build-assets.js (which is just one esbuild call). Drop them in scripts/ and chain them from package.json (already done in Step 1).
Cross-platform replacement for rm -rf dist. Works on Windows, macOS, and Linux without depending on a Unix shell being available.
import fs from "node:fs";
fs.rmSync("dist", { recursive: true, force: true });
console.log("[clean] Removed dist/");Renders src/pages/home.js (which exports an HTML string) to dist/index.html. Pure rendering — no template engine, no framework. The renderer is called once at build time.
import fs from "node:fs/promises";
import path from "node:path";
import { home } from "../src/pages/home.js";
const distDir = path.resolve("dist");
await fs.mkdir(distDir, { recursive: true });
await fs.writeFile(path.join(distDir, "index.html"), home(), "utf8");
console.log("[site] Built dist/index.html");The script imports home() from your src/pages/home.js and writes its return value to dist/index.html. The whole app HTML is generated at build time — the browser never executes the renderers.
The output references the stable output names:
<link rel="stylesheet" href="./css/main.css" />
<script type="module" src="./js/main.js"></script>Replaces a hand-rolled CSS bundler and a hand-rolled JS bundler with a single esbuild call. esbuild is a real parser, so @import (with media queries, @layer, etc.), normal import/export, and third-party modules all just work. Minification and source maps are one option each.
import fs from "node:fs";
import path from "node:path";
import * as esbuild from "esbuild";
const isProduction =
process.argv.includes("--prod") || process.env.NODE_ENV === "production";
// Standalone CSS files referenced by name in the SwatchKit layouts.
// They are NOT imported by main.css, so esbuild never sees them — copy them.
const STANDALONE_CSS = ["swatchkit-ui.css", "swatchkit-preview.css"];
await esbuild.build({
entryPoints: ["src/css/main.css", "src/js/main.js"],
bundle: true,
outdir: "dist",
outbase: "src",
minify: isProduction,
sourcemap: !isProduction, // dev-only maps; omit from production output
format: "esm",
// Emit referenced assets (fonts/images via url()) next to the output
// instead of failing, in case a dependency ships them.
loader: {
".woff": "file",
".woff2": "file",
".ttf": "file",
".eot": "file",
".svg": "file",
".png": "file",
".jpg": "file",
".gif": "file",
},
logLevel: "info",
});
fs.mkdirSync(path.join("dist", "css"), { recursive: true });
for (const file of STANDALONE_CSS) {
const src = path.join("src", "css", file);
const dest = path.join("dist", "css", file);
if (fs.existsSync(src)) {
fs.copyFileSync(src, dest);
console.log(`[assets] Copied dist/css/${file}`);
}
}
console.log("[assets] Built CSS and JS with esbuild");With outbase: "src", esbuild preserves the source folder structure in the output:
dist/
├── css/
│ ├── main.css
│ ├── main.css.map (dev only)
│ ├── swatchkit-ui.css (copied)
│ └── swatchkit-preview.css (copied)
└── js/
├── main.js
└── main.js.map (dev only)
Both build and build:prod use this same script — the only difference is the --prod flag (or NODE_ENV=production), which flips minify to true and sourcemap to false.
The loader config tells esbuild to copy font and image files (referenced via url() in CSS) into the output instead of failing. If your CSS doesn't reference external assets, this is a no-op.
A source map (main.js.map) is a separate JSON file linked from the bottom of the bundle by a comment:
//# sourceMappingURL=main.js.mapKey facts:
- The map is only fetched when DevTools is open. Normal visitors never download it. It is a DevTools feature, not a runtime API — the JS engine ignores the comment during execution.
- Shipping maps therefore has zero runtime cost and does not undermine minification: the executed file is still fully minified. The map just lets a developer's DevTools show original source + names.
- The only real tradeoff is source exposure: the
.mapinlines the original source (sourcesContent), so anyone who fetches it can read your unminified code.
esbuild sourcemap modes:
| Mode | Comment in file | .map emitted |
Use |
|---|---|---|---|
true |
yes | yes | full live-site debugging |
"external" |
no | yes | maps uploaded to an error tracker (Sentry, etc.), not the server |
"inline" |
data URI | no (embedded) | bloats the shipped file — avoid for prod |
false |
no | no | no maps |
This script's default: sourcemap: !isProduction — full maps in dev, none in the production bundle. This keeps prod output clean and avoids source exposure, while keeping dev fully debuggable. If you use Sentry-style error tracking, switch the production side to "external" and upload the .map files separately.
Where your renderers live. A renderer is a pure function that takes a props object and returns an HTML string. The contract is formalized in The renderer contract in the main README — short version: pure, deterministic, no side effects, no build-tool coupling.
A renderer:
// src/components/button.js
/**
* @param {object} props
* @param {string} props.label - Visible button text
* @param {string} [props.href] - If set, renders an <a> instead of a <button>
* @param {"primary"|"outline"|"danger"} [props.variant] - Visual style (default: primary)
* @param {"small"|"large"} [props.size] - Optional size modifier
*/
export function renderButton({ label, href, variant = "primary", size }) {
const classes = ["button"];
if (variant === "outline") classes.push("outline");
else if (variant === "danger") classes.push("danger");
if (size === "small") classes.push("small");
else if (size === "large") classes.push("large");
const classStr = classes.join(" ");
if (href) {
return `<a class="${classStr}" href="${href}">${label}</a>`;
}
return `<button class="${classStr}">${label}</button>`;
}This is renderButton — the same function the app uses, the same function the swatchkit page uses. One source of truth.
Exports a home() function that returns the main app's HTML as a string. Called at build time by scripts/build-site.js.
// src/pages/home.js
import { renderButton } from "../components/button.js";
import { renderCard } from "../components/card.js";
const html = String.raw;
export function home() {
return html`<!doctype html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My App</title>
<link rel="stylesheet" href="./css/main.css" />
</head>
<body class="wrapper region flow">
<header>
<div>
<h1>My App</h1>
<p>
A real project using the same component render functions as the
SwatchKit pattern library. See
<a href="./swatchkit/">the pattern library</a>.
</p>
</div>
<button id="themeToggle" class="theme-toggle" aria-label="Toggle theme">
<svg class="moon visible" viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
<svg class="sun hidden" viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
</svg>
</button>
</header>
<section>
<h2>Buttons</h2>
<p>
These buttons use the same <code>renderButton</code> function as
the button swatch in the pattern library.
</p>
<h3>Primary Actions</h3>
<div class="cluster gap">
${renderButton({ label: "Save changes" })}
${renderButton({ label: "Continue", href: "/next" })}
</div>
<h3>Destructive</h3>
${renderButton({ label: "Delete project", variant: "danger" })}
<h3>Sizes</h3>
<div class="cluster gap">
${renderButton({ label: "Small", size: "small" })}
${renderButton({ label: "Default" })}
${renderButton({ label: "Large", size: "large" })}
</div>
<h3>Outline</h3>
<div class="cluster gap">
${renderButton({ label: "Cancel", variant: "outline", href: "/" })}
${renderButton({ label: "Confirm", variant: "outline", href: "/confirm" })}
</div>
</section>
<section>
<h2>Card Example</h2>
${renderCard({
title: "Project Alpha",
body: "A soft editorial brand for Aurora.",
ctaLabel: "View brand",
ctaHref: "/brands/aurora/",
})}
</section>
<script type="module" src="./js/main.js"></script>
</body>
</html>`;
}This is a complete example — the whole app, with header, two sections (buttons and a card), and the theme toggle wired up. The pattern is: import the renderers at the top, call them inline as template substitutions. The browser sees the resulting HTML, not the source.
For a starting project, you can trim this down to just a header and one component. The full version is here so you can see how everything fits together — copy the parts you need.
The entry stylesheet. It declares the cascade-layer order, then imports each group of styles into its layer:
@layer reset, tokens, elements, compositions, swatches, app, utilities;
@import "global/reset.css" layer(reset);
@import "global/tokens.css" layer(tokens);
@import "global/variables.css" layer(tokens);
@import "global/elements.css" layer(elements);
@import "compositions/index.css" layer(compositions);
@import "swatches/index.css" layer(swatches);
@import "utilities/index.css" layer(utilities);
/* === Your App Styles === */
@layer app {
/* @import "theme.css" layer(app); — or write rules directly here */
}global/, compositions/, and utilities/ come from swatchkit init. swatches/ and your app CSS are yours — see Step 6 for how to extend them.
Cascade layers make the cascade predictable: a later layer always wins over an earlier one, regardless of selector specificity. utilities is declared last, so a utility class in your markup wins over component and app styles without !important. Plain unlayered CSS still beats every layer — your escape hatch.
Two rules to keep in mind:
@importstatements must come before any regular style rules. Keep the@layerdeclaration and all@importlines at the top; put app CSS in the@layer app { }block.- The single
@layer reset, …, utilities;line fixes the order globally. It doesn't matter that the@layer app { }block physically appears after theutilitiesimport —utilitiesstill wins because it's later in that declaration.
esbuild reads this file as a real CSS entry: it follows the @imports (including layer(...) assignments), inlines them into one file, and supports media queries, @layer, and quoted/unquoted url() in the process.
The entry script for the main app. Imports any shared initialization and runs it:
import { initThemeToggle } from "./theme-toggle.js";
initThemeToggle();esbuild bundles this entry plus all the modules it transitively imports into a single dist/js/main.js. The browser loads it as <script type="module" src="./js/main.js"> from your home() template.
A swatch is a JS file that imports the same renderer your app uses, then exports the HTML string for that swatch's preview:
// swatchkit/swatches/button/index.js
import { renderButton } from "../../../src/components/button.js";
const html = String.raw;
export default html`
<h2>Button</h2>
<p>
This swatch uses the same <code>renderButton</code> function as
the main app at <code>src/pages/home.js</code>. Edit one place,
see the change everywhere.
</p>
<h3>Primary Actions</h3>
<div class="cluster gap">
${renderButton({ label: "Save changes" })}
${renderButton({ label: "Continue", href: "/next" })}
</div>
<h3>Outline</h3>
<div class="cluster gap">
${renderButton({ label: "Cancel", variant: "outline", href: "/" })}
${renderButton({ label: "Confirm", variant: "outline", href: "/confirm" })}
</div>
<h3>Destructive</h3>
${renderButton({ label: "Delete project", variant: "danger" })}
<h3>Sizes</h3>
<div class="cluster gap">
${renderButton({ label: "Small", size: "small" })}
${renderButton({ label: "Default" })}
${renderButton({ label: "Large", size: "large" })}
</div>
`;The relative import path is ../../../src/components/button.js — three levels up from swatchkit/swatches/button/index.js to the project root, then down into src/components/. The swatchkit CLI executes this file at build time, takes its default export, and bakes the resulting HTML into the swatch's preview page.
Once everything is in place:
npm run buildOutput (in order):
[clean] Removed dist/
[site] Built dist/index.html
[SwatchKit] Starting build…
Source: …/swatchkit
Output: …/dist/swatchkit
Parsing token blocks (src/css/global/tokens.css, …)…
Skipping CSS copy (cssCopy: false). CSS referenced at: ../css/
Scanning HTML patterns (swatchkit/**/*.html)…
Generated 20 preview pages in …/dist/swatchkit/preview
Using custom layout: …/swatchkit/_swatchkit.html
Build complete! Generated …/dist/swatchkit/index.html
[assets] Built CSS and JS with esbuild
dist/css/main.css 29.0 kB
dist/js/main.js 0.8 kB
[assets] Copied dist/css/swatchkit-ui.css
[assets] Copied dist/css/swatchkit-preview.css
For a deployable artifact:
npm run build:prodSame chain, but build-assets.js is invoked with --prod: output is minified, no source maps.
npm run build is a one-shot build. For day-to-day work, npm run dev builds
once and then watches your source and rebuilds the affected part on save,
while serving dist/ at http://localhost:8080/.
npm run devIt runs four things in parallel (via npm-run-all):
| Script | Watches | Rebuilds |
|---|---|---|
watch:site |
src/pages/**, src/components/** |
dist/index.html (re-runs build:site) |
watch:assets |
src/css/**, src/js/**, src/components/** |
dist/css/main.css + dist/js/main.js (re-runs esbuild) |
swatchkit:watch |
swatchkit/**, tokens.css |
dist/swatchkit/ + regenerates token utilities |
serve |
— | static server with caching off (-c-1) |
A few things worth knowing:
- Editing a shared renderer (e.g.
src/components/button.js) triggers bothwatch:site(the app HTML re-renders) andwatch:assets(the JS bundle rebuilds) — so the app and the bundle stay in sync. - Editing
tokens.csstriggersswatchkit:watch(regeneratesutilities.cssand the token docs) andwatch:assets(re-bundles the CSS so the new token values land indist/css/main.css). That double-trigger is intentional. watch:assetsdeliberately ignoressrc/css/utilities/utilities.css— that file is generated byswatchkit:watch, and watching it would cause a rebuild loop.- This is not hot-reload. The watchers rebuild
dist/, but the browser won't refresh on its own — reload the page to see changes. The-c-1flag ensures the reload fetches freshmain.css/main.jsrather than a cached copy. (If you want true auto-refresh, swaphttp-serverforbrowser-syncor move to a framework-driven dev server.) - It's a single parallel process group:
Ctrl+Cstops all of it. If a run fails with "port 8080 in use," a previousdevleft a server running —pkill -f http-serverclears it.
End-to-end. Let's say you're adding a "badge" component.
1. Write the renderer in src/components/.
// src/components/badge.js
export function renderBadge({ label, tone = "neutral" }) {
return `<span class="badge ${tone}">${label}</span>`;
}2. Add the component's CSS.
/* src/css/swatches/badge.css */
.badge {
display: inline-block;
padding: 0.25em 0.5em;
font-size: 0.75rem;
font-weight: 600;
border-radius: 4px;
background: var(--color-mid);
color: var(--color-light);
}
.badge.success { background: #2d8a4e; }
.badge.danger { background: #c82333; }Register it in src/css/swatches/index.css:
@import "button.css";
@import "card.css";
@import "badge.css"; /* ← new */3. Create the swatch.
// swatchkit/swatches/badge/index.js
import { renderBadge } from "../../../src/components/badge.js";
const html = String.raw;
export default html`
<h2>Badge</h2>
<p>
Inline status indicator. Same <code>renderBadge</code> function
as the main app.
</p>
<div class="cluster gap">
${renderBadge({ label: "Neutral" })}
${renderBadge({ label: "Success", tone: "success" })}
${renderBadge({ label: "Danger", tone: "danger" })}
</div>
`;4. Use it in your app's home().
import { renderBadge } from "../components/badge.js";
// …
${renderBadge({ label: "New" })}5. Build.
npm run buildThe renderer is now used in both the main app (via home()) and the swatchkit pattern library (via swatchkit/swatches/badge/index.js). Edit src/components/badge.js and both update on the next build.
The contract is formalized in The renderer contract in the main README. Short version:
- Signature:
function renderX(props) → string - Pure: no side effects, no I/O, no global state
- Deterministic: same input → same output, every time
- Self-contained output: the returned string is a complete HTML fragment
If you follow this, the same function works in your app, in the swatchkit pages, and in any future test you might write.
dist/
├── index.html ← main app (from src/pages/home.js)
├── css/
│ ├── main.css ← bundled by esbuild from src/css/main.css
│ ├── main.css.map (dev only)
│ ├── swatchkit-ui.css ← copied from src/css/
│ └── swatchkit-preview.css ← copied from src/css/
├── js/
│ ├── main.js ← bundled by esbuild from src/js/main.js
│ └── main.js.map (dev only)
└── swatchkit/
├── index.html ← pattern library index
└── preview/
├── compositions/… ← one page per composition
├── swatches/… ← one page per swatch
├── tokens/… ← token reference pages
└── utilities/… ← one page per utility
Both the main app and the swatchkit pages reference dist/css/main.css (via the relative path resolved from each page's location). The CSS file is generated once, used by both. With npm run build:prod, the .map files are omitted and the CSS/JS are minified.
- Cache-busting (opt-in). The default stable filenames (
main.css,main.js) match the references inhome()and the SwatchKit templates. For long-lived cache headers, switch to hashed names:But hashed names require extra plumbing:entryNames: "[dir]/[name]-[hash]", metafile: true,
build-site.jsmust read esbuild'smetafileto learn the hashed names before writingdist/index.html, and the SwatchKit HTML expectsmain.css. Recommend keeping stable filenames unless your deploy target clearly benefits. - Source maps in production. Switch the production side of
sourcemapto"external"and upload the.mapfiles separately to your error tracker (Sentry, etc.). The shipped bundle won't have asourceMappingURLcomment, but the tracker will use the maps to show real source in stack traces. - Different CSS/JS outputs. Add more
entryPointstobuild-assets.js— esbuild handles multiple entries with one call. Each entry gets its own output underdist/based on its path undersrc/. - A real dev server.
npm run dev(thehttp-serverline inpackage.json) is a static file server with caching disabled. For something with HMR, switch to a framework-driven setup. - More swatchkit sections. Anything you add under
swatchkit/<section>/<item>/index.{js,html}becomes a section in the sidebar automatically. See the main README's "The Magic Folder" section.
For projects that refuse any dependency (not even esbuild), here's an explicit-concat CSS approach. Instead of @import, maintain an ordered list and concatenate — no regex, no @import parsing, no parser.
// scripts/build-assets.js (zero-dep variant, CSS only)
import fs from "node:fs";
import path from "node:path";
const order = [
"global/reset.css",
"global/tokens.css",
"global/variables.css",
"global/elements.css",
"compositions/index.css",
"utilities/index.css",
"swatches/index.css",
"theme.css",
];
const out = order
.map((rel) => fs.readFileSync(path.join("src/css", rel), "utf8"))
.join("\n");
fs.mkdirSync("dist/css", { recursive: true });
fs.writeFileSync("dist/css/main.css", out);For JS, a zero-dep setup typically copies the entry verbatim to dist/js/main.js — the script can't recursively resolve import statements without a real bundler. So your src/js/main.js must not import from other local files; all initialization code lives in the entry itself.
The tradeoff: you maintain the order array instead of @import lines, and you get no minification, no source maps, no third-party resolution. Acceptable for a purist zero-dep setup; esbuild is better for anything production-ish.
mkdir my-app && cd my-app
npm init -y
npm pkg set type=module private=true # the app starter is ESM — set this first
npm install -D swatchkit esbuild
npx swatchkit init --app --cssDir ./src/css
npm install
npm run devinit --app writes the integrated config, the build scripts, shared renderers
(button, card), a home page, two example swatches, and a watch-enabled
package.json. You're running with a working app + pattern library in under a
minute. Everything it generates is described in the steps above — edit away.
The app starter is ESM, so set
"type": "module"before runninginit --app(thenpm pkg set type=moduleline above). Otherwise Node loads the generatedexport defaultconfig as CommonJS and the build fails withError: Unexpected token 'export'.
If you'd rather assemble it by hand (or understand each piece):
# 1. New project
mkdir my-app && cd my-app
npm init -y
npm pkg set type=module
# 2. Tooling
npm install -D swatchkit esbuild http-server npm-run-all onchange
# 3. SwatchKit config + scaffold (one step)
npx swatchkit init --cssDir ./src/cssThen set the integrated config:
// swatchkit.config.js
export default {
cssDir: "./src/css",
cssCopy: false,
cssPath: "../css/",
};Create the three scripts from the "Step 3" section above (scripts/clean.js, scripts/build-site.js, scripts/build-assets.js) and the package.json scripts block from "Step 1".
Create the minimum app surface:
src/
├── pages/home.js # exports home() → app HTML string
├── components/ # shared renderX(props) → HTML string
├── css/main.css # @imports global/compositions/utilities/swatches
└── js/main.js # imports + inits browser modules
swatchkit/
├── _swatchkit.html # from scaffold
├── _preview.html # from scaffold
└── swatches/<name>/index.js # imports the same renderer as the app
Build and preview:
npm run build # one-shot dev build (with source maps)
npm run dev # build + watch + serve at :8080
# or for a deployable artifact:
npm run build:prod # minified, no source mapsResult:
dist/
├── index.html # main app
├── css/main.css # bundled, both app + library link it
├── js/main.js # bundled
└── swatchkit/ # pattern library, links ../css/main.css
This setup is great for static sites with a small to moderate number of components. It starts to feel limiting when:
- You want HMR while editing components.
- You want TypeScript, JSX, or a real framework.
- You want code splitting, content-hashed asset names, or service-worker-driven caching out of the box.
- You start accepting user input at runtime and the renderers need to handle escapes, validation, or async data.
For those cases, a framework-driven setup (Vite, Astro, etc.) is the next step. The shared-renderer pattern still works — the only thing that changes is what bundles the CSS and JS.